Files
galaxy-game/backend/internal/lobby/runtime_hooks_test.go
T
Ilia Denisov 5b07bb4e14 ui/phase-24: push events, turn-ready toast, single SubscribeEvents consumer
Wires the gateway's signed SubscribeEvents stream end-to-end:

- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
  current_turn advance, addressed to every active membership, push-only
  channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
  carries both per-event dispatch and revocation detection; toast
  primitive (store + host) lives in lib/; GameStateStore gains
  pendingTurn/markPendingTurn/advanceToPending and a persisted
  lastViewedTurn so a return after multiple turns surfaces the same
  "view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
  (verifyPayloadHash + verifyEvent), full-jitter exponential backoff
  1s -> 30s on transient failure, signOut("revoked") on
  Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
  (testcontainers + capturing publisher), consumer (Vitest event
  stream, toast, game-state extensions), and a Playwright e2e
  delivering a signed frame to the live UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:16:31 +02:00

208 lines
6.7 KiB
Go

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