Files
galaxy-game/notification/contract_asyncapi_test.go
T
2026-04-26 20:34:39 +02:00

627 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.membership.blocked`",
"- `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.",
)
}