feat(admin): manual account blocking (suspensions)
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
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).
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/server"
|
||||
)
|
||||
|
||||
// blockStatusBody mirrors the backend's /block-status JSON for the test.
|
||||
type blockStatusBody struct {
|
||||
Blocked bool `json:"blocked"`
|
||||
Permanent bool `json:"permanent"`
|
||||
Until string `json:"until"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// TestSuspensionGate drives the gate end-to-end through the assembled HTTP server: a blocked
|
||||
// account is refused on a normal user route with 403 + account_blocked, the block-status probe
|
||||
// stays reachable and resolves the reason to the account's language, and an unblock restores
|
||||
// access.
|
||||
func TestSuspensionGate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts := account.NewStore(testDB)
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
DB: testDB,
|
||||
Accounts: accounts,
|
||||
})
|
||||
|
||||
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
id := acc.ID
|
||||
|
||||
// Not blocked: a normal route passes and block-status reports false.
|
||||
if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK {
|
||||
t.Fatalf("profile before block = %d, want 200", rec.Code)
|
||||
}
|
||||
if bs := blockStatus(t, srv, id); bs.Blocked {
|
||||
t.Fatal("fresh account should not be blocked")
|
||||
}
|
||||
|
||||
// Block permanently with a reason.
|
||||
if _, err := accounts.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
|
||||
// A normal route is now refused with the stable code.
|
||||
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("profile while blocked = %d, want 403", rec.Code)
|
||||
}
|
||||
if code := errorCode(t, rec); code != "account_blocked" {
|
||||
t.Fatalf("error code = %q, want account_blocked", code)
|
||||
}
|
||||
|
||||
// The block-status probe stays reachable and resolves the reason to the account's language.
|
||||
bs := blockStatus(t, srv, id)
|
||||
if !bs.Blocked || !bs.Permanent {
|
||||
t.Fatalf("block-status = %+v, want blocked+permanent", bs)
|
||||
}
|
||||
if bs.Reason != "Спам" {
|
||||
t.Errorf("reason = %q, want the Russian snapshot Спам", bs.Reason)
|
||||
}
|
||||
if bs.Until != "" {
|
||||
t.Errorf("until = %q, want empty for a permanent block", bs.Until)
|
||||
}
|
||||
|
||||
// Unblock restores access.
|
||||
if err := accounts.LiftSuspension(ctx, id); err != nil {
|
||||
t.Fatalf("lift: %v", err)
|
||||
}
|
||||
if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK {
|
||||
t.Fatalf("profile after unblock = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuspensionGateTemporaryUntil checks a temporary block reports its expiry through
|
||||
// block-status as a future RFC3339 instant and not permanent.
|
||||
func TestSuspensionGateTemporaryUntil(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts := account.NewStore(testDB)
|
||||
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
|
||||
id := provisionAccount(t)
|
||||
|
||||
until := time.Now().Add(24 * time.Hour)
|
||||
if _, err := accounts.Suspend(ctx, id, &until, "", "", nil); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
bs := blockStatus(t, srv, id)
|
||||
if !bs.Blocked || bs.Permanent {
|
||||
t.Fatalf("block-status = %+v, want blocked, not permanent", bs)
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, bs.Until)
|
||||
if err != nil {
|
||||
t.Fatalf("until %q is not RFC3339: %v", bs.Until, err)
|
||||
}
|
||||
if !parsed.After(time.Now()) {
|
||||
t.Errorf("until = %v, want a future instant", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// userGet issues an authenticated GET (X-User-ID) against the assembled server.
|
||||
func userGet(t *testing.T, srv *server.Server, path string, id uuid.UUID) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-User-ID", id.String())
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// blockStatus fetches and decodes the /block-status payload, asserting a 200.
|
||||
func blockStatus(t *testing.T, srv *server.Server, id uuid.UUID) blockStatusBody {
|
||||
t.Helper()
|
||||
rec := userGet(t, srv, "/api/v1/user/block-status", id)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("block-status status = %d, want 200", rec.Code)
|
||||
}
|
||||
var b blockStatusBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
|
||||
t.Fatalf("decode block-status: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// errorCode extracts the stable code from the backend error envelope.
|
||||
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var e struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||
t.Fatalf("decode error envelope: %v", err)
|
||||
}
|
||||
return e.Error.Code
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user