//go:build integration package inttest import ( "context" "database/sql" "errors" "sync" "testing" "time" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) // newGameService builds a game service over the shared pool and registry. func newGameService() *game.Service { return game.NewService( game.NewStore(testDB), account.NewStore(testDB), testRegistry, game.Config{ DictDir: dictDir(), DictVersion: testDictVersion, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour, }, zap.NewNop(), ) } // provisionAccount creates a fresh durable account and returns its id. func provisionAccount(t *testing.T) uuid.UUID { t.Helper() acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString()) if err != nil { t.Fatalf("provision account: %v", err) } return acc.ID } // openingSeed returns a seed whose fresh two-player English opening rack has a // legal move, so a greedy mirror can drive a game. func openingSeed(t *testing.T) int64 { t.Helper() for seed := int64(1); seed <= 200; seed++ { g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed}) if err != nil { t.Fatalf("engine new: %v", err) } if _, ok := g.HintView(); ok { return seed } } t.Fatal("no opening seed found") return 0 } // newMirror builds a parallel engine game with the same seed, used to compute // legal moves to feed the service under test. func newMirror(t *testing.T, seed int64, players int) *engine.Game { t.Helper() g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed}) if err != nil { t.Fatalf("mirror new: %v", err) } return g } // readStats reads an account's statistics row. func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) { t.Helper() row := testDB.QueryRowContext(context.Background(), `SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id) if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, 0, 0, 0, 0, false } t.Fatalf("read stats: %v", err) } return wins, losses, draws, maxGame, maxWord, true } // TestListForAccount checks the lobby "my games" query: it returns exactly the // games the account is seated in (each with its seats), and nothing for an outsider. func TestListForAccount(t *testing.T) { ctx := context.Background() svc := newGameService() me, opp := provisionAccount(t), provisionAccount(t) g1, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: []uuid.UUID{me, opp}, TurnTimeout: 24 * time.Hour, Seed: 1, }) if err != nil { t.Fatalf("create g1: %v", err) } g2, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: []uuid.UUID{opp, me}, TurnTimeout: 24 * time.Hour, Seed: 2, }) if err != nil { t.Fatalf("create g2: %v", err) } games, err := svc.ListForAccount(ctx, me) if err != nil { t.Fatalf("list: %v", err) } if len(games) != 2 { t.Fatalf("got %d games, want 2", len(games)) } seen := map[uuid.UUID]bool{} for _, g := range games { seen[g.ID] = true if len(g.Seats) != 2 { t.Errorf("game %s has %d seats, want 2", g.ID, len(g.Seats)) } seated := false for _, s := range g.Seats { if s.AccountID == me { seated = true } } if !seated { t.Errorf("account not found among seats of returned game %s", g.ID) } } if !seen[g1.ID] || !seen[g2.ID] { t.Errorf("returned games %v missing g1=%s or g2=%s", seen, g1.ID, g2.ID) } other := provisionAccount(t) og, err := svc.ListForAccount(ctx, other) if err != nil { t.Fatalf("list other: %v", err) } if len(og) != 0 { t.Errorf("outsider sees %d games, want 0", len(og)) } } // TestGameLifecycleAndStats drives a greedy two-player game to its natural end // through the service and checks the finish state and statistics. func TestGameLifecycleAndStats(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} seed := openingSeed(t) g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, HintsAllowed: true, HintsPerPlayer: 1, Seed: seed, }) if err != nil { t.Fatalf("create: %v", err) } if g.Status != game.StatusActive || g.Players != 2 { t.Fatalf("unexpected new game: %+v", g) } mirror := newMirror(t, seed, 2) var last game.MoveResult for i := 0; i < 300 && !mirror.Over(); i++ { cur := seats[mirror.ToMove()] if hint, ok := mirror.HintView(); ok { last, err = svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles) if err != nil { t.Fatalf("submit play: %v", err) } if _, err := mirror.SubmitPlay(hint.Dir, hint.Tiles); err != nil { t.Fatalf("mirror play: %v", err) } } else { last, err = svc.Pass(ctx, g.ID, cur) if err != nil { t.Fatalf("pass: %v", err) } if _, err := mirror.Pass(); err != nil { t.Fatalf("mirror pass: %v", err) } } } if !mirror.Over() { t.Fatal("greedy game did not finish") } if last.Game.Status != game.StatusFinished || last.Game.EndReason == "" { t.Fatalf("final game not finished: %+v", last.Game) } w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0]) w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1]) if !ok0 || !ok1 { t.Fatal("both players must have a stats row") } if mg0 <= 0 || mg1 <= 0 { t.Errorf("max game points must be positive: %d, %d", mg0, mg1) } decisive := (w0 == 1 && l1 == 1) || (w1 == 1 && l0 == 1) draw := d0 == 1 && d1 == 1 && w0 == 0 && w1 == 0 if !decisive && !draw { t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1) } } // TestReplayEquivalence plays a few moves through one service, then proves a // second service with a cold cache rebuilds the identical hidden state from the // journal. func TestReplayEquivalence(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} seed := openingSeed(t) g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, }) if err != nil { t.Fatalf("create: %v", err) } mirror := newMirror(t, seed, 2) for i := 0; i < 6 && !mirror.Over(); i++ { cur := seats[mirror.ToMove()] if hint, ok := mirror.HintView(); ok { if _, err := svc.SubmitPlay(ctx, g.ID, cur, hint.Dir, hint.Tiles); err != nil { t.Fatalf("submit: %v", err) } mirror.SubmitPlay(hint.Dir, hint.Tiles) } else { if _, err := svc.Pass(ctx, g.ID, cur); err != nil { t.Fatalf("pass: %v", err) } mirror.Pass() } } warm, err := svc.GameState(ctx, g.ID, seats[0]) if err != nil { t.Fatalf("warm state: %v", err) } cold, err := newGameService().GameState(ctx, g.ID, seats[0]) // fresh cache → replay if err != nil { t.Fatalf("cold state: %v", err) } if warm.BagLen != cold.BagLen { t.Errorf("bag len warm %d != cold %d", warm.BagLen, cold.BagLen) } if !equalStrings(warm.Rack, cold.Rack) { t.Errorf("rack warm %v != replayed %v", warm.Rack, cold.Rack) } } // TestResignWinnerAndStats checks that a resigner loses and keeps their score // while the opponent wins, with statistics to match. func TestResignWinnerAndStats(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} seed := openingSeed(t) g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, }) if err != nil { t.Fatalf("create: %v", err) } mirror := newMirror(t, seed, 2) hint, ok := mirror.HintView() if !ok { t.Fatal("no opening move") } played, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles) // p0 scores if err != nil { t.Fatalf("p0 play: %v", err) } score0 := played.Game.Seats[0].Score res, err := svc.Resign(ctx, g.ID, seats[1]) // p1 (trailing 0) resigns if err != nil { t.Fatalf("resign: %v", err) } if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" { t.Fatalf("after resign: %+v", res.Game) } if !res.Game.Seats[0].IsWinner || res.Game.Seats[1].IsWinner { t.Errorf("winner flags wrong: %+v", res.Game.Seats) } if res.Game.Seats[0].Score != score0 { t.Errorf("winner score changed on resign: %d -> %d", score0, res.Game.Seats[0].Score) } w0, l0, _, _, _, _ := readStats(t, seats[0]) w1, l1, _, _, _, _ := readStats(t, seats[1]) if w0 != 1 || l0 != 0 || w1 != 0 || l1 != 1 { t.Errorf("resign stats wrong: p0(%d/%d) p1(%d/%d)", w0, l0, w1, l1) } } // TestTimeoutSweep auto-resigns an overdue game and records it as a timeout. func TestTimeoutSweep(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t), }) if err != nil { t.Fatalf("create: %v", err) } backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour)) // Disable the to-move account's away window: with the default 00:00–07:00 // window the sweeper (correctly) declines to time out a player whose deadline // fell while they were asleep, which made this test fail whenever CI ran with // now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test // deterministic regardless of the time of day. setAway(t, seats[0], "UTC", "00:00", "00:00") // The sweep is global over the shared pool; assert the target game itself, // not the count, since other tests leave active games behind. if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 { t.Fatalf("sweep swept %d (err %v), want >= 1", n, err) } st, err := svc.History(ctx, g.ID) if err != nil { t.Fatalf("history: %v", err) } if st.Game.Status != game.StatusFinished || st.Game.EndReason != "timeout" { t.Fatalf("game not timed out: %+v", st.Game) } if !st.Game.Seats[1].IsWinner { // seat 0 was to move and timed out t.Errorf("opponent should win on timeout: %+v", st.Game.Seats) } w1, _, _, _, _, _ := readStats(t, seats[1]) if w1 != 1 { t.Errorf("opponent wins = %d, want 1", w1) } } // TestTimeoutRespectsAwayWindow keeps a player who is asleep from being timed // out until their away window ends. func TestTimeoutRespectsAwayWindow(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} // Seat 0 (to move) sleeps the whole UTC day except a one-minute gap, so any // deadline lands inside the window. setAway(t, seats[0], "UTC", "00:00", "23:59") g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t), }) if err != nil { t.Fatalf("create: %v", err) } // Deadline at 12:00 UTC, well inside the away window. turnStart := time.Date(2026, 6, 2, 11, 0, 0, 0, time.UTC) backdate(t, g.ID, turnStart) // A sweep whose clock sits inside the away window must leave the target game // active. (The sweep is global; assert the target, not the count.) if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 2, 12, 30, 0, 0, time.UTC)); err != nil { t.Fatalf("sweep inside away window: %v", err) } if status, _ := gameStatus(t, svc, g.ID); status != game.StatusActive { t.Fatalf("target timed out inside its away window (status %q)", status) } // Once the clock passes the window's end, it must time out. if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 3, 23, 59, 0, 0, time.UTC)); err != nil { t.Fatalf("sweep after away window: %v", err) } if status, reason := gameStatus(t, svc, g.ID); status != game.StatusFinished || reason != "timeout" { t.Fatalf("target not timed out after window: status %q reason %q", status, reason) } } // gameStatus returns a game's status and end reason via the service. func gameStatus(t *testing.T, svc *game.Service, id uuid.UUID) (status, endReason string) { t.Helper() h, err := svc.History(context.Background(), id) if err != nil { t.Fatalf("game status: %v", err) } return h.Game.Status, h.Game.EndReason } // TestHintPolicy exercises the per-game allowance, the profile wallet and the // disabled switch. func TestHintPolicy(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} seed := openingSeed(t) g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, HintsAllowed: true, HintsPerPlayer: 1, Seed: seed, }) if err != nil { t.Fatalf("create: %v", err) } if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance t.Fatalf("first hint: %v", err) } if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) { t.Fatalf("second hint = %v, want ErrNoHintsLeft", err) } setHintBalance(t, seats[0], 2) res, err := svc.Hint(ctx, g.ID, seats[0]) // spends the wallet if err != nil { t.Fatalf("wallet hint: %v", err) } if res.HintsRemaining != 1 { t.Errorf("hints remaining = %d, want 1", res.HintsRemaining) } off, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, HintsAllowed: false, HintsPerPlayer: 1, Seed: seed, }) if err != nil { t.Fatalf("create off: %v", err) } if _, err := svc.Hint(ctx, off.ID, seats[0]); !errors.Is(err, game.ErrHintsDisabled) { t.Fatalf("disabled hint = %v, want ErrHintsDisabled", err) } } // TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the // created game's variant and ErrNotFound for an unknown id. func TestGameVariant(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 1, }) if err != nil { t.Fatalf("create: %v", err) } if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish { t.Fatalf("GameVariant = %v, %v; want english, nil", v, err) } if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) { t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err) } } // TestCheckWordAndComplaint covers the word-check tool and complaint capture. func TestCheckWordAndComplaint(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), }) if err != nil { t.Fatalf("create: %v", err) } if ok, err := svc.CheckWord(ctx, g.ID, "CAT"); err != nil || !ok { t.Errorf("CheckWord cat = %v, %v; want true", ok, err) } if ok, err := svc.CheckWord(ctx, g.ID, "zzzzzz"); err != nil || ok { t.Errorf("CheckWord zzzzzz = %v, %v; want false", ok, err) } c, err := svc.FileComplaint(ctx, g.ID, seats[0], "ZZZZZZ", "should be a word") if err != nil { t.Fatalf("file complaint: %v", err) } if c.ID == uuid.Nil || c.Word != "zzzzzz" || c.WasValid || c.Status != game.StatusComplaintOpen { t.Errorf("unexpected complaint: %+v", c) } } // TestEvaluatePlayPreview previews a legal and an illegal play without committing. func TestEvaluatePlayPreview(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} seed := openingSeed(t) g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, }) if err != nil { t.Fatalf("create: %v", err) } hint, _ := newMirror(t, seed, 2).HintView() eval, err := svc.EvaluatePlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles) if err != nil { t.Fatalf("evaluate: %v", err) } if !eval.Valid || eval.Score <= 0 { t.Errorf("legal preview = %+v, want valid with score", eval) } // The same play must still be available afterwards (no commit). if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { t.Fatalf("submit after evaluate: %v", err) } bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], engine.Horizontal, []engine.TileRecord{{Row: 0, Col: 0, Letter: "q"}}) if err != nil { t.Fatalf("evaluate illegal: %v", err) } if bad.Valid { t.Error("disconnected play must be invalid") } } // TestConcurrentSubmitSerialized confirms the per-game lock lets only one of two // racing identical submissions win. func TestConcurrentSubmitSerialized(t *testing.T) { ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} seed := openingSeed(t) g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, }) if err != nil { t.Fatalf("create: %v", err) } hint, _ := newMirror(t, seed, 2).HintView() var wg sync.WaitGroup var mu sync.Mutex var ok int for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err == nil { mu.Lock() ok++ mu.Unlock() } }() } wg.Wait() if ok != 1 { t.Errorf("successful submits = %d, want exactly 1", ok) } } func backdate(t *testing.T, gameID uuid.UUID, at time.Time) { t.Helper() if _, err := testDB.ExecContext(context.Background(), `UPDATE backend.games SET turn_started_at = $1 WHERE game_id = $2`, at, gameID); err != nil { t.Fatalf("backdate: %v", err) } } func setAway(t *testing.T, id uuid.UUID, tz, start, end string) { t.Helper() if _, err := testDB.ExecContext(context.Background(), `UPDATE backend.accounts SET time_zone = $1, away_start = $2::time, away_end = $3::time WHERE account_id = $4`, tz, start, end, id); err != nil { t.Fatalf("set away: %v", err) } } func setHintBalance(t *testing.T, id uuid.UUID, n int) { t.Helper() if _, err := testDB.ExecContext(context.Background(), `UPDATE backend.accounts SET hint_balance = $1 WHERE account_id = $2`, n, id); err != nil { t.Fatalf("set hint balance: %v", err) } } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export // is allowed only once the game is over, so an active game leaks nothing mid-play. func TestExportGCGRefusesActiveGame(t *testing.T) { ctx := context.Background() gameID, _ := newGameWithSeats(t, 2) if _, err := newGameService().ExportGCG(ctx, gameID); !errors.Is(err, game.ErrGameActive) { t.Fatalf("export of active game = %v, want ErrGameActive", err) } }