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