Files
scrabble-game/backend/internal/inttest/forfeit_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

124 lines
3.5 KiB
Go

//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/game"
)
// TestForfeitAllResignsActiveGame checks the game-side effect of a block on a two-player game:
// ForfeitAllForAccount resigns the blocked player, finishing the game with the opponent winning.
func TestForfeitAllResignsActiveGame(t *testing.T) {
ctx := context.Background()
svc := newGameService()
gid, seats := newGameWithSeats(t, 2) // seats[0] = blocked, seats[1] = opponent
n, err := svc.ForfeitAllForAccount(ctx, seats[0])
if err != nil {
t.Fatalf("forfeit all: %v", err)
}
if n < 1 {
t.Fatalf("forfeit count = %d, want at least 1", n)
}
g, err := svc.GameByID(ctx, gid)
if err != nil {
t.Fatalf("get game: %v", err)
}
if g.Status != game.StatusFinished {
t.Errorf("status = %q, want finished", g.Status)
}
if w := seatWinner(g, seats[0]); w {
t.Error("the blocked player must not be the winner")
}
if w := seatWinner(g, seats[1]); !w {
t.Error("the opponent must win when the blocked player forfeits")
}
}
// TestForfeitAllCancelsOpenGame checks that a block deletes the blocked player's open auto-match
// game (no opponent to resign against), clearing it from the matchmaking pool.
func TestForfeitAllCancelsOpenGame(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g := openGame(t, svc, starter, evenOpeningSeed(t))
n, err := svc.ForfeitAllForAccount(ctx, starter)
if err != nil {
t.Fatalf("forfeit all: %v", err)
}
if n < 1 {
t.Fatalf("forfeit count = %d, want at least 1", n)
}
if c := gameRowCount(t, g.ID); c != 0 {
t.Errorf("open game rows after forfeit = %d, want 0 (deleted)", c)
}
}
// TestForfeitAllResignsSeatInMultiplayerGame checks that in a 3-player game the blocked player's
// seat is resigned while the game continues for the remaining players.
func TestForfeitAllResignsSeatInMultiplayerGame(t *testing.T) {
ctx := context.Background()
svc := newGameService()
gid, seats := newGameWithSeats(t, 3)
n, err := svc.ForfeitAllForAccount(ctx, seats[0])
if err != nil {
t.Fatalf("forfeit all: %v", err)
}
if n < 1 {
t.Fatalf("forfeit count = %d, want at least 1", n)
}
g, err := svc.GameByID(ctx, gid)
if err != nil {
t.Fatalf("get game: %v", err)
}
if g.Status != game.StatusActive {
t.Errorf("status = %q, want still active (two seats remain)", g.Status)
}
if c := resignMoveCount(t, gid, 0); c != 1 {
t.Errorf("resign moves for seat 0 = %d, want 1", c)
}
}
// seatWinner reports the is_winner flag for the given account's seat in g.
func seatWinner(g game.Game, accountID uuid.UUID) bool {
for _, s := range g.Seats {
if s.AccountID == accountID {
return s.IsWinner
}
}
return false
}
// gameRowCount counts the rows in the games table for gameID (0 once it is deleted).
func gameRowCount(t *testing.T, gameID uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.games WHERE game_id = $1`, gameID).Scan(&n); err != nil {
t.Fatalf("count game rows: %v", err)
}
return n
}
// resignMoveCount counts the resign moves journalled for a seat in a game.
func resignMoveCount(t *testing.T, gameID uuid.UUID, seat int) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.game_moves WHERE game_id = $1 AND action = 'resign' AND seat = $2`,
gameID, seat).Scan(&n); err != nil {
t.Fatalf("count resign moves: %v", err)
}
return n
}