Files
galaxy-game/backend/internal/notification/events_test.go
T
Ilia Denisov 2ca47eb4df ui/phase-25: backend turn-cutoff guard + auto-pause + UI sync protocol
Backend now owns the turn-cutoff and pause guards the order tab
relies on: the scheduler flips runtime_status between
generation_in_progress and running around every engine tick, a
failed tick auto-pauses the game through OnRuntimeSnapshot, and a
new game.paused notification kind fans out alongside
game.turn.ready. The user-games handlers reject submits with
HTTP 409 turn_already_closed or game_paused depending on the
runtime state.

UI delegates auto-sync to a new OrderQueue: offline detection,
single retry on reconnect, conflict / paused classification.
OrderDraftStore surfaces conflictBanner / pausedBanner runes,
clears them on local mutation or on a game.turn.ready push via
resetForNewTurn. The order tab renders the matching banners and
the new conflict per-row badge; i18n bundles cover en + ru.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:00:16 +02:00

191 lines
5.9 KiB
Go

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)
}
})
}
}