diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 5b1d82d..86b1607 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -314,19 +314,6 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { } } -// provisionGuest creates a fresh ephemeral guest account and returns its id. -func provisionGuest(t *testing.T) uuid.UUID { - t.Helper() - acc, err := account.NewStore(testDB).ProvisionGuest(context.Background()) - if err != nil { - t.Fatalf("provision guest: %v", err) - } - if !acc.IsGuest { - t.Fatalf("provisioned account %s is not flagged guest", acc.ID) - } - return acc.ID -} - // TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game // against a robot to a natural end and checks the guest holds a seat (the // game_players foreign key is satisfied) yet accrues no statistics, while the diff --git a/backend/internal/inttest/draft_test.go b/backend/internal/inttest/draft_test.go index 3268dcf..4538629 100644 --- a/backend/internal/inttest/draft_test.go +++ b/backend/internal/inttest/draft_test.go @@ -5,35 +5,10 @@ package inttest import ( "context" "testing" - "time" - "github.com/google/uuid" - - "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) -// newDraftGame creates a started two-player English game on an opening seed and returns the -// service, game id, seats, and the opening play (from a mirror) used to drive a real commit. -func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) { - t.Helper() - 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 || len(hint.Tiles) == 0 { - t.Fatal("no opening move") - } - return svc, g.ID, seats, hint -} - // TestDraftPersistAndConflictReset covers draft persistence: a round-trip of the // rack order + board tiles, the actor's own draft cleared on their move, and an opponent's // board draft reset when a committed play overlaps one of its cells (the rack order kept). diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 5a219d3..18e72fc 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -4,88 +4,17 @@ 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) { diff --git a/backend/internal/inttest/helpers.go b/backend/internal/inttest/helpers.go new file mode 100644 index 0000000..fc94a8f --- /dev/null +++ b/backend/internal/inttest/helpers.go @@ -0,0 +1,167 @@ +//go:build integration + +package inttest + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/metric/noop" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/lobby" + "scrabble/backend/internal/robot" + "scrabble/backend/internal/social" +) + +// Shared fixtures for the Postgres-backed integration suite: the service +// constructors over the shared pool/registry, account provisioning, game +// assembly, and the stats reader. Helpers used by a single test file stay in +// that file; everything reused across files lives here. + +// 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(), + ) +} + +// newSocialService builds a social service over the shared pool, reading game +// state through a real game service. +func newSocialService() *social.Service { + return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService()) +} + +// newRobotService builds a robot service over games (shared so its moves and the +// test's human moves use the same live-game cache and per-game locks), a fresh +// social service for nudges, and a no-op meter. +func newRobotService(t *testing.T, games *game.Service) *robot.Service { + t.Helper() + return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop()) +} + +// newMatchmaker builds a matchmaker starting real games and substituting from +// robots after wait. +func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker { + t.Helper() + return lobby.NewMatchmaker(newGameService(), robots, wait, 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 +} + +// provisionGuest creates a fresh ephemeral guest account and returns its id. +func provisionGuest(t *testing.T) uuid.UUID { + t.Helper() + acc, err := account.NewStore(testDB).ProvisionGuest(context.Background()) + if err != nil { + t.Fatalf("provision guest: %v", err) + } + if !acc.IsGuest { + t.Fatalf("provisioned account %s is not flagged guest", acc.ID) + } + 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 +} + +// newGameWithSeats creates a started game seating n fresh accounts and returns the +// game id and the seated account ids in seat order. +func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) { + t.Helper() + seats := make([]uuid.UUID, n) + for i := range seats { + seats[i] = provisionAccount(t) + } + g, err := newGameService().Create(context.Background(), game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), + }) + if err != nil { + t.Fatalf("create game: %v", err) + } + return g.ID, seats +} + +// newDraftGame creates a started two-player English game on an opening seed and returns the +// service, game id, seats, and the opening play (from a mirror) used to drive a real commit. +func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) { + t.Helper() + 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 || len(hint.Tiles) == 0 { + t.Fatal("no opening move") + } + return svc, g.ID, seats, hint +} + +// 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 +} diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index 7a577ab..738e4c7 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -8,31 +8,12 @@ import ( "time" "github.com/google/uuid" - "go.opentelemetry.io/otel/metric/noop" - "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" - "scrabble/backend/internal/lobby" - "scrabble/backend/internal/robot" ) -// newRobotService builds a robot service over games (shared so its moves and the -// test's human moves use the same live-game cache and per-game locks), a fresh -// social service for nudges, and a no-op meter. -func newRobotService(t *testing.T, games *game.Service) *robot.Service { - t.Helper() - return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop()) -} - -// newMatchmaker builds a matchmaker starting real games and substituting from -// robots after wait. -func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker { - t.Helper() - return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop()) -} - // setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or // idle) at a chosen instant, independent of wall time. func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) { diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 12f598a..87a13d9 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -45,29 +45,6 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool { return false } -// newSocialService builds a social service over the shared pool, reading game -// state through a real game service. -func newSocialService() *social.Service { - return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService()) -} - -// newGameWithSeats creates a started game seating n fresh accounts and returns the -// game id and the seated account ids in seat order. -func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) { - t.Helper() - seats := make([]uuid.UUID, n) - for i := range seats { - seats[i] = provisionAccount(t) - } - g, err := newGameService().Create(context.Background(), game.CreateParams{ - Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), - }) - if err != nil { - t.Fatalf("create game: %v", err) - } - return g.ID, seats -} - // TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as // pending rather than blocked: robots no longer block friend requests, so the request // just sits unanswered and later expires — mirroring a human who ignores it.