ed53e25e57
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
Cap a player's simultaneous unfinished games per kind (vs_ai, random, friends) with independent guest and durable-account tiers, held in a new single-row backend.config table (-1 = unlimited) behind an in-memory cache and editable live in the admin console (/_gm/limits). Each game is tagged with games.game_kind on creation. This replaces the earlier flat MaxActiveQuickGames=10 combined cap: the per-tier/kind config is the single mechanism, enforced at the same handler gate (ensureUnderGameLimit by kind on lobby/enqueue) plus the durable friends cap in CreateInvitation. game.Service.AtGameLimit only resolves the tier and counts; the limit policy stays at the request edge. Guests are now refused friend requests, friend-code redemption, befriend-in-game and invitation creation outright (403 guest_forbidden) -- previously only the UI hid these. Admin: a kind column in both game lists and the config editor. Defaults: guest 1 vs_ai / 1 random / 0 friends; durable 10 / 10 / 10.
274 lines
11 KiB
Go
274 lines
11 KiB
Go
//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
|
|
}
|