57c778f9b2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.
- Telegram: one bot; drop service_language and the supported_languages
set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
connector proto). The single bot renders chat and out-of-app push in
the recipient's preferred_language; remove the game-language push
routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
{erudit_ru}, CHECK non-empty + subset of the three variants). Gates
the New Game picker, vs-AI and the friend invitation the player
creates, enforced server-side (HTTP 400 otherwise); an invited friend
may still accept any variant. Edited on the Settings screen; variants
are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
backend, ui, UI_DESIGN, PRERELEASE).
The migration squash is deferred to a follow-up PR.
191 lines
7.4 KiB
Go
191 lines
7.4 KiB
Go
//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
|
|
}
|