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
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).
124 lines
3.5 KiB
Go
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
|
|
}
|