//go:build integration package inttest import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/google/uuid" "go.uber.org/zap/zaptest" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/server" ) // The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames): // the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a // new game once the cap is reached, while accepting an incoming invitation stays allowed. // TestCountActiveQuickGames checks the count includes active and open quick games (the // honest-AI ones among them) and excludes finished games, friend games (created by // invitation) and games the account is not seated in. func TestCountActiveQuickGames(t *testing.T) { ctx := context.Background() clearOpenGames(t) games := newGameService() human := provisionAccount(t) opp := provisionAccount(t) if n := mustCount(t, games, human); n != 0 { t.Fatalf("fresh account count = %d, want 0", n) } // An open (awaiting-opponent) quick game counts. if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{ Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour, }, time.Now().Add(time.Minute), nil); err != nil { t.Fatalf("open quick game: %v", err) } // An active quick game and an honest-AI quick game both count (neither has an invitation row). mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1) mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2) // A finished quick game does NOT count. fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3) if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil { t.Fatalf("finish game: %v", err) } // A game the human is not seated in does NOT count. mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4) // A friend game (created by invitation) does NOT count, even though it is active. startFriendGame(t, human) if n := mustCount(t, games, human); n != 3 { t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n) } } // TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby // reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick // enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an // incoming invitation is still allowed. func TestGameLimitGate(t *testing.T) { ctx := context.Background() games := newGameService() srv := server.New(":0", server.Deps{ Logger: zaptest.NewLogger(t), DB: testDB, Accounts: account.NewStore(testDB), Games: games, Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0), Invitations: newInvitationService(), }) human := provisionAccount(t) opp := provisionAccount(t) // Under the cap: the lobby reports the player is not limited. if gamesListAtLimit(t, srv, human) { t.Fatal("a fresh account must be under the game limit") } // Reach the cap with active quick games seating the human (no invitation row → quick). for i := 0; i < game.MaxActiveQuickGames; i++ { mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1)) } // At the cap: the lobby flags it and both create paths are refused with the stable code. if !gamesListAtLimit(t, srv, human) { t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames) } // erudit_ru is in the default variant preferences, so the variant gate passes and the // game-limit gate is what fires here. if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" { t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec)) } invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String()) if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" { t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec)) } // Accepting an incoming invitation is never blocked, even at the cap: another player invites // the capped human, who accepts over HTTP and the game starts (friend games do not count). inviter := provisionAccount(t) inv := newInvitationService() invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite()) if err != nil { t.Fatalf("create invitation: %v", err) } if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK { t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String()) } } // mustCount returns the account's active-quick-game count, failing on error. func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int { t.Helper() n, err := games.CountActiveQuickGames(context.Background(), id) if err != nil { t.Fatalf("count active quick games: %v", err) } return n } // mustCreateQuick creates an active quick game (no invitation) seating the given accounts and // returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock). func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID { t.Helper() p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed} if vsAI { p.VsAI = true p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games } g, err := games.Create(context.Background(), p) if err != nil { t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err) } return g.ID } // startFriendGame has a fresh inviter invite the given account to a friend game and the invitee // accept it, so the (active) friend game exists with its game_invitations row. func startFriendGame(t *testing.T, invitee uuid.UUID) { t.Helper() ctx := context.Background() inv := newInvitationService() inviter := provisionAccount(t) invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()) if err != nil { t.Fatalf("create invitation: %v", err) } if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil { t.Fatalf("accept invitation: %v", err) } } // gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag. func gamesListAtLimit(t *testing.T, srv *server.Server, id uuid.UUID) bool { t.Helper() rec := userGet(t, srv, "/api/v1/user/games", id) if rec.Code != http.StatusOK { t.Fatalf("games.list status = %d, want 200", rec.Code) } var body struct { AtGameLimit bool `json:"at_game_limit"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode games.list: %v", err) } return body.AtGameLimit } // userPost issues an authenticated JSON POST (X-User-ID) against the assembled server. func userPost(t *testing.T, srv *server.Server, path string, id uuid.UUID, body string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("X-User-ID", id.String()) req.Header.Set("Content-Type", "application/json") srv.Handler().ServeHTTP(rec, req) return rec }