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.", ) }