Files
scrabble-game/backend/internal/inttest/game_test.go
T
Ilia Denisov 10412fee8e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Failing after 12s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
Stage 17 round 5 — backend/correctness bug fixes
- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat
  (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat.
- Quick-match cancel was a UI no-op (only stopped polling): add the full path
  (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's
  pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no
  later robot-substituted game). NewGame dequeues on cancel and on abandon.
- Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win.
  The winner now takes rank 1 and the viewer is placed from rank 2 — matching the
  game-detail screen.
- Friend request to a robot: robots no longer block requests; the request stays
  pending and expires (friendRequestTTL), mirroring a human who ignores it.
- Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a
  correct message; the chat nudge button disables during the hourly cooldown; the
  nudge note reads 'Waiting for your move!' (button keeps the Nudge action label).
Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot
inttest, result.ts 0-0 resignation, nudge_too_soon mapping.
2026-06-07 09:17:35 +02:00

631 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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)
}
}
// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
// despite leading on score.
func TestResignOnOpponentTurn(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, ok := newMirror(t, seed, 2).HintView()
if !ok {
t.Fatal("no opening move")
}
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn
t.Fatalf("p0 play: %v", err)
}
res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn
if err != nil {
t.Fatalf("off-turn resign = %v, want nil", err)
}
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
t.Fatalf("after off-turn resign: %+v", res.Game)
}
if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner {
t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats)
}
}
// 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:0007: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)
}
}