626 lines
24 KiB
Go
626 lines
24 KiB
Go
package notification
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type notificationCatalogExpectation struct {
|
|
producer string
|
|
audienceKind string
|
|
allowedAudienceKinds []string
|
|
payloadSchema string
|
|
requiredFields []string
|
|
}
|
|
|
|
var expectedNotificationTypeCatalog = []string{
|
|
"geo.review_recommended",
|
|
"game.turn.ready",
|
|
"game.finished",
|
|
"game.generation_failed",
|
|
"lobby.runtime_paused_after_start",
|
|
"lobby.application.submitted",
|
|
"lobby.membership.approved",
|
|
"lobby.membership.rejected",
|
|
"lobby.membership.blocked",
|
|
"lobby.invite.created",
|
|
"lobby.invite.redeemed",
|
|
"lobby.invite.expired",
|
|
"lobby.race_name.registration_eligible",
|
|
"lobby.race_name.registered",
|
|
"lobby.race_name.registration_denied",
|
|
}
|
|
|
|
var expectedNotificationCatalog = map[string]notificationCatalogExpectation{
|
|
"geo.review_recommended": {
|
|
producer: "geoprofile",
|
|
audienceKind: "admin_email",
|
|
payloadSchema: "GeoReviewRecommendedPayload",
|
|
requiredFields: []string{"user_id", "user_email", "observed_country", "usual_connection_country", "review_reason"},
|
|
},
|
|
"game.turn.ready": {
|
|
producer: "game_master",
|
|
audienceKind: "user",
|
|
payloadSchema: "GameTurnReadyPayload",
|
|
requiredFields: []string{"game_id", "game_name", "turn_number"},
|
|
},
|
|
"game.finished": {
|
|
producer: "game_master",
|
|
audienceKind: "user",
|
|
payloadSchema: "GameFinishedPayload",
|
|
requiredFields: []string{"game_id", "game_name", "final_turn_number"},
|
|
},
|
|
"game.generation_failed": {
|
|
producer: "game_master",
|
|
audienceKind: "admin_email",
|
|
payloadSchema: "GameGenerationFailedPayload",
|
|
requiredFields: []string{"game_id", "game_name", "failure_reason"},
|
|
},
|
|
"lobby.runtime_paused_after_start": {
|
|
producer: "game_lobby",
|
|
audienceKind: "admin_email",
|
|
payloadSchema: "LobbyRuntimePausedAfterStartPayload",
|
|
requiredFields: []string{"game_id", "game_name"},
|
|
},
|
|
"lobby.application.submitted": {
|
|
producer: "game_lobby",
|
|
allowedAudienceKinds: []string{"user", "admin_email"},
|
|
payloadSchema: "LobbyApplicationSubmittedPayload",
|
|
requiredFields: []string{"game_id", "game_name", "applicant_user_id", "applicant_name"},
|
|
},
|
|
"lobby.membership.approved": {
|
|
producer: "game_lobby",
|
|
audienceKind: "user",
|
|
payloadSchema: "LobbyMembershipApprovedPayload",
|
|
requiredFields: []string{"game_id", "game_name"},
|
|
},
|
|
"lobby.membership.rejected": {
|
|
producer: "game_lobby",
|
|
audienceKind: "user",
|
|
payloadSchema: "LobbyMembershipRejectedPayload",
|
|
requiredFields: []string{"game_id", "game_name"},
|
|
},
|
|
"lobby.membership.blocked": {
|
|
producer: "game_lobby",
|
|
audienceKind: "user",
|
|
payloadSchema: "LobbyMembershipBlockedPayload",
|
|
requiredFields: []string{"game_id", "game_name", "membership_user_id", "membership_user_name", "reason"},
|
|
},
|
|
"lobby.invite.created": {
|
|
producer: "game_lobby",
|
|
audienceKind: "user",
|
|
payloadSchema: "LobbyInviteCreatedPayload",
|
|
requiredFields: []string{"game_id", "game_name", "inviter_user_id", "inviter_name"},
|
|
},
|
|
"lobby.invite.redeemed": {
|
|
producer: "game_lobby",
|
|
audienceKind: "user",
|
|
payloadSchema: "LobbyInviteRedeemedPayload",
|
|
requiredFields: []string{"game_id", "game_name", "invitee_user_id", "invitee_name"},
|
|
},
|
|
"lobby.invite.expired": {
|
|
producer: "game_lobby",
|
|
audienceKind: "user",
|
|
payloadSchema: "LobbyInviteExpiredPayload",
|
|
requiredFields: []string{"game_id", "game_name", "invitee_user_id", "invitee_name"},
|
|
},
|
|
"lobby.race_name.registration_eligible": {
|
|
producer: "game_lobby",
|
|
audienceKind: "user",
|
|
payloadSchema: "LobbyRaceNameRegistrationEligiblePayload",
|
|
requiredFields: []string{"game_id", "game_name", "race_name", "eligible_until_ms"},
|
|
},
|
|
"lobby.race_name.registered": {
|
|
producer: "game_lobby",
|
|
audienceKind: "user",
|
|
payloadSchema: "LobbyRaceNameRegisteredPayload",
|
|
requiredFields: []string{"race_name"},
|
|
},
|
|
"lobby.race_name.registration_denied": {
|
|
producer: "game_lobby",
|
|
audienceKind: "user",
|
|
payloadSchema: "LobbyRaceNameRegistrationDeniedPayload",
|
|
requiredFields: []string{"game_id", "game_name", "race_name", "reason"},
|
|
},
|
|
}
|
|
|
|
const expectedNotificationCatalogTable = `| ` + "`notification_type`" + ` | Producer | Audience | Channels | Required ` + "`payload_json`" + ` fields |
|
|
| --- | --- | --- | --- | --- |
|
|
| ` + "`geo.review_recommended`" + ` | ` + "`Geo Profile Service`" + ` (` + "`geoprofile`" + `) | configured admin email list (` + "`audience_kind=admin_email`" + `) | ` + "`email`" + ` | ` + "`user_id`" + `, ` + "`user_email`" + `, ` + "`observed_country`" + `, ` + "`usual_connection_country`" + `, ` + "`review_reason`" + ` |
|
|
| ` + "`game.turn.ready`" + ` | ` + "`Game Master`" + ` (` + "`game_master`" + `) | active accepted participants (` + "`audience_kind=user`" + `) | ` + "`push+email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`turn_number`" + ` |
|
|
| ` + "`game.finished`" + ` | ` + "`Game Master`" + ` (` + "`game_master`" + `) | active accepted participants (` + "`audience_kind=user`" + `) | ` + "`push+email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`final_turn_number`" + ` |
|
|
| ` + "`game.generation_failed`" + ` | ` + "`Game Master`" + ` (` + "`game_master`" + `) | configured admin email list (` + "`audience_kind=admin_email`" + `) | ` + "`email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`failure_reason`" + ` |
|
|
| ` + "`lobby.runtime_paused_after_start`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | configured admin email list (` + "`audience_kind=admin_email`" + `) | ` + "`email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + ` |
|
|
| ` + "`lobby.application.submitted`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | private owner (` + "`audience_kind=user`" + `) or public admins (` + "`audience_kind=admin_email`" + `) | private: ` + "`push+email`" + `, public: ` + "`email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`applicant_user_id`" + `, ` + "`applicant_name`" + ` |
|
|
| ` + "`lobby.membership.approved`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | applicant user (` + "`audience_kind=user`" + `) | ` + "`push+email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + ` |
|
|
| ` + "`lobby.membership.rejected`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | applicant user (` + "`audience_kind=user`" + `) | ` + "`push+email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + ` |
|
|
| ` + "`lobby.membership.blocked`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | private-game owner (` + "`audience_kind=user`" + `) | ` + "`push+email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`membership_user_id`" + `, ` + "`membership_user_name`" + `, ` + "`reason`" + ` |
|
|
| ` + "`lobby.invite.created`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | invited user (` + "`audience_kind=user`" + `) | ` + "`push+email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`inviter_user_id`" + `, ` + "`inviter_name`" + ` |
|
|
| ` + "`lobby.invite.redeemed`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | private-game owner (` + "`audience_kind=user`" + `) | ` + "`push+email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`invitee_user_id`" + `, ` + "`invitee_name`" + ` |
|
|
| ` + "`lobby.invite.expired`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | private-game owner (` + "`audience_kind=user`" + `) | ` + "`email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`invitee_user_id`" + `, ` + "`invitee_name`" + ` |
|
|
| ` + "`lobby.race_name.registration_eligible`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | capable member (` + "`audience_kind=user`" + `) | ` + "`push+email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`race_name`" + `, ` + "`eligible_until_ms`" + ` |
|
|
| ` + "`lobby.race_name.registered`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | registering user (` + "`audience_kind=user`" + `) | ` + "`push+email`" + ` | ` + "`race_name`" + ` |
|
|
| ` + "`lobby.race_name.registration_denied`" + ` | ` + "`Game Lobby`" + ` (` + "`game_lobby`" + `) | incapable member (` + "`audience_kind=user`" + `) | ` + "`email`" + ` | ` + "`game_id`" + `, ` + "`game_name`" + `, ` + "`race_name`" + `, ` + "`reason`" + ` |`
|
|
|
|
var expectedSharedDocumentationSnippets = []string{
|
|
"`lobby.application.submitted` keeps one stable `notification_type` and one stable `payload_json` shape",
|
|
"`lobby.invite.revoked` deliberately produces no notification in v1",
|
|
"private-game invite notifications remain user-bound by internal `user_id`",
|
|
}
|
|
|
|
func TestIntentAsyncAPISpecLoads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t)
|
|
require.Equal(t, "3.1.0", getStringValue(t, doc, "asyncapi"))
|
|
}
|
|
|
|
func TestIntentAsyncAPISpecFreezesChannelAndOperation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t)
|
|
|
|
channel := getMapValue(t, doc, "channels", "intents")
|
|
require.Equal(t, "notification:intents", getStringValue(t, channel, "address"))
|
|
|
|
channelMessages := getMapValue(t, channel, "messages")
|
|
require.Equal(
|
|
t,
|
|
"#/components/messages/NotificationIntent",
|
|
getStringValue(t, getMapValue(t, channelMessages, "notificationIntent"), "$ref"),
|
|
)
|
|
|
|
operation := getMapValue(t, doc, "operations", "publishNotificationIntent")
|
|
require.Equal(t, "send", getStringValue(t, operation, "action"))
|
|
require.Equal(t, "#/channels/intents", getStringValue(t, getMapValue(t, operation, "channel"), "$ref"))
|
|
|
|
messageRefs := getSliceValue(t, operation, "messages")
|
|
require.Len(t, messageRefs, 1)
|
|
require.Equal(
|
|
t,
|
|
"#/channels/intents/messages/notificationIntent",
|
|
getStringValue(t, messageRefs[0].(map[string]any), "$ref"),
|
|
)
|
|
}
|
|
|
|
func TestIntentAsyncAPISpecFreezesEnvelopeSchema(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t)
|
|
schemas := getMapValue(t, getMapValue(t, doc, "components"), "schemas")
|
|
envelope := getMapValue(t, schemas, "NotificationIntentEnvelope")
|
|
|
|
require.ElementsMatch(
|
|
t,
|
|
[]any{
|
|
"notification_type",
|
|
"producer",
|
|
"audience_kind",
|
|
"idempotency_key",
|
|
"occurred_at_ms",
|
|
"payload_json",
|
|
},
|
|
getSliceValue(t, envelope, "required"),
|
|
)
|
|
|
|
properties := getMapValue(t, envelope, "properties")
|
|
require.ElementsMatch(
|
|
t,
|
|
[]string{
|
|
"notification_type",
|
|
"producer",
|
|
"audience_kind",
|
|
"recipient_user_ids_json",
|
|
"idempotency_key",
|
|
"occurred_at_ms",
|
|
"request_id",
|
|
"trace_id",
|
|
"payload_json",
|
|
},
|
|
mapKeys(properties),
|
|
)
|
|
|
|
notificationType := getMapValue(t, properties, "notification_type")
|
|
require.Equal(t, "string", getStringValue(t, notificationType, "type"))
|
|
require.Equal(t, expectedNotificationTypeCatalog, getStringSlice(t, notificationType, "enum"))
|
|
require.Contains(t, getStringValue(t, notificationType, "description"), "Exact v1 notification type catalog")
|
|
require.Contains(t, getStringValue(t, notificationType, "description"), "`lobby.invite.revoked`")
|
|
|
|
producer := getMapValue(t, properties, "producer")
|
|
require.Equal(t, "string", getStringValue(t, producer, "type"))
|
|
require.Equal(t, []string{"geoprofile", "game_master", "game_lobby"}, getStringSlice(t, producer, "enum"))
|
|
|
|
occurredAt := getMapValue(t, properties, "occurred_at_ms")
|
|
require.Equal(t, "string", getStringValue(t, occurredAt, "type"))
|
|
require.Equal(t, "^[0-9]+$", getStringValue(t, occurredAt, "pattern"))
|
|
|
|
payloadJSON := getMapValue(t, properties, "payload_json")
|
|
require.Equal(t, "string", getStringValue(t, payloadJSON, "type"))
|
|
require.Equal(t, "application/json", getStringValue(t, payloadJSON, "contentMediaType"))
|
|
require.Contains(t, getStringValue(t, payloadJSON, "description"), "Required payload fields are frozen")
|
|
contentSchema := getMapValue(t, payloadJSON, "contentSchema")
|
|
require.Equal(t, "object", getStringValue(t, contentSchema, "type"))
|
|
require.Equal(t, true, getScalarValue(t, contentSchema, "additionalProperties"))
|
|
}
|
|
|
|
func TestIntentAsyncAPISpecFreezesAudienceRulesAndRecipientNormalization(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t)
|
|
schemas := getMapValue(t, getMapValue(t, doc, "components"), "schemas")
|
|
envelope := getMapValue(t, schemas, "NotificationIntentEnvelope")
|
|
properties := getMapValue(t, envelope, "properties")
|
|
|
|
audienceKind := getMapValue(t, properties, "audience_kind")
|
|
require.Equal(t, []string{"user", "admin_email"}, getStringSlice(t, audienceKind, "enum"))
|
|
|
|
recipients := getMapValue(t, properties, "recipient_user_ids_json")
|
|
require.Equal(t, "string", getStringValue(t, recipients, "type"))
|
|
require.Equal(t, "application/json", getStringValue(t, recipients, "contentMediaType"))
|
|
|
|
recipientSchema := getMapValue(t, recipients, "contentSchema")
|
|
require.Equal(t, "array", getStringValue(t, recipientSchema, "type"))
|
|
require.EqualValues(t, 1, getScalarValue(t, recipientSchema, "minItems"))
|
|
require.Equal(t, true, getScalarValue(t, recipientSchema, "uniqueItems"))
|
|
|
|
recipientItems := getMapValue(t, recipientSchema, "items")
|
|
require.Equal(t, "string", getStringValue(t, recipientItems, "type"))
|
|
require.EqualValues(t, 1, getScalarValue(t, recipientItems, "minLength"))
|
|
|
|
allOf := getSliceValue(t, envelope, "allOf")
|
|
userRule := findConditionalRuleByIfConst(t, allOf, "audience_kind", "user")
|
|
require.ElementsMatch(
|
|
t,
|
|
[]any{"recipient_user_ids_json"},
|
|
getSliceValue(t, getMapValue(t, userRule, "then"), "required"),
|
|
)
|
|
|
|
adminRule := findConditionalRuleByIfConst(t, allOf, "audience_kind", "admin_email")
|
|
require.ElementsMatch(
|
|
t,
|
|
[]any{"recipient_user_ids_json"},
|
|
getSliceValue(t, getMapValue(t, getMapValue(t, adminRule, "then"), "not"), "required"),
|
|
)
|
|
|
|
require.Contains(t, getStringValue(t, recipients, "description"), "unordered")
|
|
require.Contains(t, getStringValue(t, recipients, "description"), "element order does not change normalized content")
|
|
}
|
|
|
|
func TestIntentAsyncAPISpecFreezesNotificationCatalogBranches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t)
|
|
components := getMapValue(t, doc, "components")
|
|
schemas := getMapValue(t, components, "schemas")
|
|
envelope := getMapValue(t, schemas, "NotificationIntentEnvelope")
|
|
allOf := getSliceValue(t, envelope, "allOf")
|
|
|
|
for _, notificationType := range expectedNotificationTypeCatalog {
|
|
expectation := expectedNotificationCatalog[notificationType]
|
|
rule := findConditionalRuleByIfConst(t, allOf, "notification_type", notificationType)
|
|
thenSchema := getMapValue(t, rule, "then")
|
|
thenProperties := getMapValue(t, thenSchema, "properties")
|
|
|
|
require.Equal(
|
|
t,
|
|
expectation.producer,
|
|
getScalarValue(t, getMapValue(t, thenProperties, "producer"), "const"),
|
|
)
|
|
require.Equal(
|
|
t,
|
|
"#/components/schemas/"+expectation.payloadSchema,
|
|
getStringValue(t, getMapValue(t, getMapValue(t, thenProperties, "payload_json"), "contentSchema"), "$ref"),
|
|
)
|
|
|
|
if len(expectation.allowedAudienceKinds) > 0 {
|
|
oneOf := getSliceValue(t, thenSchema, "oneOf")
|
|
require.Len(t, oneOf, len(expectation.allowedAudienceKinds))
|
|
|
|
actualAudienceKinds := make([]string, 0, len(oneOf))
|
|
for _, rawBranch := range oneOf {
|
|
branch := rawBranch.(map[string]any)
|
|
actualAudienceKinds = append(
|
|
actualAudienceKinds,
|
|
getScalarValue(t, getMapValue(t, getMapValue(t, branch, "properties"), "audience_kind"), "const").(string),
|
|
)
|
|
}
|
|
require.ElementsMatch(t, expectation.allowedAudienceKinds, actualAudienceKinds)
|
|
} else {
|
|
require.Equal(
|
|
t,
|
|
expectation.audienceKind,
|
|
getScalarValue(t, getMapValue(t, thenProperties, "audience_kind"), "const"),
|
|
)
|
|
}
|
|
|
|
payloadSchema := getMapValue(t, schemas, expectation.payloadSchema)
|
|
require.Equal(t, "object", getStringValue(t, payloadSchema, "type"))
|
|
require.Equal(t, true, getScalarValue(t, payloadSchema, "additionalProperties"))
|
|
require.ElementsMatch(t, toAnySlice(expectation.requiredFields), getSliceValue(t, payloadSchema, "required"))
|
|
}
|
|
|
|
notificationType := getMapValue(t, getMapValue(t, envelope, "properties"), "notification_type")
|
|
require.NotContains(t, getStringSlice(t, notificationType, "enum"), "lobby.invite.revoked")
|
|
}
|
|
|
|
func TestIntentAsyncAPISpecFreezesExamplesAndIdempotencyRules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t)
|
|
components := getMapValue(t, doc, "components")
|
|
messages := getMapValue(t, components, "messages")
|
|
schemas := getMapValue(t, components, "schemas")
|
|
|
|
examples := getSliceValue(t, getMapValue(t, messages, "NotificationIntent"), "examples")
|
|
require.GreaterOrEqual(t, len(examples), 3)
|
|
|
|
userExamplePayload := getMapValue(t, findNamedExample(t, examples, "gameTurnReady"), "payload")
|
|
require.Equal(t, "game.turn.ready", getStringValue(t, userExamplePayload, "notification_type"))
|
|
require.Equal(t, "game_master", getStringValue(t, userExamplePayload, "producer"))
|
|
require.Equal(t, "user", getStringValue(t, userExamplePayload, "audience_kind"))
|
|
require.NotEmpty(t, getStringValue(t, userExamplePayload, "recipient_user_ids_json"))
|
|
|
|
adminExamplePayload := getMapValue(t, findNamedExample(t, examples, "geoReviewRecommended"), "payload")
|
|
require.Equal(t, "geo.review_recommended", getStringValue(t, adminExamplePayload, "notification_type"))
|
|
require.Equal(t, "geoprofile", getStringValue(t, adminExamplePayload, "producer"))
|
|
require.Equal(t, "admin_email", getStringValue(t, adminExamplePayload, "audience_kind"))
|
|
_, hasRecipients := adminExamplePayload["recipient_user_ids_json"]
|
|
require.False(t, hasRecipients)
|
|
|
|
publicApplicationPayload := getMapValue(t, findNamedExample(t, examples, "lobbyApplicationSubmittedPublic"), "payload")
|
|
require.Equal(t, "lobby.application.submitted", getStringValue(t, publicApplicationPayload, "notification_type"))
|
|
require.Equal(t, "game_lobby", getStringValue(t, publicApplicationPayload, "producer"))
|
|
require.Equal(t, "admin_email", getStringValue(t, publicApplicationPayload, "audience_kind"))
|
|
_, hasApplicationRecipients := publicApplicationPayload["recipient_user_ids_json"]
|
|
require.False(t, hasApplicationRecipients)
|
|
|
|
envelope := getMapValue(t, schemas, "NotificationIntentEnvelope")
|
|
description := getStringValue(t, envelope, "description")
|
|
require.Contains(t, description, "(producer, idempotency_key)")
|
|
require.Contains(t, description, "same normalized content is a successful duplicate")
|
|
require.Contains(t, description, "different normalized content is a conflict")
|
|
require.Contains(t, description, "`request_id` and `trace_id` are observability-only metadata")
|
|
|
|
payloadJSON := getMapValue(t, getMapValue(t, envelope, "properties"), "payload_json")
|
|
require.Contains(t, getStringValue(t, payloadJSON, "description"), "object key order")
|
|
require.Contains(t, getStringValue(t, payloadJSON, "description"), "array order")
|
|
require.Contains(t, getStringValue(t, payloadJSON, "description"), "remains significant")
|
|
}
|
|
|
|
func TestNotificationCatalogDocsStayInSync(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
readme := loadTextFile(t, "README.md")
|
|
flowsDoc := loadTextFile(t, filepath.Join("docs", "flows.md"))
|
|
docsIndex := loadTextFile(t, filepath.Join("docs", "README.md"))
|
|
normalizedReadme := normalizeWhitespace(readme)
|
|
normalizedFlowsDoc := normalizeWhitespace(flowsDoc)
|
|
|
|
require.Contains(t, readme, expectedNotificationCatalogTable)
|
|
require.Contains(t, docsIndex, "- [Main flows](flows.md)")
|
|
|
|
for _, snippet := range expectedSharedDocumentationSnippets {
|
|
normalizedSnippet := normalizeWhitespace(snippet)
|
|
require.Contains(t, normalizedReadme, normalizedSnippet)
|
|
}
|
|
|
|
require.Contains(t, normalizedFlowsDoc, normalizeWhitespace("Producer -> Notification"))
|
|
require.Contains(t, normalizedFlowsDoc, normalizeWhitespace("XADD normalized intent"))
|
|
}
|
|
|
|
func loadAsyncAPISpec(t *testing.T) map[string]any {
|
|
t.Helper()
|
|
|
|
payload := loadTextFile(t, filepath.Join("api", "intents-asyncapi.yaml"))
|
|
|
|
var doc map[string]any
|
|
if err := yaml.Unmarshal([]byte(payload), &doc); err != nil {
|
|
require.Failf(t, "test failed", "decode spec: %v", err)
|
|
}
|
|
|
|
return doc
|
|
}
|
|
|
|
func loadTextFile(t *testing.T, relativePath string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(moduleRoot(t), relativePath)
|
|
payload, err := os.ReadFile(path)
|
|
if err != nil {
|
|
require.Failf(t, "test failed", "read file %s: %v", path, err)
|
|
}
|
|
|
|
return string(payload)
|
|
}
|
|
|
|
func moduleRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
_, thisFile, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
require.FailNow(t, "runtime.Caller failed")
|
|
}
|
|
|
|
return filepath.Dir(thisFile)
|
|
}
|
|
|
|
func findConditionalRuleByIfConst(t *testing.T, rules []any, property, constValue string) map[string]any {
|
|
t.Helper()
|
|
|
|
for _, rawRule := range rules {
|
|
rule, ok := rawRule.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
ifSchema, ok := rule["if"].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
properties, ok := ifSchema["properties"].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
propertySchema, ok := properties[property].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if actual, ok := propertySchema["const"].(string); ok && actual == constValue {
|
|
return rule
|
|
}
|
|
}
|
|
|
|
require.FailNowf(t, "test failed", "conditional rule for %s=%s not found", property, constValue)
|
|
return nil
|
|
}
|
|
|
|
func findNamedExample(t *testing.T, examples []any, name string) map[string]any {
|
|
t.Helper()
|
|
|
|
for _, rawExample := range examples {
|
|
example, ok := rawExample.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if getStringValue(t, example, "name") == name {
|
|
return example
|
|
}
|
|
}
|
|
|
|
require.FailNowf(t, "test failed", "example %s not found", name)
|
|
return nil
|
|
}
|
|
|
|
func getMapValue(t *testing.T, value map[string]any, path ...string) map[string]any {
|
|
t.Helper()
|
|
|
|
current := value
|
|
for _, segment := range path {
|
|
raw, ok := current[segment]
|
|
if !ok {
|
|
require.Failf(t, "test failed", "missing map key %s", segment)
|
|
}
|
|
next, ok := raw.(map[string]any)
|
|
if !ok {
|
|
require.Failf(t, "test failed", "value at %s is not a map", segment)
|
|
}
|
|
current = next
|
|
}
|
|
|
|
return current
|
|
}
|
|
|
|
func getStringValue(t *testing.T, value map[string]any, key string) string {
|
|
t.Helper()
|
|
|
|
raw, ok := value[key]
|
|
if !ok {
|
|
require.Failf(t, "test failed", "missing key %s", key)
|
|
}
|
|
result, ok := raw.(string)
|
|
if !ok {
|
|
require.Failf(t, "test failed", "value at %s is not a string", key)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func getStringSlice(t *testing.T, value map[string]any, key string) []string {
|
|
t.Helper()
|
|
|
|
raw := getSliceValue(t, value, key)
|
|
result := make([]string, 0, len(raw))
|
|
for _, item := range raw {
|
|
text, ok := item.(string)
|
|
if !ok {
|
|
require.Failf(t, "test failed", "value at %s is not a string slice", key)
|
|
}
|
|
result = append(result, text)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func getScalarValue(t *testing.T, value map[string]any, key string) any {
|
|
t.Helper()
|
|
|
|
raw, ok := value[key]
|
|
if !ok {
|
|
require.Failf(t, "test failed", "missing key %s", key)
|
|
}
|
|
|
|
return raw
|
|
}
|
|
|
|
func getSliceValue(t *testing.T, value map[string]any, key string) []any {
|
|
t.Helper()
|
|
|
|
raw, ok := value[key]
|
|
if !ok {
|
|
require.Failf(t, "test failed", "missing key %s", key)
|
|
}
|
|
result, ok := raw.([]any)
|
|
if !ok {
|
|
require.Failf(t, "test failed", "value at %s is not a slice", key)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func mapKeys(value map[string]any) []string {
|
|
keys := make([]string, 0, len(value))
|
|
for key := range value {
|
|
keys = append(keys, key)
|
|
}
|
|
|
|
return keys
|
|
}
|
|
|
|
func toAnySlice(values []string) []any {
|
|
result := make([]any, 0, len(values))
|
|
for _, value := range values {
|
|
result = append(result, value)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func normalizeWhitespace(value string) string {
|
|
return strings.ToLower(strings.Join(strings.Fields(value), " "))
|
|
}
|
|
|
|
func TestGatewayREADMEFreezesExactPushVocabulary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
gatewayReadme := loadTextFile(t, filepath.Join("..", "gateway", "README.md"))
|
|
|
|
require.Contains(t, gatewayReadme, "The initial notification event vocabulary\nin v1 is exactly:")
|
|
require.Contains(
|
|
t,
|
|
gatewayReadme,
|
|
strings.Join([]string{
|
|
"- `game.turn.ready`",
|
|
"- `game.finished`",
|
|
"- `lobby.application.submitted`",
|
|
"- `lobby.membership.approved`",
|
|
"- `lobby.membership.rejected`",
|
|
"- `lobby.invite.created`",
|
|
"- `lobby.invite.redeemed`",
|
|
"- `lobby.race_name.registration_eligible`",
|
|
"- `lobby.race_name.registered`",
|
|
}, "\n"),
|
|
)
|
|
require.Contains(
|
|
t,
|
|
gatewayReadme,
|
|
"`lobby.application.submitted` is published toward `Gateway` only for the\nprivate-game owner flow. The public-game variant is email-only.",
|
|
)
|
|
}
|