Files
2026-05-06 10:14:55 +03:00

202 lines
6.5 KiB
Go

package user_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
backendserver "galaxy/backend/internal/server"
"galaxy/backend/internal/server/middleware/basicauth"
"galaxy/backend/internal/user"
"github.com/google/uuid"
)
// TestUserSurfaceEndToEnd exercises the user-facing slice of the gin
// router with a real Postgres pool and the real handlers. It is the
// thinnest possible integration test that proves the wire layer wires
// the user.Service correctly — the contract test already validates the
// OpenAPI envelope on every endpoint, and the focused unit tests in
// user_test.go cover the business logic of Service.
func TestUserSurfaceEndToEnd(t *testing.T) {
db := startPostgres(t)
revoker := &recordingRevoker{}
lobby := &recordingLobbyCascade{}
notif := &recordingNotificationCascade{}
geo := &recordingGeoCascade{}
svc := user.NewService(user.Deps{
Store: user.NewStore(db),
Cache: user.NewCache(),
Lobby: lobby,
Notification: notif,
Geo: geo,
SessionRevoker: revoker,
UserNameMaxRetries: 10,
Now: time.Now,
})
uid, err := svc.EnsureByEmail(context.Background(), "nora@example.test", "en", "UTC", "")
if err != nil {
t.Fatalf("EnsureByEmail: %v", err)
}
const adminPassword = "user-e2e-test-secret"
verifier := basicauth.NewStaticVerifier(adminPassword)
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
AdminVerifier: verifier,
UserAccount: backendserver.NewUserAccountHandlers(svc, nil),
AdminUsers: backendserver.NewAdminUsersHandlers(svc, nil),
InternalUsers: backendserver.NewInternalUsersHandlers(svc, nil),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
// 1. GET /api/v1/user/account → 200 with default `free` entitlement.
resp := doRequest(t, handler, "GET", "/api/v1/user/account",
header("X-User-ID", uid.String()), nil)
if resp.Code != http.StatusOK {
t.Fatalf("user/account GET status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
body := decodeAccountResponse(t, resp)
if body.Account.Entitlement.PlanCode != user.TierFree {
t.Fatalf("entitlement.plan_code = %q, want %q", body.Account.Entitlement.PlanCode, user.TierFree)
}
// 2. PATCH profile → display_name updated.
resp = doRequest(t, handler, "PATCH", "/api/v1/user/account/profile",
header("X-User-ID", uid.String()),
map[string]any{"display_name": "Nora"})
if resp.Code != http.StatusOK {
t.Fatalf("profile PATCH status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
body = decodeAccountResponse(t, resp)
if body.Account.DisplayName != "Nora" {
t.Fatalf("display_name = %q, want %q", body.Account.DisplayName, "Nora")
}
// 3. POST admin entitlement → tier flips to monthly.
adminAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("operator:"+adminPassword))
resp = doRequest(t, handler, "POST", "/api/v1/admin/users/"+uid.String()+"/entitlements",
header("Authorization", adminAuth),
map[string]any{
"tier": "monthly",
"source": "admin",
"actor": map[string]any{"type": "admin", "id": "operator"},
})
if resp.Code != http.StatusOK {
t.Fatalf("admin entitlement POST status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
body = decodeAccountResponse(t, resp)
if body.Account.Entitlement.PlanCode != user.TierMonthly {
t.Fatalf("entitlement.plan_code = %q, want %q", body.Account.Entitlement.PlanCode, user.TierMonthly)
}
if body.Account.Entitlement.EndsAt == nil {
t.Fatalf("monthly entitlement returned ends_at = nil")
}
// 4. POST user soft-delete → 204.
resp = doRequest(t, handler, "POST", "/api/v1/user/account/delete",
header("X-User-ID", uid.String()), nil)
if resp.Code != http.StatusNoContent {
t.Fatalf("user delete POST status = %d, want 204; body=%s", resp.Code, resp.Body.String())
}
if revoker.calls != 1 {
t.Fatalf("session revoker calls = %d, want 1", revoker.calls)
}
if lobby.deletedCalls != 1 {
t.Fatalf("lobby.OnUserDeleted calls = %d, want 1", lobby.deletedCalls)
}
if notif.calls != 1 {
t.Fatalf("notification.OnUserDeleted calls = %d, want 1", notif.calls)
}
if geo.calls != 1 {
t.Fatalf("geo.OnUserDeleted calls = %d, want 1", geo.calls)
}
// 5. GET internal account-internal after soft-delete → 404.
resp = doRequest(t, handler, "GET", "/api/v1/internal/users/"+uid.String()+"/account-internal", nil, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("internal account-internal GET after delete = %d, want 404; body=%s",
resp.Code, resp.Body.String())
}
// 6. GET user/account on a fresh UUID → 404.
stranger := uuid.New().String()
resp = doRequest(t, handler, "GET", "/api/v1/user/account",
header("X-User-ID", stranger), nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("user/account GET stranger status = %d, want 404; body=%s", resp.Code, resp.Body.String())
}
}
type accountResponseBody struct {
Account struct {
UserID string `json:"user_id"`
Email string `json:"email"`
UserName string `json:"user_name"`
DisplayName string `json:"display_name"`
Entitlement struct {
PlanCode string `json:"plan_code"`
IsPaid bool `json:"is_paid"`
MaxRegisteredRaceNames int32 `json:"max_registered_race_names"`
EndsAt *string `json:"ends_at"`
} `json:"entitlement"`
} `json:"account"`
}
type headerKV struct{ key, value string }
func header(key, value string) headerKV { return headerKV{key: key, value: value} }
func doRequest(t *testing.T, handler http.Handler, method, path string, hdr any, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
var rdr *bytes.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
rdr = bytes.NewReader(raw)
} else {
rdr = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, "http://backend.internal"+path, rdr)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if hdr != nil {
switch h := hdr.(type) {
case headerKV:
req.Header.Set(h.key, h.value)
case []headerKV:
for _, kv := range h {
req.Header.Set(kv.key, kv.value)
}
}
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func decodeAccountResponse(t *testing.T, rec *httptest.ResponseRecorder) accountResponseBody {
t.Helper()
var out accountResponseBody
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("unmarshal AccountResponse: %v; body=%s", err, rec.Body.String())
}
return out
}