package notification import ( "os" "path/filepath" "regexp" "sort" "testing" "github.com/stretchr/testify/require" ) const expectedPushPayloadMappingTable = `| ` + "`notification_type`" + ` | FlatBuffers table | Payload fields | | --- | --- | --- | | ` + "`game.turn.ready`" + ` | ` + "`notification.GameTurnReadyEvent`" + ` | ` + "`game_id`" + `, ` + "`turn_number`" + ` | | ` + "`game.finished`" + ` | ` + "`notification.GameFinishedEvent`" + ` | ` + "`game_id`" + `, ` + "`final_turn_number`" + ` | | ` + "`lobby.application.submitted`" + ` | ` + "`notification.LobbyApplicationSubmittedEvent`" + ` | ` + "`game_id`" + `, ` + "`applicant_user_id`" + ` | | ` + "`lobby.membership.approved`" + ` | ` + "`notification.LobbyMembershipApprovedEvent`" + ` | ` + "`game_id`" + ` | | ` + "`lobby.membership.rejected`" + ` | ` + "`notification.LobbyMembershipRejectedEvent`" + ` | ` + "`game_id`" + ` | | ` + "`lobby.membership.blocked`" + ` | ` + "`notification.LobbyMembershipBlockedEvent`" + ` | ` + "`game_id`" + `, ` + "`membership_user_id`" + `, ` + "`reason`" + ` | | ` + "`lobby.invite.created`" + ` | ` + "`notification.LobbyInviteCreatedEvent`" + ` | ` + "`game_id`" + `, ` + "`inviter_user_id`" + ` | | ` + "`lobby.invite.redeemed`" + ` | ` + "`notification.LobbyInviteRedeemedEvent`" + ` | ` + "`game_id`" + `, ` + "`invitee_user_id`" + ` | | ` + "`lobby.race_name.registration_eligible`" + ` | ` + "`notification.LobbyRaceNameRegistrationEligibleEvent`" + ` | ` + "`game_id`" + `, ` + "`race_name`" + `, ` + "`eligible_until_ms`" + ` | | ` + "`lobby.race_name.registered`" + ` | ` + "`notification.LobbyRaceNameRegisteredEvent`" + ` | ` + "`race_name`" + ` |` var expectedPushPayloadSchemaTableNames = []string{ "GameTurnReadyEvent", "GameFinishedEvent", "LobbyApplicationSubmittedEvent", "LobbyMembershipApprovedEvent", "LobbyMembershipRejectedEvent", "LobbyMembershipBlockedEvent", "LobbyInviteCreatedEvent", "LobbyInviteRedeemedEvent", "LobbyRaceNameRegistrationEligibleEvent", "LobbyRaceNameRegisteredEvent", } var expectedPushPayloadSchemaFields = map[string][]string{ "GameTurnReadyEvent": { "game_id:string;", "turn_number:int64;", }, "GameFinishedEvent": { "game_id:string;", "final_turn_number:int64;", }, "LobbyApplicationSubmittedEvent": { "game_id:string;", "applicant_user_id:string;", }, "LobbyMembershipApprovedEvent": { "game_id:string;", }, "LobbyMembershipRejectedEvent": { "game_id:string;", }, "LobbyMembershipBlockedEvent": { "game_id:string;", "membership_user_id:string;", "reason:string;", }, "LobbyInviteCreatedEvent": { "game_id:string;", "inviter_user_id:string;", }, "LobbyInviteRedeemedEvent": { "game_id:string;", "invitee_user_id:string;", }, "LobbyRaceNameRegistrationEligibleEvent": { "game_id:string;", "race_name:string;", "eligible_until_ms:int64;", }, "LobbyRaceNameRegisteredEvent": { "race_name:string;", }, } var expectedPushPayloadGeneratedFiles = []string{ "GameFinishedEvent.go", "GameTurnReadyEvent.go", "LobbyApplicationSubmittedEvent.go", "LobbyInviteCreatedEvent.go", "LobbyInviteRedeemedEvent.go", "LobbyMembershipApprovedEvent.go", "LobbyMembershipBlockedEvent.go", "LobbyMembershipRejectedEvent.go", "LobbyRaceNameRegisteredEvent.go", "LobbyRaceNameRegistrationEligibleEvent.go", } var expectedPushPayloadDocumentationSnippets = []string{ "Only the ten user-facing push notification types above are represented in `notification.fbs`.", "`geo.review_recommended`, `game.generation_failed`, `lobby.runtime_paused_after_start`, `lobby.invite.expired`, and `lobby.race_name.registration_denied` remain outside this schema because they are email-only in v1.", "`notification_type` alone determines the concrete FlatBuffers table.", "No extra envelope or FlatBuffers `union` is added in v1.", "The push payload must stay lightweight and must not attempt to mirror full game, lobby, or profile state.", "`game_name`, human-readable user names, and other full business-state fields stay out of the push schema.", } func TestNotificationPushPayloadSchemaFreezesTablesAndFields(t *testing.T) { t.Parallel() schema := loadTextFile(t, filepath.Join("..", "pkg", "schema", "fbs", "notification.fbs")) require.Contains(t, schema, "namespace notification;") require.Contains(t, schema, "root_type GameTurnReadyEvent;") require.NotContains(t, schema, "union ") tablePattern := regexp.MustCompile(`(?m)^table ([A-Za-z0-9_]+) \{$`) matches := tablePattern.FindAllStringSubmatch(schema, -1) actualTableNames := make([]string, 0, len(matches)) for _, match := range matches { actualTableNames = append(actualTableNames, match[1]) } require.Equal(t, expectedPushPayloadSchemaTableNames, actualTableNames) for _, tableName := range expectedPushPayloadSchemaTableNames { tableBody := extractFlatBuffersTableBody(t, schema, tableName) for _, field := range expectedPushPayloadSchemaFields[tableName] { require.Contains(t, tableBody, field) } } } func TestNotificationPushPayloadGeneratedBindingsStayInSync(t *testing.T) { t.Parallel() dirPath := filepath.Join(moduleRoot(t), "..", "pkg", "schema", "fbs", "notification") entries, err := os.ReadDir(dirPath) require.NoError(t, err) actualFiles := make([]string, 0, len(entries)) for _, entry := range entries { require.Falsef(t, entry.IsDir(), "unexpected directory in generated bindings: %s", entry.Name()) actualFiles = append(actualFiles, entry.Name()) fileContents := loadTextFile(t, filepath.Join("..", "pkg", "schema", "fbs", "notification", entry.Name())) require.Contains(t, fileContents, "// Code generated by the FlatBuffers compiler. DO NOT EDIT.") require.Contains(t, fileContents, "package notification") } sort.Strings(actualFiles) require.Equal(t, expectedPushPayloadGeneratedFiles, actualFiles) } func TestNotificationPushPayloadDocsStayInSync(t *testing.T) { t.Parallel() readme := loadTextFile(t, "README.md") flowsDoc := loadTextFile(t, filepath.Join("docs", "flows.md")) examplesDoc := loadTextFile(t, filepath.Join("docs", "examples.md")) docsIndex := loadTextFile(t, filepath.Join("docs", "README.md")) normalizedReadme := normalizeWhitespace(readme) normalizedFlowsDoc := normalizeWhitespace(flowsDoc) normalizedExamplesDoc := normalizeWhitespace(examplesDoc) require.Contains(t, readme, expectedPushPayloadMappingTable) require.Contains(t, docsIndex, "- [Main flows](flows.md)") require.Contains(t, docsIndex, "- [Configuration and contract examples](examples.md)") for _, snippet := range expectedPushPayloadDocumentationSnippets { normalizedSnippet := normalizeWhitespace(snippet) require.Contains(t, normalizedReadme, normalizedSnippet) } require.Contains(t, normalizedFlowsDoc, normalizeWhitespace("encode FlatBuffers notification payload")) require.Contains(t, normalizedExamplesDoc, normalizeWhitespace("payload_bytes ''")) } func extractFlatBuffersTableBody(t *testing.T, schema, tableName string) string { t.Helper() pattern := regexp.MustCompile(`(?s)table ` + regexp.QuoteMeta(tableName) + ` \{(.*?)\}`) match := pattern.FindStringSubmatch(schema) if match == nil { require.FailNowf(t, "test failed", "table %s not found in schema", tableName) } return match[1] }