Files
scrabble-game/backend/internal/inttest/suspension_console_test.go
T
Ilia Denisov d1ba666495
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
feat(admin): manual account blocking (suspensions)
Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint.

Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
2026-06-14 21:55:59 +02:00

122 lines
4.2 KiB
Go

//go:build integration
package inttest
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/game"
"scrabble/backend/internal/server"
)
// TestConsoleBlockAndUnblock drives the admin console block flow through HTTP: blocking a user
// records the suspension and forfeits their active game, the user card renders both branches,
// and unblocking lifts it.
func TestConsoleBlockAndUnblock(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
games := newGameService()
srv := consoleServer(t, accounts, games)
gid, seats := newGameWithSeats(t, 2)
blocked := seats[0]
// The card renders the not-blocked branch before any block.
if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK {
t.Fatalf("user card before block = %d, want 200", rec.Code)
}
// Block permanently via the console.
if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/block", url.Values{"duration": {"permanent"}}); rec.Code != http.StatusOK {
t.Fatalf("console block = %d, want 200", rec.Code)
}
if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || !ok {
t.Fatalf("after console block: blocked=%v err=%v, want blocked", ok, err)
}
if g, err := games.GameByID(ctx, gid); err != nil || g.Status != game.StatusFinished {
t.Fatalf("game after block: status=%q err=%v, want finished", g.Status, err)
}
// The card renders the blocked branch.
if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK {
t.Fatalf("user card after block = %d, want 200", rec.Code)
}
// Unblock via the console.
if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/unblock", url.Values{}); rec.Code != http.StatusOK {
t.Fatalf("console unblock = %d, want 200", rec.Code)
}
if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || ok {
t.Fatalf("after console unblock: blocked=%v err=%v, want not blocked", ok, err)
}
}
// TestConsoleReasonsCRUD drives reason creation through the console and checks the page renders.
func TestConsoleReasonsCRUD(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
srv := consoleServer(t, accounts, newGameService())
if rec := consoleGet(t, srv, "/_gm/reasons"); rec.Code != http.StatusOK {
t.Fatalf("reasons page = %d, want 200", rec.Code)
}
form := url.Values{"text_en": {"Console reason"}, "text_ru": {"Причина из консоли"}}
if rec := consolePost(t, srv, "/_gm/reasons", form); rec.Code != http.StatusOK {
t.Fatalf("console create reason = %d, want 200", rec.Code)
}
reasons, err := accounts.ListReasons(ctx)
if err != nil {
t.Fatalf("list reasons: %v", err)
}
found := false
for _, r := range reasons {
if r.TextEn == "Console reason" && r.TextRu == "Причина из консоли" {
found = true
}
}
if !found {
t.Error("reason created via the console was not found")
}
}
// consoleServer assembles a server with the admin console mounted (it needs accounts, games and a
// registry).
func consoleServer(t *testing.T, accounts *account.Store, games *game.Service) *server.Server {
t.Helper()
return server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: accounts,
Games: games,
Registry: testRegistry,
DictDir: dictDir(),
})
}
// consoleGet issues a console GET (a safe method, so the same-origin guard passes).
func consoleGet(t *testing.T, srv *server.Server, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://example.com"+path, nil))
return rec
}
// consolePost issues a same-origin form POST to the console.
func consolePost(t *testing.T, srv *server.Server, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", "http://example.com")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}