//go:build integration package inttest import ( "context" "encoding/json" "errors" "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/gamelimits" "scrabble/backend/internal/lobby" "scrabble/backend/internal/server" "scrabble/backend/internal/social" ) // The game-limit suite covers the per-tier, per-kind active-game caps (backend.config): the // game_kind tag persisted per creation path, the tier resolution + counting rule // (game.Service.AtGameLimit), the HTTP gate + lobby at_game_limit flag, the guest gate on friend // actions, and the config hot-cache reflecting an admin edit. Accepting an invitation stays exempt. // newGameLimits builds a gamelimits service over the shared pool and loads the seeded config // (guest 1/1/0, durable 10/10/10). func newGameLimits(t *testing.T) *gamelimits.Service { t.Helper() gl := gamelimits.NewService(gamelimits.NewStore(testDB)) if err := gl.Load(context.Background()); err != nil { t.Fatalf("load game limits: %v", err) } return gl } // restoreDefaultLimits resets the single config row to the migration seed on cleanup, so a test that // edited it never leaks its values into another (the row is a shared singleton). func restoreDefaultLimits(t *testing.T, gl *gamelimits.Service) { t.Helper() t.Cleanup(func() { _ = gl.Update(context.Background(), gamelimits.Config{ Guest: gamelimits.Limits{VsAI: 1, Random: 1, Friends: 0}, Durable: gamelimits.Limits{VsAI: 10, Random: 10, Friends: 10}, }) }) } // gameKind reads a game's persisted game_kind tag. func gameKind(t *testing.T, gameID uuid.UUID) int16 { t.Helper() var k int16 if err := testDB.QueryRowContext(context.Background(), `SELECT game_kind FROM backend.games WHERE game_id=$1`, gameID).Scan(&k); err != nil { t.Fatalf("read game_kind: %v", err) } return k } // mustCreateKind creates an active game seating the accounts, tagged with kind, and returns its id. func mustCreateKind(t *testing.T, games *game.Service, seats []uuid.UUID, kind gamelimits.Kind) uuid.UUID { t.Helper() g, err := games.Create(context.Background(), game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Kind: kind, }) if err != nil { t.Fatalf("create game (kind=%d): %v", kind, err) } return g.ID } // startFriendGameID has inviter invite invitee to a friend game and the invitee accept, returning // the started game's id. func startFriendGameID(t *testing.T, inv *lobby.InvitationService, inviter, invitee uuid.UUID) uuid.UUID { t.Helper() ctx := context.Background() invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()) if err != nil { t.Fatalf("create invitation: %v", err) } got, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true) if err != nil { t.Fatalf("accept invitation: %v", err) } if got.GameID == nil { t.Fatal("accepted invitation has no game id") } return *got.GameID } // TestGameKindPersisted checks each creation path stamps the right game_kind: random (auto-match) // =2, vs_ai =1, friend (by invitation) =3. func TestGameKindPersisted(t *testing.T) { ctx := context.Background() clearOpenGames(t) games := newGameService() inv := newInvitationService() human, opp := provisionAccount(t), provisionAccount(t) rnd, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour}, time.Now().Add(time.Minute), nil) if err != nil { t.Fatalf("open random game: %v", err) } if k := gameKind(t, rnd.ID); k != int16(gamelimits.KindRandom) { t.Errorf("random game_kind = %d, want %d", k, gamelimits.KindRandom) } ai := mustCreateKind(t, games, []uuid.UUID{human, opp}, gamelimits.KindVsAI) if k := gameKind(t, ai); k != int16(gamelimits.KindVsAI) { t.Errorf("vs_ai game_kind = %d, want %d", k, gamelimits.KindVsAI) } friend := startFriendGameID(t, inv, human, opp) if k := gameKind(t, friend); k != int16(gamelimits.KindFriends) { t.Errorf("friend game_kind = %d, want %d", k, gamelimits.KindFriends) } } // TestGuestActiveGameLimitHTTP drives the guest random cap through the assembled server: the first // auto-match opens a game, the lobby then flags at_game_limit, and a second enqueue is refused 409 // game_limit_reached. It also checks the guest vs_ai and friends caps resolve at the domain level. func TestGuestActiveGameLimitHTTP(t *testing.T) { ctx := context.Background() clearOpenGames(t) gl := newGameLimits(t) games := newGameService() games.SetGameLimits(gl) 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(), GameLimits: gl, }) guest := provisionGuest(t) if gamesListAtLimit(t, srv, guest) { t.Fatal("a fresh guest must be under the random game limit") } // The first auto-match opens a random game (guest random cap = 1). if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK { t.Fatalf("first enqueue = %d (%s), want 200", rec.Code, rec.Body.String()) } // At the cap: the lobby flags it and a second enqueue is refused with the stable code. if !gamesListAtLimit(t, srv, guest) { t.Fatal("after one random game the guest must be at the limit") } if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" { t.Fatalf("second enqueue = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec)) } // A guest is refused the friends flow outright at the HTTP edge (403 guest_forbidden). opp := provisionAccount(t) invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String()) if rec := userPost(t, srv, "/api/v1/user/invitations", guest, invBody); rec.Code != http.StatusForbidden || errorCode(t, rec) != "guest_forbidden" { t.Fatalf("guest invitation = (%d, %q), want (403, guest_forbidden)", rec.Code, errorCode(t, rec)) } // The guest vs_ai cap is 1: one vs_ai game puts the guest at the vs_ai limit. mustCreateKind(t, games, []uuid.UUID{guest, opp}, gamelimits.KindVsAI) if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindVsAI); err != nil || !at { t.Fatalf("guest vs_ai at-limit = (%v, %v), want (true, nil)", at, err) } // The guest friends cap is 0: a guest is always at the friends limit (the 403 gate blocks first). if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindFriends); err != nil || !at { t.Fatalf("guest friends at-limit = (%v, %v), want (true, nil)", at, err) } } // TestDurableTierHigherLimit checks a durable account resolves the durable tier, not the guest one: // one random game leaves it well under the durable cap (10). func TestDurableTierHigherLimit(t *testing.T) { ctx := context.Background() gl := newGameLimits(t) games := newGameService() games.SetGameLimits(gl) durable, opp := provisionAccount(t), provisionAccount(t) mustCreateKind(t, games, []uuid.UUID{durable, opp}, gamelimits.KindRandom) if at, err := games.AtGameLimit(ctx, durable, gamelimits.KindRandom); err != nil || at { t.Fatalf("durable random at-limit after one game = (%v, %v), want (false, nil)", at, err) } } // TestGuestForbiddenFriendActions checks a guest is refused every friend action server-side (the UI // hides them; this is the source of truth): creating an invitation, sending a friend request, and // redeeming a friend code. func TestGuestForbiddenFriendActions(t *testing.T) { ctx := context.Background() guest := provisionGuest(t) other := provisionAccount(t) inv := newInvitationService() if _, err := inv.CreateInvitation(ctx, guest, []uuid.UUID{other}, englishInvite()); !errors.Is(err, lobby.ErrGuestForbidden) { t.Fatalf("guest CreateInvitation err = %v, want ErrGuestForbidden", err) } soc := newSocialService() if err := soc.SendFriendRequest(ctx, guest, other); !errors.Is(err, social.ErrGuestForbidden) { t.Fatalf("guest SendFriendRequest err = %v, want ErrGuestForbidden", err) } if _, err := soc.RedeemFriendCode(ctx, guest, "000000"); !errors.Is(err, social.ErrGuestForbidden) { t.Fatalf("guest RedeemFriendCode err = %v, want ErrGuestForbidden", err) } } // TestDurableFriendsCapAndConfigCache lowers the durable friends cap to 1 through the service (the // admin-edit path), checks a durable inviter is refused a second friend game with 409, and that // accepting an incoming invitation is still exempt from the cap. func TestDurableFriendsCapAndConfigCache(t *testing.T) { ctx := context.Background() gl := newGameLimits(t) restoreDefaultLimits(t, gl) cfg := gl.Get() cfg.Durable.Friends = 1 if err := gl.Update(ctx, cfg); err != nil { t.Fatalf("update durable friends cap: %v", err) } games := newGameService() games.SetGameLimits(gl) inv := lobby.NewInvitationService(lobby.NewStore(testDB), games, account.NewStore(testDB), newSocialService()) inviter := provisionAccount(t) d2, d3 := provisionAccount(t), provisionAccount(t) // The inviter's first friend game reaches the (lowered) cap of 1. startFriendGameID(t, inv, inviter, d2) if at, err := games.AtGameLimit(ctx, inviter, gamelimits.KindFriends); err != nil || !at { t.Fatalf("inviter friends at-limit = (%v, %v), want (true, nil)", at, err) } // A second invitation is refused: the cache reflects the edit. if _, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{d3}, englishInvite()); !errors.Is(err, game.ErrGameLimitReached) { t.Fatalf("second invitation err = %v, want ErrGameLimitReached", err) } // Accept stays exempt: someone else invites the capped inviter, who accepts and the game starts. startFriendGameID(t, inv, d3, inviter) } // 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 }