package mail import ( "context" "encoding/json" "net/http" "path/filepath" "runtime" "testing" "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) func TestInternalOpenAPISpecValidates(t *testing.T) { t.Parallel() loadSpec(t) } func TestInternalOpenAPISpecFreezesLoginCodeDeliveryContract(t *testing.T) { t.Parallel() doc := loadSpec(t) operation := getOperation(t, doc, "/api/v1/internal/login-code-deliveries", http.MethodPost) require.Equal(t, "acceptLoginCodeDelivery", operation.OperationID) assertOperationParameterRefs(t, operation, "#/components/parameters/IdempotencyKey") assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/LoginCodeDeliveryRequest", "login-code-deliveries request schema") assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusOK), "#/components/schemas/LoginCodeDeliveryResponse", "login-code-deliveries success schema") assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusBadRequest), "#/components/schemas/ErrorResponse", "bad request schema") assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusConflict), "#/components/schemas/ErrorResponse", "conflict schema") assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusInternalServerError), "#/components/schemas/ErrorResponse", "internal error schema") assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusServiceUnavailable), "#/components/schemas/ErrorResponse", "service unavailable schema") request := componentSchemaRef(t, doc, "LoginCodeDeliveryRequest") assertRequiredFields(t, request, "email", "code", "locale") response := componentSchemaRef(t, doc, "LoginCodeDeliveryResponse") assertRequiredFields(t, response, "outcome") assertStringEnum(t, response, "outcome", "sent", "suppressed") } func TestInternalOpenAPISpecFreezesOperatorContract(t *testing.T) { t.Parallel() doc := loadSpec(t) listOperation := getOperation(t, doc, "/api/v1/internal/deliveries", http.MethodGet) require.Equal(t, "listDeliveries", listOperation.OperationID) assertOperationParameterRefs( t, listOperation, "#/components/parameters/RecipientFilter", "#/components/parameters/StatusFilter", "#/components/parameters/SourceFilter", "#/components/parameters/TemplateIDFilter", "#/components/parameters/IdempotencyKeyFilter", "#/components/parameters/FromCreatedAtMSFilter", "#/components/parameters/ToCreatedAtMSFilter", "#/components/parameters/ListLimit", "#/components/parameters/ListCursor", ) assertSchemaRef(t, responseSchemaRef(t, listOperation, http.StatusOK), "#/components/schemas/DeliveryListResponse", "deliveries list success schema") deliveryGetOperation := getOperation(t, doc, "/api/v1/internal/deliveries/{delivery_id}", http.MethodGet) require.Equal(t, "getDelivery", deliveryGetOperation.OperationID) assertOperationParameterRefs(t, deliveryGetOperation, "#/components/parameters/DeliveryIDPath") assertSchemaRef(t, responseSchemaRef(t, deliveryGetOperation, http.StatusOK), "#/components/schemas/DeliveryDetailResponse", "delivery get success schema") attemptsOperation := getOperation(t, doc, "/api/v1/internal/deliveries/{delivery_id}/attempts", http.MethodGet) require.Equal(t, "listDeliveryAttempts", attemptsOperation.OperationID) assertOperationParameterRefs(t, attemptsOperation, "#/components/parameters/DeliveryIDPath") assertSchemaRef(t, responseSchemaRef(t, attemptsOperation, http.StatusOK), "#/components/schemas/DeliveryAttemptsResponse", "delivery attempts success schema") resendOperation := getOperation(t, doc, "/api/v1/internal/deliveries/{delivery_id}/resend", http.MethodPost) require.Equal(t, "resendDelivery", resendOperation.OperationID) assertOperationParameterRefs(t, resendOperation, "#/components/parameters/DeliveryIDPath") assertSchemaRef(t, responseSchemaRef(t, resendOperation, http.StatusOK), "#/components/schemas/DeliveryResendResponse", "delivery resend success schema") assertSchemaRef(t, responseSchemaRef(t, resendOperation, http.StatusConflict), "#/components/schemas/ErrorResponse", "delivery resend conflict schema") listResponse := componentSchemaRef(t, doc, "DeliveryListResponse") assertRequiredFields(t, listResponse, "items") detailResponse := componentSchemaRef(t, doc, "DeliveryDetailResponse") assertRequiredFields(t, detailResponse, "delivery_id", "source", "payload_mode", "to", "cc", "bcc", "reply_to", "attachments", "locale_fallback_used", "idempotency_key", "status", "attempt_count", "created_at_ms", "updated_at_ms") attemptsResponse := componentSchemaRef(t, doc, "DeliveryAttemptsResponse") assertRequiredFields(t, attemptsResponse, "items") resendResponse := componentSchemaRef(t, doc, "DeliveryResendResponse") assertRequiredFields(t, resendResponse, "delivery_id") } func TestInternalOpenAPISpecErrorExamplesMatchStableErrors(t *testing.T) { t.Parallel() doc := loadSpec(t) require.JSONEq( t, `{"error":{"code":"invalid_request","message":"Idempotency-Key header must not be empty"}}`, string(mustJSON(t, responseExampleValue(t, doc, "InvalidRequestError", "missingHeader"))), ) require.JSONEq( t, `{"error":{"code":"conflict","message":"request conflicts with current state"}}`, string(mustJSON(t, responseExampleValue(t, doc, "ConflictError", "conflict"))), ) require.JSONEq( t, `{"error":{"code":"internal_error","message":"internal server error"}}`, string(mustJSON(t, responseExampleValue(t, doc, "InternalError", "internal"))), ) require.JSONEq( t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, string(mustJSON(t, responseExampleValue(t, doc, "ServiceUnavailableError", "unavailable"))), ) require.JSONEq( t, `{"error":{"code":"delivery_not_found","message":"delivery not found"}}`, string(mustJSON(t, responseExampleValue(t, doc, "DeliveryNotFoundError", "missingDelivery"))), ) require.JSONEq( t, `{"error":{"code":"resend_not_allowed","message":"delivery status does not allow resend"}}`, string(mustJSON(t, responseExampleValue(t, doc, "ResendNotAllowedError", "resendNotAllowed"))), ) } func loadSpec(t *testing.T) *openapi3.T { t.Helper() _, thisFile, _, ok := runtime.Caller(0) if !ok { require.FailNow(t, "runtime.Caller failed") } specPath := filepath.Join(filepath.Dir(thisFile), "api", "internal-openapi.yaml") loader := openapi3.NewLoader() doc, err := loader.LoadFromFile(specPath) if err != nil { require.Failf(t, "test failed", "load spec %s: %v", specPath, err) } if doc == nil { require.Failf(t, "test failed", "load spec %s: returned nil document", specPath) } if err := doc.Validate(context.Background()); err != nil { require.Failf(t, "test failed", "validate spec %s: %v", specPath, err) } return doc } func getOperation(t *testing.T, doc *openapi3.T, path string, method string) *openapi3.Operation { t.Helper() if doc.Paths == nil { require.FailNow(t, "spec is missing paths") } pathItem := doc.Paths.Value(path) if pathItem == nil { require.Failf(t, "test failed", "spec is missing path %s", path) } operation := pathItem.GetOperation(method) if operation == nil { require.Failf(t, "test failed", "spec is missing %s operation for path %s", method, path) } return operation } func requestSchemaRef(t *testing.T, operation *openapi3.Operation) *openapi3.SchemaRef { t.Helper() if operation.RequestBody == nil || operation.RequestBody.Value == nil { require.FailNow(t, "operation is missing request body") } mediaType := operation.RequestBody.Value.Content.Get("application/json") if mediaType == nil || mediaType.Schema == nil { require.FailNow(t, "operation is missing application/json request schema") } return mediaType.Schema } func responseSchemaRef(t *testing.T, operation *openapi3.Operation, status int) *openapi3.SchemaRef { t.Helper() responseRef := operation.Responses.Status(status) if responseRef == nil || responseRef.Value == nil { require.Failf(t, "test failed", "operation is missing %d response", status) } mediaType := responseRef.Value.Content.Get("application/json") if mediaType == nil || mediaType.Schema == nil { require.Failf(t, "test failed", "operation is missing application/json schema for %d response", status) } return mediaType.Schema } func componentSchemaRef(t *testing.T, doc *openapi3.T, name string) *openapi3.SchemaRef { t.Helper() if doc.Components.Schemas == nil { require.FailNow(t, "spec is missing component schemas") } schemaRef := doc.Components.Schemas[name] if schemaRef == nil { require.Failf(t, "test failed", "spec is missing component schema %s", name) } return schemaRef } func responseExampleValue(t *testing.T, doc *openapi3.T, responseName string, exampleName string) any { t.Helper() responseRef := doc.Components.Responses[responseName] if responseRef == nil || responseRef.Value == nil { require.Failf(t, "test failed", "spec is missing component response %s", responseName) } mediaType := responseRef.Value.Content.Get("application/json") if mediaType == nil { require.Failf(t, "test failed", "response %s is missing application/json content", responseName) } exampleRef := mediaType.Examples[exampleName] if exampleRef == nil || exampleRef.Value == nil { require.Failf(t, "test failed", "response %s is missing example %s", responseName, exampleName) } return exampleRef.Value.Value } func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want string, name string) { t.Helper() require.NotNil(t, schemaRef, "%s schema ref", name) require.Equal(t, want, schemaRef.Ref, "%s schema ref", name) } func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) { t.Helper() require.NotNil(t, schemaRef) require.ElementsMatch(t, fields, schemaRef.Value.Required) } func assertStringEnum(t *testing.T, schemaRef *openapi3.SchemaRef, property string, values ...string) { t.Helper() require.NotNil(t, schemaRef) propertyRef := schemaRef.Value.Properties[property] require.NotNil(t, propertyRef, "schema property %s", property) got := make([]string, 0, len(propertyRef.Value.Enum)) for _, value := range propertyRef.Value.Enum { got = append(got, value.(string)) } require.ElementsMatch(t, values, got) } func assertOperationParameterRefs(t *testing.T, operation *openapi3.Operation, refs ...string) { t.Helper() got := make([]string, 0, len(operation.Parameters)) for _, parameterRef := range operation.Parameters { got = append(got, parameterRef.Ref) } require.ElementsMatch(t, refs, got) } func mustJSON(t *testing.T, value any) []byte { t.Helper() payload, err := json.Marshal(value) require.NoError(t, err) return payload }