202 lines
6.5 KiB
Go
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
|
|
}
|