0284c9b83a
- Vitest units: board replay, placement machine, premium parity, i18n key parity, FlatBuffers codec round-trips (19 tests) - Playwright smoke (mock transport): guest -> lobby -> board -> place tile -> preview - ui-test.yaml workflow: check/unit/build + bundle-size budget (67.5KB gzip < 100KB) + chromium e2e - gateway transcode tests for games.list (seat display_name), pass, hint - backend integration test for game.ListForAccount
558 lines
17 KiB
Go
558 lines
17 KiB
Go
//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))
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|