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

210 lines
7.0 KiB
Go

//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// TestSuspensionRoundTrip covers a permanent block: a fresh account is not blocked, Suspend with
// a reason makes CurrentSuspension report it (permanent, with the en/ru snapshot resolved per
// language), and LiftSuspension reverses it.
func TestSuspensionRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
id := provisionAccount(t)
if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok {
t.Fatalf("fresh CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err)
}
if _, err := store.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil {
t.Fatalf("suspend: %v", err)
}
susp, ok, err := store.CurrentSuspension(ctx, id)
if err != nil || !ok {
t.Fatalf("CurrentSuspension after suspend = (ok %v, err %v), want (true, nil)", ok, err)
}
if !susp.Permanent() {
t.Errorf("Permanent() = false, want true for a nil-until block")
}
if got := susp.LocalizedReason("en"); got != "Spam" {
t.Errorf("LocalizedReason(en) = %q, want Spam", got)
}
if got := susp.LocalizedReason("ru"); got != "Спам" {
t.Errorf("LocalizedReason(ru) = %q, want Спам", got)
}
if got := susp.LocalizedReason("fr"); got != "Spam" {
t.Errorf("LocalizedReason(fr) = %q, want the English fallback Spam", got)
}
if err := store.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift: %v", err)
}
if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok {
t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err)
}
}
// TestTemporarySuspensionExpiry checks the until boundary: a future expiry is in force (and not
// permanent), while a past expiry is already not in force.
func TestTemporarySuspensionExpiry(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
future := provisionAccount(t)
until := time.Now().Add(time.Hour)
if _, err := store.Suspend(ctx, future, &until, "", "", nil); err != nil {
t.Fatalf("suspend future: %v", err)
}
susp, ok, err := store.CurrentSuspension(ctx, future)
if err != nil || !ok {
t.Fatalf("future CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err)
}
if susp.Permanent() {
t.Error("Permanent() = true, want false for a dated block")
}
if susp.BlockedUntil == nil || !susp.BlockedUntil.After(time.Now()) {
t.Errorf("BlockedUntil = %v, want a future instant", susp.BlockedUntil)
}
past := provisionAccount(t)
expired := time.Now().Add(-time.Hour)
if _, err := store.Suspend(ctx, past, &expired, "", "", nil); err != nil {
t.Fatalf("suspend past: %v", err)
}
if _, ok, err := store.CurrentSuspension(ctx, past); err != nil || ok {
t.Fatalf("expired CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err)
}
}
// TestSuspensionStrongestWins checks that with overlapping blocks CurrentSuspension returns the
// strongest — a permanent block outranks any dated one — and that LiftSuspension clears them all.
func TestSuspensionStrongestWins(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
id := provisionAccount(t)
soon := time.Now().Add(time.Hour)
if _, err := store.Suspend(ctx, id, &soon, "", "", nil); err != nil {
t.Fatalf("suspend dated: %v", err)
}
if _, err := store.Suspend(ctx, id, nil, "", "", nil); err != nil {
t.Fatalf("suspend permanent: %v", err)
}
later := time.Now().Add(2 * time.Hour)
if _, err := store.Suspend(ctx, id, &later, "", "", nil); err != nil {
t.Fatalf("suspend dated-later: %v", err)
}
susp, ok, err := store.CurrentSuspension(ctx, id)
if err != nil || !ok {
t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err)
}
if !susp.Permanent() {
t.Error("strongest block should be the permanent one")
}
if err := store.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift: %v", err)
}
if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok {
t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err)
}
}
// TestReasonsCRUD covers the operator-editable picklist: create, list, get, update, delete, and
// the ErrNotFound paths.
func TestReasonsCRUD(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
created, err := store.CreateReason(ctx, "Cheating", "Читерство")
if err != nil {
t.Fatalf("create reason: %v", err)
}
if created.ID == uuid.Nil || created.TextEn != "Cheating" || created.TextRu != "Читерство" {
t.Fatalf("created reason = %+v, want populated en/ru and id", created)
}
got, err := store.GetReason(ctx, created.ID)
if err != nil {
t.Fatalf("get reason: %v", err)
}
if got.TextEn != "Cheating" {
t.Errorf("get TextEn = %q, want Cheating", got.TextEn)
}
list, err := store.ListReasons(ctx)
if err != nil {
t.Fatalf("list reasons: %v", err)
}
if !containsReason(list, created.ID) {
t.Error("created reason missing from ListReasons")
}
updated, err := store.UpdateReason(ctx, created.ID, "Abuse", "Оскорбления")
if err != nil {
t.Fatalf("update reason: %v", err)
}
if updated.TextEn != "Abuse" || updated.TextRu != "Оскорбления" {
t.Errorf("updated reason = %+v, want Abuse/Оскорбления", updated)
}
if err := store.DeleteReason(ctx, created.ID); err != nil {
t.Fatalf("delete reason: %v", err)
}
if _, err := store.GetReason(ctx, created.ID); !errors.Is(err, account.ErrNotFound) {
t.Errorf("get deleted reason = %v, want ErrNotFound", err)
}
if _, err := store.UpdateReason(ctx, uuid.New(), "x", "y"); !errors.Is(err, account.ErrNotFound) {
t.Errorf("update missing reason = %v, want ErrNotFound", err)
}
}
// TestSuspensionReasonSnapshotSurvivesDelete checks that a block keeps the reason text it was
// stamped with even after the picklist entry is deleted (the FK nulls reason_id, the snapshot
// stays).
func TestSuspensionReasonSnapshotSurvivesDelete(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
id := provisionAccount(t)
reason, err := store.CreateReason(ctx, "Toxicity", "Токсичность")
if err != nil {
t.Fatalf("create reason: %v", err)
}
if _, err := store.Suspend(ctx, id, nil, reason.TextEn, reason.TextRu, &reason.ID); err != nil {
t.Fatalf("suspend with reason: %v", err)
}
if err := store.DeleteReason(ctx, reason.ID); err != nil {
t.Fatalf("delete reason: %v", err)
}
susp, ok, err := store.CurrentSuspension(ctx, id)
if err != nil || !ok {
t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err)
}
if susp.LocalizedReason("en") != "Toxicity" || susp.LocalizedReason("ru") != "Токсичность" {
t.Errorf("reason snapshot lost after delete: en=%q ru=%q", susp.LocalizedReason("en"), susp.LocalizedReason("ru"))
}
}
// containsReason reports whether list holds a reason with the given id.
func containsReason(list []account.Reason, id uuid.UUID) bool {
for _, r := range list {
if r.ID == id {
return true
}
}
return false
}