Files
scrabble-game/backend/internal/inttest/suspension_gate_test.go
T
Ilia Denisov ef2c2d1eb9
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
feat(account): seed the time zone from the client's detected offset at creation
A new account's time_zone defaulted to 'UTC' until the player saved a profile, so the
robot's sleep window and the turn-timeout away-window sweeper — both anchored to the
account zone via account.ResolveZone — ran on UTC for every fresh player, skewing
robot-game timing until a manual Settings save. Seed the zone at creation instead, from
the client's detected "±HH:MM" offset.

- Carry browser_tz on the three account-creating auth requests (TelegramLoginRequest,
  GuestLoginRequest, EmailRequestRequest — the email account is provisioned at the
  code-request step, not at login) through the fbs envelope (+ Go/TS codegen), the
  gateway transcode + backend client, and the backend auth handlers into
  ProvisionTelegram / ProvisionGuest / ProvisionEmail.
- create() now writes time_zone explicitly: the validated detected offset, or 'UTC'
  (equal to the column default) when absent or malformed — deterministic, never guessed.
  The column is already NOT NULL DEFAULT 'UTC', so no migration is needed and existing
  accounts keep 'UTC'. An existing account is never overwritten on re-login.
- A detected zero offset is stored as "+00:00" (the zone is known and equals UTC),
  distinct from the "UTC" default that means "unknown" — which the feedback console's
  three-zone Filed display already reflects.
- Guard the guest handler against an empty payload (the bootstrap historically carried
  none) so it degrades to no-seed rather than panicking in GetRootAs*.
- Tests: zone seeding across Telegram/guest/email plus the "+00:00"/malformed/empty
  cases and the not-overwrite rule; codec round-trip for the three auth encoders.
  ARCHITECTURE + FUNCTIONAL(+ru) updated.
2026-06-22 18:43:24 +02:00

152 lines
4.7 KiB
Go

//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
}