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 }