284 lines
10 KiB
Go
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
|
|
}
|