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).
210 lines
7.0 KiB
Go
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
|
|
}
|