package lobby_test import ( "context" "database/sql" "fmt" "sync" "testing" "time" "galaxy/backend/internal/config" "galaxy/backend/internal/lobby" "github.com/google/uuid" ) // capturingPublisher records every `LobbyNotification` intent that the // lobby service emits, so a test can assert the producer side without // running the real notification.Submit pipeline. type capturingPublisher struct { mu sync.Mutex items []lobby.LobbyNotification } func (p *capturingPublisher) PublishLobbyEvent(_ context.Context, ev lobby.LobbyNotification) error { p.mu.Lock() defer p.mu.Unlock() p.items = append(p.items, ev) return nil } func (p *capturingPublisher) byKind(kind string) []lobby.LobbyNotification { p.mu.Lock() defer p.mu.Unlock() out := make([]lobby.LobbyNotification, 0, len(p.items)) for _, ev := range p.items { if ev.Kind == kind { out = append(out, ev) } } return out } // newServiceWithPublisher mirrors `newServiceForTest` but lets the // caller inject a custom NotificationPublisher; the runtime-hooks // emit path needs to observe intents directly. func newServiceWithPublisher(t *testing.T, db *sql.DB, now func() time.Time, max int32, publisher lobby.NotificationPublisher) *lobby.Service { t.Helper() store := lobby.NewStore(db) cache := lobby.NewCache() if err := cache.Warm(context.Background(), store); err != nil { t.Fatalf("warm cache: %v", err) } svc, err := lobby.NewService(lobby.Deps{ Store: store, Cache: cache, Notification: publisher, Entitlement: stubEntitlement{max: max}, Config: config.LobbyConfig{ SweeperInterval: time.Second, PendingRegistrationTTL: time.Hour, InviteDefaultTTL: time.Hour, }, Now: now, }) if err != nil { t.Fatalf("new service: %v", err) } return svc } // TestOnRuntimeSnapshotEmitsTurnReady verifies that an engine snapshot // advancing `current_turn` fans out a `game.turn.ready` intent to every // active member, that the idempotency key is anchored on (game_id, turn), // and that a snapshot with the same turn does not re-emit. func TestOnRuntimeSnapshotEmitsTurnReady(t *testing.T) { db := startPostgres(t) now := time.Now().UTC() clock := func() time.Time { return now } publisher := &capturingPublisher{} svc := newServiceWithPublisher(t, db, clock, 5, publisher) owner := uuid.New() seedAccount(t, db, owner) game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{ OwnerUserID: &owner, Visibility: lobby.VisibilityPrivate, GameName: "Turn-Ready Fan-Out", MinPlayers: 1, MaxPlayers: 4, StartGapHours: 1, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(time.Hour), TurnSchedule: "0 0 * * *", TargetEngineVersion: "1.0.0", }) if err != nil { t.Fatalf("create game: %v", err) } if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("open enrollment: %v", err) } // Seed two active members through the store so the test focuses on // the runtime hook, not the membership state machine. store := lobby.NewStore(db) canonicalPolicy, err := lobby.NewPolicy() if err != nil { t.Fatalf("new policy: %v", err) } memberA := uuid.New() memberB := uuid.New() seedAccount(t, db, memberA) seedAccount(t, db, memberB) for i, m := range []uuid.UUID{memberA, memberB} { race := fmt.Sprintf("Race%d", i+1) canonical, err := canonicalPolicy.Canonical(race) if err != nil { t.Fatalf("canonical %q: %v", race, err) } if _, err := db.ExecContext(context.Background(), ` INSERT INTO backend.memberships ( membership_id, game_id, user_id, race_name, canonical_key, status ) VALUES ($1, $2, $3, $4, $5, 'active') `, uuid.New(), game.GameID, m, race, string(canonical)); err != nil { t.Fatalf("seed membership %s: %v", m, err) } } if err := svc.Cache().Warm(context.Background(), store); err != nil { t.Fatalf("re-warm cache: %v", err) } if _, err := svc.ReadyToStart(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("ready-to-start: %v", err) } if _, err := svc.Start(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("start: %v", err) } // First snapshot: prev=0, current_turn=1 → emit on the very first // turn after the engine starts producing. if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{ CurrentTurn: 1, RuntimeStatus: "running", }); err != nil { t.Fatalf("on-runtime-snapshot 1: %v", err) } intents := publisher.byKind(lobby.NotificationGameTurnReady) if len(intents) != 1 { t.Fatalf("after turn 1 want 1 turn-ready intent, got %d", len(intents)) } first := intents[0] wantKey := fmt.Sprintf("turn-ready:%s:1", game.GameID) if first.IdempotencyKey != wantKey { t.Errorf("turn 1 idempotency key = %q, want %q", first.IdempotencyKey, wantKey) } if got := first.Payload["turn"]; got != int32(1) { t.Errorf("turn 1 payload turn = %v, want 1", got) } if got := first.Payload["game_id"]; got != game.GameID.String() { t.Errorf("turn 1 payload game_id = %v, want %s", got, game.GameID) } if len(first.Recipients) != 2 { t.Errorf("turn 1 recipients = %d, want 2", len(first.Recipients)) } recipientSet := map[uuid.UUID]struct{}{} for _, r := range first.Recipients { recipientSet[r] = struct{}{} } if _, ok := recipientSet[memberA]; !ok { t.Errorf("turn 1 missing memberA in recipients") } if _, ok := recipientSet[memberB]; !ok { t.Errorf("turn 1 missing memberB in recipients") } // Same turn re-delivered (duplicate snapshot, gateway replay) must // not re-emit at the lobby layer: prev catches up to merged. if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{ CurrentTurn: 1, RuntimeStatus: "running", }); err != nil { t.Fatalf("on-runtime-snapshot 1 replay: %v", err) } if got := len(publisher.byKind(lobby.NotificationGameTurnReady)); got != 1 { t.Fatalf("after duplicate turn 1 want 1 intent, got %d", got) } // Next turn advances → second emit with key anchored on turn 2. if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{ CurrentTurn: 2, RuntimeStatus: "running", }); err != nil { t.Fatalf("on-runtime-snapshot 2: %v", err) } intents = publisher.byKind(lobby.NotificationGameTurnReady) if len(intents) != 2 { t.Fatalf("after turn 2 want 2 turn-ready intents, got %d", len(intents)) } wantKey2 := fmt.Sprintf("turn-ready:%s:2", game.GameID) if intents[1].IdempotencyKey != wantKey2 { t.Errorf("turn 2 idempotency key = %q, want %q", intents[1].IdempotencyKey, wantKey2) } if got := intents[1].Payload["turn"]; got != int32(2) { t.Errorf("turn 2 payload turn = %v, want 2", got) } }