5b07bb4e14
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>
208 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|