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