package notification import ( "strings" "testing" "galaxy/backend/push" "github.com/google/uuid" ) // jsonFriendlyKinds lists catalog kinds whose payload is small and // stable enough that the gateway-bound encoding stays JSON instead of // FlatBuffers. The default for new producers is still FB; declaring a // kind here is a deliberate decision baked into the build target's // payload contract. // // `game.turn.ready` ships `{game_id, turn}` only, the UI parses it // inline in `routes/games/[id]/+layout.svelte` (Phase 24), and no // other consumer reads the payload — adopting the FB encoder would // require a new TS notification stub set and the regen tooling for // `pkg/schema/fbs/notification.fbs` without buying anything. // // `game.paused` (Phase 25) follows the same JSON-friendly contract: // payload is `{game_id, turn, reason}` consumed by the same in-game // shell layout, so there is no value in dragging a FB schema in for // one consumer. var jsonFriendlyKinds = map[string]bool{ KindGameTurnReady: true, KindGamePaused: true, } // TestBuildClientPushEventCoversCatalog asserts that every catalog kind // is exercised by this test, that FB-typed kinds return a // `preMarshaledEvent`, and that JSON-friendly kinds (see // `jsonFriendlyKinds` above) return a `push.JSONEvent`. func TestBuildClientPushEventCoversCatalog(t *testing.T) { t.Parallel() gameID := uuid.MustParse("11111111-1111-1111-1111-111111111111") applicationID := uuid.MustParse("22222222-2222-2222-2222-222222222222") inviterID := uuid.MustParse("33333333-3333-3333-3333-333333333333") tests := []struct { name string kind string payload map[string]any }{ {"invite received", KindLobbyInviteReceived, map[string]any{ "game_id": gameID.String(), "inviter_user_id": inviterID.String(), }}, {"invite revoked", KindLobbyInviteRevoked, map[string]any{ "game_id": gameID.String(), }}, {"application submitted", KindLobbyApplicationSubmitted, map[string]any{ "game_id": gameID.String(), "application_id": applicationID.String(), }}, {"application approved", KindLobbyApplicationApproved, map[string]any{"game_id": gameID.String()}}, {"application rejected", KindLobbyApplicationRejected, map[string]any{"game_id": gameID.String()}}, {"membership removed", KindLobbyMembershipRemoved, map[string]any{"reason": "deleted"}}, {"membership blocked", KindLobbyMembershipBlocked, map[string]any{ "game_id": gameID.String(), "reason": "permanent_blocked", }}, {"race name registered", KindLobbyRaceNameRegistered, map[string]any{"race_name": "Skylancer"}}, {"race name pending", KindLobbyRaceNamePending, map[string]any{ "race_name": "Skylancer", "expires_at": "2026-05-06T12:00:00Z", }}, {"race name expired", KindLobbyRaceNameExpired, map[string]any{"race_name": "Skylancer"}}, {"runtime image pull failed", KindRuntimeImagePullFailed, map[string]any{ "game_id": gameID.String(), "image_ref": "gcr.io/example:1.0.0", }}, {"runtime container start failed", KindRuntimeContainerStartFailed, map[string]any{"game_id": gameID.String()}}, {"runtime start config invalid", KindRuntimeStartConfigInvalid, map[string]any{ "game_id": gameID.String(), "reason": "missing engine version", }}, {"game turn ready", KindGameTurnReady, map[string]any{ "game_id": gameID.String(), "turn": int32(7), }}, {"game paused", KindGamePaused, map[string]any{ "game_id": gameID.String(), "turn": int32(7), "reason": "generation_failed", }}, } seenKinds := map[string]bool{} for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() event, err := buildClientPushEvent(tt.kind, tt.payload) if err != nil { t.Fatalf("build %s: %v", tt.kind, err) } if event.Kind() != tt.kind { t.Fatalf("Kind() = %q, want %q", event.Kind(), tt.kind) } bytes, err := event.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } if len(bytes) == 0 { t.Fatalf("Marshal returned empty bytes") } _, isJSON := event.(push.JSONEvent) wantJSON := jsonFriendlyKinds[tt.kind] if isJSON != wantJSON { t.Fatalf("kind %s: JSONEvent=%v, want JSONEvent=%v", tt.kind, isJSON, wantJSON) } }) seenKinds[tt.kind] = true } for _, kind := range SupportedKinds() { if !seenKinds[kind] { t.Errorf("catalog kind %q is not covered by this test", kind) } } } func TestBuildClientPushEventUnknownKindFallsBackToJSON(t *testing.T) { t.Parallel() event, err := buildClientPushEvent("unknown.kind", map[string]any{"x": 1}) if err != nil { t.Fatalf("unexpected error: %v", err) } if _, ok := event.(push.JSONEvent); !ok { t.Fatalf("expected JSONEvent fallback, got %T", event) } if event.Kind() != "unknown.kind" { t.Fatalf("Kind() = %q", event.Kind()) } } func TestBuildClientPushEventRejectsBrokenPayloads(t *testing.T) { t.Parallel() tests := []struct { name string kind string payload map[string]any want string }{ { name: "missing required uuid", kind: KindLobbyApplicationSubmitted, payload: map[string]any{"game_id": uuid.NewString()}, want: "application_id is missing", }, { name: "non-uuid string", kind: KindLobbyInviteRevoked, payload: map[string]any{"game_id": "not-a-uuid"}, want: "is not a uuid", }, { name: "uuid not a string", kind: KindLobbyInviteRevoked, payload: map[string]any{"game_id": 42}, want: "must be a string", }, { name: "missing required string", kind: KindLobbyRaceNameRegistered, payload: map[string]any{}, want: "race_name is missing", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() _, err := buildClientPushEvent(tt.kind, tt.payload) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), tt.want) { t.Fatalf("unexpected error: %v", err) } }) } }