Files
galaxy-game/mail/contract_openapi_test.go
T
2026-04-17 18:39:16 +02:00

284 lines
10 KiB
Go

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
}