From 9c2a396d6984fcc758f796c46ebb66a6d256a5aa Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 11:25:48 +0800 Subject: [PATCH] feat(platform): align user payloads --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 12 +- docs/parity/gochat_routes.txt | 3 +- internal/app/bootstrap.go | 2 +- internal/handler/api/v1/platform_e2e_test.go | 89 ++++++-- .../handler/api/v1/platform_user_handler.go | 121 ++++++++--- internal/router/router.go | 5 +- internal/service/platform_user_service.go | 205 +++++++++++++++--- .../service/platform_user_service_test.go | 76 ++++--- 8 files changed, 390 insertions(+), 123 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 72edb251..dfa207cb 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,12 +49,12 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(agents): align serializer fields`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(agents): align serializer fields`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(platform): align user payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(platform): align user payloads`. - Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: record placeholder smoke audit`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: P3.2 account agents serializer drift from `app/views/api/v1/models/_agent.json.jbuilder` is implemented. Account agent list/create/show/update responses now expose only the Chatwoot `_agent` fields plus enterprise `custom_role_id`, and no longer leak local `invited_by` or `account_user_id` membership fields. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. Next active implementation slice is B12 live smoke finding, remaining P3.2 account users/invitations drift from fresh reference evidence, or another route/controller/serializer drift opened from current `reference/chatwoot` inspection. +- Worktree status at this implementation checkpoint: P3.2 platform users drift from `Platform::Api::V1::UsersController`, `platform/api/v1/models/_user.json.jbuilder`, and `users/token.json.jbuilder` is implemented. Platform user show/create/update now return raw Chatwoot user payloads, create reuses existing users by email and idempotently grants platform-app permission, access-token and login/token payloads match the reference shape, custom attributes merge on update, and delete returns empty `200 OK`. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. Next active implementation slice is B12 live smoke finding, remaining P3.2 invitations drift from fresh reference evidence, or another route/controller/serializer drift opened from current `reference/chatwoot` inspection. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 906` after adding Chatwoot no-trailing-slash assignment policy routes. +- Route dump succeeds with `TOTAL: 905` after removing the non-Chatwoot `GET /platform/api/v1/users/:id/token` route and keeping the Chatwoot `GET /platform/api/v1/users/:id/login` route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. - Tracked frontend-critical route audit covers 362 Chatwoot routes: 353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. @@ -209,6 +209,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(platform): align user payloads` | Advances P3.2 platform user parity by matching Chatwoot `Platform::Api::V1::UsersController`, `app/views/platform/api/v1/models/_user.json.jbuilder`, `users/token.json.jbuilder`, and platform routes. GoChat now returns raw user/token/login payloads instead of local envelopes, creates or reuses users by email with confirmed active status, idempotently grants platform-app permission, generates/reuses personal access tokens, merges `custom_attributes` on update, returns empty `200 OK` deletes, registers `GET /platform/api/v1/users/:id/login`, and removes the non-reference `GET /platform/api/v1/users/:id/token` route from the dump. | `go test ./internal/handler/api/v1 -run PlatformUser -count=1`; `go test ./internal/service -run PlatformUser -count=1`; `go test ./internal/router -run RegisterRoutes -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 905`; tracked route parity remains `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. | Platform users move to Review for current reference evidence; continue remaining P3.2 invitations drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. | | `feat(agents): align serializer fields` | Advances P3.2 agents serializer parity by matching Chatwoot `app/views/api/v1/models/_agent.json.jbuilder`. GoChat account agent list/create/show/update payloads now return only `id`, `account_id`, `availability_status`, `auto_offline`, `confirmed`, `email`, `provider`, `available_name`, optional `custom_attributes`, `name`, `role`, `thumbnail`, and enterprise `custom_role_id`; local `invited_by` and `account_user_id` fields are no longer emitted. | `go test ./internal/handler/api/v1 -run 'TestAgentHandlerSuite/TestCreateAgent\|TestAgentHandlerSuite/TestListAfterCreate\|TestAgentHandlerSuite/TestGetAgent' -count=1`; `go test ./internal/handler/api/v1 -run AgentHandler -count=1`; `go test ./internal/service ./internal/repository ./internal/router -run Agent -count=1`; sandboxed full `go test ./...` failed only on local socket/miniredis/httptest restrictions; escalated full `go test ./...` passed; `git diff --check`. | Agent serializer field shape moves to Review for current reference evidence; continue remaining P3.2 account users/invitations drift or B12 live smoke. | | `docs: record placeholder smoke audit` | Documentation/tooling checkpoint for Phase 6 and B12 readiness. Adds `docs/parity/placeholder_audit.md`, classifies remaining `chatwootParityStub` call sites as public webhook nil-handler guards only, refreshes `docs/parity/frontend_smoke_report.md`, and moves Phase 6 placeholder burn-down to Review for the inspected account/contact/conversation/message/inbox/widget/public critical paths. | `rg -n "chatwootParityStub" internal/handler internal/service internal/router internal/channel`; `rg -n "chatwootParityStub" internal/router/router.go | wc -l`; `bash -n scripts/parity_frontend_smoke.sh`; `scripts/parity_frontend_smoke.sh --check`; `scripts/parity_frontend_smoke.sh --print`; `git diff --check`. | Continue B12 live API/browser/enterprise smoke when the full stack is available; otherwise continue the next evidence-backed P2/P3 serializer/controller drift such as P3.2 account users/invitations. | | `feat(assignment-policies): align chatwoot payloads` | Advances P4.9/Phase 2/3 assignment policy parity by matching Chatwoot `AssignmentPoliciesController`, `AssignmentPolicies::InboxesController`, `Inboxes::AssignmentPoliciesController`, Jbuilder views, frontend `assignmentPolicies` API/store usage, and `routes.rb:306-313`. GoChat now registers no-trailing-slash Chatwoot assignment policy routes, persists Chatwoot assignment policy fields, returns raw array/object payloads, accepts raw and nested request bodies, exposes assigned inbox counts, replaces inbox policy associations, lists policy inboxes as `{ inboxes: [...] }`, and returns empty `200 OK` deletes. | `go test ./internal/handler/api/v1 -run AssignmentPolicy -count=1`; `go test ./internal/service ./internal/repository ./internal/router ./internal/app ./cmd/migrate ./cmd/route_parity -count=1`; sandboxed full `go test ./...` failed only on local socket restrictions, escalated full `go test ./...` passed; `git diff --check`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`. Route dump is `TOTAL: 906`; tracked route parity remains `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. | Assignment policies move to Review for current reference evidence; continue Phase 6 placeholder audit, B12 live smoke, or the next evidence-backed route/controller/serializer drift. | @@ -1674,7 +1675,7 @@ Frontend-critical API groups to audit first: | ID | Area | Scope | Status | | --- | --- | --- | --- | | P3.1 | Auth/session/profile | Login, logout, current user, profile, availability, notification settings, Devise password reset, and confirmation. | Done; profile account permission arrays and user notification-settings raw payloads now match Chatwoot role/custom-role semantics and dashboard settings store expectations. | -| P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; account show/update/create/cache-key/active-at plus agents/teams/team-member, assignable-agent settings payloads, profile account permission arrays, platform account-user raw/idempotent membership, agents `_agent` serializer fields, `order_by_full_name` ordering with Chatwoot no-pagination index behavior, agents bulk invite best-effort behavior, and agent update PATCH/`auto_offline`/validation semantics now have focused Chatwoot-style coverage. | +| P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; account show/update/create/cache-key/active-at plus agents/teams/team-member, assignable-agent settings payloads, profile account permission arrays, platform account-user raw/idempotent membership, platform user raw serializer/create-login-token/update-delete semantics, agents `_agent` serializer fields, `order_by_full_name` ordering with Chatwoot no-pagination index behavior, agents bulk invite best-effort behavior, and agent update PATCH/`auto_offline`/validation semantics now have focused Chatwoot-style coverage. | | P3.3 | Inboxes/channels | Inbox CRUD, assignable agents, avatars, channel config, business hours, widget config. | Review; assignable-agent payload drift closed, broader inbox serializer drift remains evidence-driven. | | P3.4 | Conversations/messages | List filters, status changes, assignment, labels, private notes, attachments, drafts, typing/read events. | Review | | P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation, avatars, and shared attachments. | Doing; company multipart avatar create/update and fixed 25-row company pagination now match the reused dashboard companies store/API path. | @@ -2196,6 +2197,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.2 platform user checkpoint prepared as `feat(platform): align user payloads`; audited Chatwoot `Platform::Api::V1::UsersController`, `platform/api/v1/models/_user.json.jbuilder`, `users/token.json.jbuilder`, and platform user routes. Platform user show/create/update now return raw Chatwoot user payloads, create reuses existing users by email and creates the platform-app permissible idempotently, access tokens are generated/reused for user payloads, `GET /users/:id/login` returns a Chatwoot-shaped URL payload, `POST /users/:id/token` returns `{ access_token, expiry, user }`, update merges `custom_attributes`, and destroy returns empty `200 OK`. The stale non-reference `GET /platform/api/v1/users/:id/token` route was removed from the dump, leaving route dump `TOTAL: 905` and tracked route parity at `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. Focused platform handler/service/router tests, route parity test/generation, full `go test ./...`, and `git diff --check` passed; continue remaining P3.2 invitations drift or B12 live smoke. - 2026-06-06: P3.5 company avatar form/pagination checkpoint prepared as `feat(companies): align avatar form payloads`; audited Chatwoot enterprise `CompaniesController`, company Jbuilder views, reused dashboard `api/companies.js`, and Pinia `stores/companies.js`. Company create/update now accepts nested multipart `company[...]` form payloads generated when the frontend includes an avatar, maps `company[avatar]` into the existing serialized `avatar_url` boundary, preserves nested multipart `company[custom_attributes][...]`, and makes company list/search/contact list/search use Chatwoot's fixed `RESULTS_PER_PAGE = 25` instead of local `per_page` overrides. Focused company handler tests, focused company service tests, full `go test ./...`, and `git diff --check` passed; no route artifacts changed. - 2026-06-06: P3.11 account integrations apps/hooks checkpoint prepared as `feat(integrations): align app hook payloads`; audited Chatwoot `routes.rb:345-348`, `Integrations::AppsController`, `Integrations::HooksController`, app/hook Jbuilder views, `Integrations::App`, `Integrations::Hook`, `config/integration/apps.yml`, and reused dashboard `api/integrations.js`, `store/modules/integrations.js`, and settings integration components. Account integration apps/hooks now return Chatwoot `{ payload: [...] }` app index payloads and raw app/hook objects, accept raw frontend hook bodies plus nested `{ hook: ... }`, register frontend no-trailing-slash app/hook routes and hook `PATCH`, persist `app_id/reference_id`, scope hook show/update/delete by account, serialize boolean `status`, account/inbox `hook_type`, `settings`, `inbox`, app `hooks`, settings schemas, and visible properties, seed additional reference app catalog rows, and return empty `200 OK` delete. Focused integration hook handler/service tests, migration test, route parity test, router focused test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 869` and tracked route parity is `321 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 328`. - 2026-06-06: P3.10 account webhook API checkpoint prepared as `feat(webhooks): align account payloads`; audited Chatwoot `routes.rb:342`, `WebhooksController`, account webhook Jbuilder views, `Webhook`, reused dashboard `api/webhooks.js`, `store/modules/webhooks.js`, and settings Webhooks components. Account webhook APIs now return Chatwoot `{ payload: { webhooks } }` list payloads and `{ payload: { webhook } }` mutation/show payloads, accept nested `{ webhook: ... }` frontend bodies plus legacy raw bodies, register frontend `PATCH` update, persist `name/url/subscriptions/inbox_id/secret`, scope show/update/delete by account, validate HTTP/HTTPS URLs and allowed subscriptions, return empty `200 OK` delete, and keep outbound delivery event filtering on the same rows. Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 865` and tracked route parity is `313 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 320`. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 4da1db2d..8e6d3fca 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -458,7 +458,6 @@ GET /platform/api/v1/installation_configs/:id GET /platform/api/v1/users GET /platform/api/v1/users/:id GET /platform/api/v1/users/:id/login -GET /platform/api/v1/users/:id/token GET /platform/api/v1/widget_tests/ GET /platform/api/v1/widget_tests/:type GET /public/api/v1/conversations/:conversation_uuid/csats @@ -904,4 +903,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 906 +TOTAL: 905 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index dfdf1cf4..d92d4eec 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -610,7 +610,7 @@ func Bootstrap(env string) (*App, error) { summaryReportService := service.NewSummaryReportService(reportingEventsRollupRepo) dashboardAppService := service.NewDashboardAppService(dashboardAppRepo) platformAppService := service.NewPlatformAppService(platformAppRepo, accessTokenRepo, permissibleRepo) - platformUserService := service.NewPlatformUserService(userRepo, permissibleRepo) + platformUserService := service.NewPlatformUserService(userRepo, permissibleRepo, accessTokenRepo, accountUserRepo) // M12: AgentBot services (AgentBot + AgentBotInbox + Listener) agentBotService := service.NewAgentBotService(agentBotRepo) agentBotInboxService := service.NewAgentBotInboxService(agentBotInboxRepo, agentBotRepo) diff --git a/internal/handler/api/v1/platform_e2e_test.go b/internal/handler/api/v1/platform_e2e_test.go index 4a953acf..6ca797aa 100644 --- a/internal/handler/api/v1/platform_e2e_test.go +++ b/internal/handler/api/v1/platform_e2e_test.go @@ -33,6 +33,14 @@ func unpackData(t *testing.T, body []byte) map[string]interface{} { return dataMap } +func unpackRawObject(t *testing.T, body []byte) map[string]interface{} { + t.Helper() + var data map[string]interface{} + err := json.Unmarshal(body, &data) + require.NoError(t, err, "response is not valid JSON: %s", string(body)) + return data +} + // parseID extracts the numeric "id" from a data envelope and returns it as a string for URL paths. func parseID(t *testing.T, data map[string]interface{}) string { t.Helper() @@ -54,10 +62,12 @@ func setupPlatformTokenTestE2E(t *testing.T) (*gin.Engine, *repository.Permissib userRepo := repository.NewUserRepo(db) accountRepo := repository.NewAccountRepo(db) + accountUserRepo := repository.NewAccountUserRepo(db) + accessTokenRepo := repository.NewAccessTokenRepo(db) agentBotRepo := repository.NewAgentBotRepo(db) permissibleRepo := repository.NewPermissibleRepo(db) accountService := service.NewAccountService(accountRepo) - platformUserService := service.NewPlatformUserService(userRepo, permissibleRepo) + platformUserService := service.NewPlatformUserService(userRepo, permissibleRepo, accessTokenRepo, accountUserRepo) platformUser := v1.NewPlatformUserHandler(platformUserService) platformAccount := v1.NewPlatformAccountHandler(accountRepo, permissibleRepo, accountService) @@ -76,6 +86,7 @@ func setupPlatformTokenTestE2E(t *testing.T) (*gin.Engine, *repository.Permissib platformGroup.GET("/users", platformUser.List) platformGroup.GET("/users/:id", platformUser.Show) platformGroup.POST("/users", platformUser.Create) + platformGroup.GET("/users/:id/login", platformUser.Login) platformGroup.POST("/users/:id/login", platformUser.Login) platformGroup.POST("/users/:id/token", platformUser.Token) platformGroup.PATCH("/users/:id", platformUser.Update) @@ -114,10 +125,32 @@ func TestPlatformUserE2E_Create(t *testing.T) { req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) - assert.Equal(t, http.StatusCreated, w.Code) - data := unpackData(t, w.Body.Bytes()) + assert.Equal(t, http.StatusOK, w.Code) + data := unpackRawObject(t, w.Body.Bytes()) assert.Equal(t, "Test User", data["name"]) assert.Equal(t, "test@example.com", data["email"]) + assert.NotEmpty(t, data["access_token"]) + assert.Contains(t, data, "accounts") +} + +func TestPlatformUserE2E_CreateExistingUserReturnsExistingAndPermits(t *testing.T) { + engine, permissibleRepo, userRepo, _ := setupPlatformTokenTestE2E(t) + ctx := t.Context() + existing := &model.User{Name: "Old Name", Email: "existing@example.com", Provider: "email", Active: true} + require.NoError(t, userRepo.Create(ctx, existing)) + + body := `{"name": "New Name", "email": "existing@example.com", "password": "secret123"}` + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + data := unpackRawObject(t, w.Body.Bytes()) + assert.Equal(t, "Old Name", data["name"]) + assert.Equal(t, float64(existing.ID), data["id"]) + _, err := permissibleRepo.FindByPlatformAppAndResource(ctx, uint(1), model.PermissibleTypeUser, existing.ID) + assert.NoError(t, err) } func TestPlatformUserE2E_Show(t *testing.T) { @@ -129,8 +162,8 @@ func TestPlatformUserE2E_Show(t *testing.T) { req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - createData := unpackData(t, w.Body.Bytes()) + require.Equal(t, http.StatusOK, w.Code) + createData := unpackRawObject(t, w.Body.Bytes()) userID := parseID(t, createData) // Show user @@ -139,7 +172,7 @@ func TestPlatformUserE2E_Show(t *testing.T) { engine.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - showData := unpackData(t, w.Body.Bytes()) + showData := unpackRawObject(t, w.Body.Bytes()) assert.Equal(t, "Show User", showData["name"]) } @@ -152,20 +185,22 @@ func TestPlatformUserE2E_Update(t *testing.T) { req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - createData := unpackData(t, w.Body.Bytes()) + require.Equal(t, http.StatusOK, w.Code) + createData := unpackRawObject(t, w.Body.Bytes()) userID := parseID(t, createData) // Update user - updateBody := `{"name": "Updated Name", "email": "updated@example.com"}` + updateBody := `{"name": "Updated Name", "email": "updated@example.com", "custom_attributes": {"tier": "gold"}}` w = httptest.NewRecorder() req, _ = http.NewRequest("PATCH", "/platform/api/v1/users/"+userID, bytes.NewBufferString(updateBody)) req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - updateData := unpackData(t, w.Body.Bytes()) + updateData := unpackRawObject(t, w.Body.Bytes()) assert.Equal(t, "Updated Name", updateData["name"]) + attrs := updateData["custom_attributes"].(map[string]interface{}) + assert.Equal(t, "gold", attrs["tier"]) } func TestPlatformUserE2E_Destroy(t *testing.T) { @@ -177,8 +212,8 @@ func TestPlatformUserE2E_Destroy(t *testing.T) { req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - createData := unpackData(t, w.Body.Bytes()) + require.Equal(t, http.StatusOK, w.Code) + createData := unpackRawObject(t, w.Body.Bytes()) userID := parseID(t, createData) // Delete user @@ -186,7 +221,7 @@ func TestPlatformUserE2E_Destroy(t *testing.T) { req, _ = http.NewRequest("DELETE", "/platform/api/v1/users/"+userID, nil) engine.ServeHTTP(w, req) - assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, http.StatusOK, w.Code) } func TestPlatformUserE2E_Login(t *testing.T) { @@ -198,16 +233,19 @@ func TestPlatformUserE2E_Login(t *testing.T) { req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - createData := unpackData(t, w.Body.Bytes()) + require.Equal(t, http.StatusOK, w.Code) + createData := unpackRawObject(t, w.Body.Bytes()) userID := parseID(t, createData) // Login w = httptest.NewRecorder() - req, _ = http.NewRequest("POST", "/platform/api/v1/users/"+userID+"/login", nil) + req, _ = http.NewRequest("GET", "/platform/api/v1/users/"+userID+"/login", nil) engine.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + loginData := unpackRawObject(t, w.Body.Bytes()) + assert.Contains(t, loginData["url"], "email=sso%40example.com") + assert.Contains(t, loginData["url"], "sso_auth_token=") } func TestPlatformUserE2E_Token(t *testing.T) { @@ -219,8 +257,8 @@ func TestPlatformUserE2E_Token(t *testing.T) { req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - createData := unpackData(t, w.Body.Bytes()) + require.Equal(t, http.StatusOK, w.Code) + createData := unpackRawObject(t, w.Body.Bytes()) userID := parseID(t, createData) // Token @@ -229,6 +267,11 @@ func TestPlatformUserE2E_Token(t *testing.T) { engine.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + tokenData := unpackRawObject(t, w.Body.Bytes()) + assert.NotEmpty(t, tokenData["access_token"]) + assert.Nil(t, tokenData["expiry"]) + userInfo := tokenData["user"].(map[string]interface{}) + assert.Equal(t, "Token User", userInfo["name"]) } func TestPlatformUserE2E_List(t *testing.T) { @@ -241,7 +284,7 @@ func TestPlatformUserE2E_List(t *testing.T) { req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) + require.Equal(t, http.StatusOK, w.Code) } // List users @@ -393,8 +436,8 @@ func TestPlatformAccountUserE2E_Create(t *testing.T) { req, _ = http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(userBody)) req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - userData := unpackData(t, w.Body.Bytes()) + require.Equal(t, http.StatusOK, w.Code) + userData := unpackRawObject(t, w.Body.Bytes()) userID := parseID(t, userData) // Create AccountUser @@ -441,8 +484,8 @@ func TestPlatformAccountUserE2E_Index(t *testing.T) { req, _ = http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(userBody)) req.Header.Set("Content-Type", "application/json") engine.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - userData := unpackData(t, w.Body.Bytes()) + require.Equal(t, http.StatusOK, w.Code) + userData := unpackRawObject(t, w.Body.Bytes()) userID := parseID(t, userData) // Add user to account diff --git a/internal/handler/api/v1/platform_user_handler.go b/internal/handler/api/v1/platform_user_handler.go index 4492f9a2..93ac4e0b 100644 --- a/internal/handler/api/v1/platform_user_handler.go +++ b/internal/handler/api/v1/platform_user_handler.go @@ -2,9 +2,11 @@ package v1 import ( "net/http" + "net/url" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" @@ -37,13 +39,13 @@ func (h *PlatformUserHandler) Show(c *gin.Context) { platformAppID := getPlatformAppID(c) - user, err := h.svc.GetUser(c.Request.Context(), platformAppID, userID) + user, err := h.svc.GetUserResponse(c.Request.Context(), platformAppID, userID) if err != nil { handlePlatformError(c, err) return } - response.OK(c, user) + c.JSON(http.StatusOK, serializePlatformUser(user)) } // Create creates a new user and auto-creates Permissible record. @@ -53,27 +55,27 @@ func (h *PlatformUserHandler) Show(c *gin.Context) { func (h *PlatformUserHandler) Create(c *gin.Context) { platformAppID := getPlatformAppID(c) - var req struct { - Name string `json:"name" binding:"required"` - Email string `json:"email" binding:"required,email"` - Password string `json:"password,omitempty"` - } + var req service.PlatformUserRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } + if req.Email == "" { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "email is required") + return + } - user, err := h.svc.CreateUser(c.Request.Context(), platformAppID, req.Name, req.Email, req.Password) + user, err := h.svc.CreateUser(c.Request.Context(), platformAppID, req) if err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error()) return } - response.Created(c, user) + c.JSON(http.StatusOK, serializePlatformUser(user)) } // Login generates an SSO login link for a user. -// POST /platform/api/v1/users/:id/login +// GET /platform/api/v1/users/:id/login // Reference: Chatwoot Platform::Api::V1::UsersController#login // Returns: { url: sso_redirect_url } func (h *PlatformUserHandler) Login(c *gin.Context) { @@ -85,18 +87,17 @@ func (h *PlatformUserHandler) Login(c *gin.Context) { platformAppID := getPlatformAppID(c) - // Verify Permissible access first - if err := h.svc.ValidatePermissible(c.Request.Context(), platformAppID, userID); err != nil { + user, err := h.svc.GetUserResponse(c.Request.Context(), platformAppID, userID) + if err != nil { handlePlatformError(c, err) return } - // SSO link generation — requires full SSO implementation (HMAC/JWT token, user lookup). - // Production note: When SSO middleware is wired, this endpoint will generate - // a signed redirect URL based on the user record and SSO configuration. - response.OK(c, gin.H{ - "url": "", // Would be populated with SSO redirect URL - "id": userID, + query := url.Values{} + query.Set("email", user.User.Email) + query.Set("sso_auth_token", user.AccessToken) + c.JSON(http.StatusOK, gin.H{ + "url": "/app/login?" + query.Encode(), }) } @@ -119,15 +120,22 @@ func (h *PlatformUserHandler) Token(c *gin.Context) { return } - user, err := h.svc.GetUser(c.Request.Context(), platformAppID, userID) + user, err := h.svc.TokenResponse(c.Request.Context(), platformAppID, userID) if err != nil { handlePlatformError(c, err) return } - response.OK(c, gin.H{ - "id": user.ID, - "sso_auth_token": "", // Would be populated with SSO token + c.JSON(http.StatusOK, gin.H{ + "access_token": user.AccessToken, + "expiry": nil, + "user": gin.H{ + "id": user.User.ID, + "name": user.User.Name, + "display_name": user.User.DisplayName, + "email": user.User.Email, + "pubsub_token": user.User.PubsubToken, + }, }) } @@ -144,22 +152,19 @@ func (h *PlatformUserHandler) Update(c *gin.Context) { platformAppID := getPlatformAppID(c) - var req struct { - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - } + var req service.PlatformUserRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } - user, err := h.svc.UpdateUser(c.Request.Context(), platformAppID, userID, req.Name, req.Email) + user, err := h.svc.UpdateUser(c.Request.Context(), platformAppID, userID, req) if err != nil { handlePlatformError(c, err) return } - response.OK(c, user) + c.JSON(http.StatusOK, serializePlatformUser(user)) } // Destroy deletes a user. @@ -180,7 +185,63 @@ func (h *PlatformUserHandler) Destroy(c *gin.Context) { return } - response.NoContent(c) + c.Status(http.StatusOK) +} + +func serializePlatformUser(payload *service.PlatformUserResponse) gin.H { + if payload == nil { + return gin.H{} + } + user := payload.User + out := gin.H{ + "access_token": payload.AccessToken, + "account_id": activeAccountID(payload.AccountUsers), + "available_name": nonEmpty(user.DisplayName, user.Name), + "avatar_url": user.AvatarURL, + "confirmed": user.ConfirmedAt != nil, + "display_name": user.DisplayName, + "message_signature": user.MessageSignature, + "email": user.Email, + "id": user.ID, + "name": user.Name, + "provider": nonEmpty(user.Provider, "email"), + "pubsub_token": user.PubsubToken, + "role": activeAccountRole(payload.AccountUsers), + "ui_settings": jsonObject(user.UISettings), + "uid": user.UID, + "accounts": serializePlatformUserAccounts(payload.AccountUsers), + } + if attrs := jsonObject(user.CustomAttributes); len(attrs) > 0 { + out["custom_attributes"] = attrs + } + return out +} + +func serializePlatformUserAccounts(accountUsers []model.AccountUser) []gin.H { + accounts := make([]gin.H, 0, len(accountUsers)) + for _, au := range accountUsers { + accounts = append(accounts, gin.H{ + "id": au.AccountID, + "name": au.Account.Name, + "active_at": au.ActiveAt, + "role": au.Role, + }) + } + return accounts +} + +func activeAccountID(accountUsers []model.AccountUser) any { + if len(accountUsers) == 0 { + return nil + } + return accountUsers[0].AccountID +} + +func activeAccountRole(accountUsers []model.AccountUser) any { + if len(accountUsers) == 0 { + return nil + } + return accountUsers[0].Role } // --- Helper functions for Platform API handlers --- @@ -235,4 +296,4 @@ func (h *PlatformUserHandler) List(c *gin.Context) { } response.OKWithMeta(c, users[start:end], page.Page, page.PerPage, total) -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index 08cb4596..586c806d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1872,10 +1872,6 @@ func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) { g.PUT("/agent_bots/:id/avatar", h.AgentBot.PlatformUpdateAvatar) g.POST("/agent_bots/:id/reset", h.AgentBot.PlatformResetConfig) - // G16: Platform Users SSO — super-admin initiates SSO for a user - // Reference: Chatwoot Platform::Api::V1::UsersController #login, #token - v1.RegisterPlatformUserSSORoutes(g, h.PlatformUserSSO) - // InstallationConfig CRUD (super-admin, global key-value config) // Reference: Chatwoot Platform::Api::V1::InstallationConfigsController g.GET("/installation_configs", h.InstallationConfig.List) @@ -1906,6 +1902,7 @@ func registerPlatformTokenRoutes(g *gin.RouterGroup, h *Handlers) { g.GET("/users", h.PlatformUser.List) g.GET("/users/:id", h.PlatformUser.Show) g.POST("/users", h.PlatformUser.Create) + g.GET("/users/:id/login", h.PlatformUser.Login) g.POST("/users/:id/login", h.PlatformUser.Login) g.POST("/users/:id/token", h.PlatformUser.Token) g.PATCH("/users/:id", h.PlatformUser.Update) diff --git a/internal/service/platform_user_service.go b/internal/service/platform_user_service.go index 9cb4071d..aaee3c19 100644 --- a/internal/service/platform_user_service.go +++ b/internal/service/platform_user_service.go @@ -8,27 +8,58 @@ package service // The Permissible system governs which resources each PlatformApp can access. import ( "context" + "encoding/json" "errors" + "fmt" + "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" + "gorm.io/datatypes" + "gorm.io/gorm" ) // PlatformUserService provides user CRUD for Platform API (AccessToken auth). type PlatformUserService struct { - userRepo *repository.UserRepo - permissibleRepo *repository.PermissibleRepo + userRepo *repository.UserRepo + permissibleRepo *repository.PermissibleRepo + accessTokenRepo *repository.AccessTokenRepo + accountUserRepo *repository.AccountUserRepo +} + +type PlatformUserRequest struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Password string `json:"password"` + CustomAttributes map[string]any `json:"custom_attributes"` +} + +type PlatformUserResponse struct { + User model.User + AccessToken string + AccountUsers []model.AccountUser } // NewPlatformUserService creates a new PlatformUserService. func NewPlatformUserService( userRepo *repository.UserRepo, permissibleRepo *repository.PermissibleRepo, + extras ...any, ) *PlatformUserService { - return &PlatformUserService{ + svc := &PlatformUserService{ userRepo: userRepo, permissibleRepo: permissibleRepo, } + for _, extra := range extras { + switch repo := extra.(type) { + case *repository.AccessTokenRepo: + svc.accessTokenRepo = repo + case *repository.AccountUserRepo: + svc.accountUserRepo = repo + } + } + return svc } // ValidatePermissible checks that the PlatformApp has permission to access the target user. @@ -52,38 +83,63 @@ func (s *PlatformUserService) GetUser(ctx context.Context, platformAppID uint, u return s.userRepo.FindByID(ctx, userID) } +func (s *PlatformUserService) GetUserResponse(ctx context.Context, platformAppID uint, userID uint) (*PlatformUserResponse, error) { + user, err := s.GetUser(ctx, platformAppID, userID) + if err != nil { + return nil, err + } + return s.BuildUserResponse(ctx, user) +} + // CreateUser creates a new user and auto-creates a Permissible record. // Reference: Chatwoot UsersController#create — skips confirmation, auto-permissible -func (s *PlatformUserService) CreateUser(ctx context.Context, platformAppID uint, name, email, password string) (*model.User, error) { - user := &model.User{ - Name: name, - Email: email, - Provider: "email", - } - if password != "" { - user.Password = password - } - - if err := s.userRepo.Create(ctx, user); err != nil { - return nil, err +func (s *PlatformUserService) CreateUser(ctx context.Context, platformAppID uint, req PlatformUserRequest) (*PlatformUserResponse, error) { + user, err := s.userRepo.FindByEmail(ctx, req.Email) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + confirmedAt := time.Now().UTC() + attrs, attrsErr := marshalCustomAttributes(req.CustomAttributes) + if attrsErr != nil { + return nil, attrsErr + } + user = &model.User{ + Name: req.Name, + DisplayName: req.DisplayName, + Email: req.Email, + Password: req.Password, + Provider: "email", + CustomAttributes: attrs, + ConfirmedAt: &confirmedAt, + Active: true, + } + if err := s.userRepo.Create(ctx, user); err != nil { + return nil, err + } } // Auto-create Permissible record (PlatformApp can access this user) - perm := &model.Permissible{ - PlatformAppID: platformAppID, - PermissibleType: model.PermissibleTypeUser, - PermissibleID: user.ID, - } - if err := s.permissibleRepo.Create(ctx, perm); err != nil { - // Non-critical — permissible creation failure shouldn't block user creation + if _, err := s.permissibleRepo.FindByPlatformAppAndResource(ctx, platformAppID, model.PermissibleTypeUser, user.ID); err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + perm := &model.Permissible{ + PlatformAppID: platformAppID, + PermissibleType: model.PermissibleTypeUser, + PermissibleID: user.ID, + } + if err := s.permissibleRepo.Create(ctx, perm); err != nil { + return nil, err + } } - return user, nil + return s.BuildUserResponse(ctx, user) } // UpdateUser updates a user, after verifying permissible access. // Reference: Chatwoot UsersController#update — merges custom_attributes, skips reconfirmation -func (s *PlatformUserService) UpdateUser(ctx context.Context, platformAppID uint, userID uint, name, email string) (*model.User, error) { +func (s *PlatformUserService) UpdateUser(ctx context.Context, platformAppID uint, userID uint, req PlatformUserRequest) (*PlatformUserResponse, error) { if err := s.ValidatePermissible(ctx, platformAppID, userID); err != nil { return nil, err } @@ -93,17 +149,84 @@ func (s *PlatformUserService) UpdateUser(ctx context.Context, platformAppID uint return nil, err } - if name != "" { - user.Name = name + if req.Name != "" { + user.Name = req.Name } - if email != "" { - user.Email = email + if req.DisplayName != "" { + user.DisplayName = req.DisplayName + } + if req.Email != "" { + user.Email = req.Email + } + if req.Password != "" { + user.Password = req.Password + } + if req.CustomAttributes != nil { + merged := platformJSONObj(user.CustomAttributes) + for key, value := range req.CustomAttributes { + merged[key] = value + } + attrs, err := marshalCustomAttributes(merged) + if err != nil { + return nil, err + } + user.CustomAttributes = attrs } if err := s.userRepo.Update(ctx, user); err != nil { return nil, err } - return user, nil + return s.BuildUserResponse(ctx, user) +} + +func (s *PlatformUserService) BuildUserResponse(ctx context.Context, user *model.User) (*PlatformUserResponse, error) { + if user == nil { + return nil, errors.New("user is nil") + } + accessToken, err := s.currentAccessToken(ctx, user.ID) + if err != nil { + return nil, err + } + var accountUsers []model.AccountUser + if s.accountUserRepo != nil { + accountUsers, err = s.accountUserRepo.FindByUserWithAccounts(ctx, user.ID) + if err != nil { + return nil, err + } + } + return &PlatformUserResponse{User: *user, AccessToken: accessToken, AccountUsers: accountUsers}, nil +} + +func (s *PlatformUserService) TokenResponse(ctx context.Context, platformAppID uint, userID uint) (*PlatformUserResponse, error) { + return s.GetUserResponse(ctx, platformAppID, userID) +} + +func (s *PlatformUserService) currentAccessToken(ctx context.Context, userID uint) (string, error) { + if s.accessTokenRepo == nil { + return "", nil + } + tokens, err := s.accessTokenRepo.FindActiveByOwner(ctx, model.AccessTokenOwnerTypeUser, userID) + if err != nil { + return "", fmt.Errorf("failed to load access token: %w", err) + } + if len(tokens) > 0 { + return tokens[0].Token, nil + } + plainToken, err := generatePlatformAccessToken() + if err != nil { + return "", err + } + accessToken := &model.AccessToken{ + OwnerType: model.AccessTokenOwnerTypeUser, + OwnerID: userID, + Token: plainToken, + TokenPrefix: tokenPrefix(plainToken), + Name: "Personal Access Token", + } + if err := s.accessTokenRepo.Create(ctx, accessToken); err != nil { + return "", err + } + return plainToken, nil } // DeleteUser deletes a user, after verifying permissible access. @@ -115,6 +238,28 @@ func (s *PlatformUserService) DeleteUser(ctx context.Context, platformAppID uint return s.userRepo.Delete(ctx, userID) } +func marshalCustomAttributes(attrs map[string]any) (datatypes.JSON, error) { + if attrs == nil { + return datatypes.JSON([]byte(`{}`)), nil + } + data, err := json.Marshal(attrs) + if err != nil { + return nil, err + } + return datatypes.JSON(data), nil +} + +func platformJSONObj(raw datatypes.JSON) map[string]any { + if len(raw) == 0 { + return map[string]any{} + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil || obj == nil { + return map[string]any{} + } + return obj +} + // ListPermissibleUsers returns all users that the PlatformApp has permissible access to. func (s *PlatformUserService) ListPermissibleUsers(ctx context.Context, platformAppID uint) ([]model.User, error) { permissibles, err := s.permissibleRepo.FindByPlatformAppID(ctx, platformAppID) @@ -133,4 +278,4 @@ func (s *PlatformUserService) ListPermissibleUsers(ctx context.Context, platform } } return users, nil -} \ No newline at end of file +} diff --git a/internal/service/platform_user_service_test.go b/internal/service/platform_user_service_test.go index c50f2755..2f46402a 100644 --- a/internal/service/platform_user_service_test.go +++ b/internal/service/platform_user_service_test.go @@ -15,10 +15,12 @@ import ( func setupPlatformUserServiceTest(t *testing.T) (*service.PlatformUserService, *repository.PermissibleRepo) { t.Helper() - db := testutil.NewTestDBWithModels(t, &model.User{}, &model.Permissible{}, &model.AccessToken{}, &model.PlatformApp{}) + db := testutil.NewTestDBWithModels(t, &model.User{}, &model.Account{}, &model.AccountUser{}, &model.Permissible{}, &model.AccessToken{}, &model.PlatformApp{}) userRepo := repository.NewUserRepo(db) permissibleRepo := repository.NewPermissibleRepo(db) - svc := service.NewPlatformUserService(userRepo, permissibleRepo) + accessTokenRepo := repository.NewAccessTokenRepo(db) + accountUserRepo := repository.NewAccountUserRepo(db) + svc := service.NewPlatformUserService(userRepo, permissibleRepo, accessTokenRepo, accountUserRepo) return svc, permissibleRepo } @@ -37,18 +39,19 @@ func TestPlatformUserService_CreateUser(t *testing.T) { // Since we don't have PlatformAppRepo in the test setup, we test the core logic: // CreateUser should create user + auto-permissible record - user, err := svc.CreateUser(ctx, 1, "Test User", "test@example.com", "password123") + user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Test User", Email: "test@example.com", Password: "password123"}) require.NoError(t, err) - assert.Equal(t, "Test User", user.Name) - assert.Equal(t, "test@example.com", user.Email) - assert.NotZero(t, user.ID) + assert.Equal(t, "Test User", user.User.Name) + assert.Equal(t, "test@example.com", user.User.Email) + assert.NotZero(t, user.User.ID) + assert.NotEmpty(t, user.AccessToken) // Verify auto-permissible was created - perm, err := permissibleRepo.FindByPlatformAppAndResource(ctx, 1, model.PermissibleTypeUser, user.ID) + perm, err := permissibleRepo.FindByPlatformAppAndResource(ctx, 1, model.PermissibleTypeUser, user.User.ID) require.NoError(t, err) require.NotNil(t, perm) assert.Equal(t, model.PermissibleTypeUser, perm.PermissibleType) - assert.Equal(t, user.ID, perm.PermissibleID) + assert.Equal(t, user.User.ID, perm.PermissibleID) assert.Equal(t, uint(1), perm.PlatformAppID) } @@ -57,15 +60,30 @@ func TestPlatformUserService_GetUser(t *testing.T) { ctx := context.Background() // Create user with auto-permissible - user, err := svc.CreateUser(ctx, 1, "Test User", "test@example.com", "password123") + user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Test User", Email: "test@example.com", Password: "password123"}) require.NoError(t, err) // GetUser should succeed because auto-permissible was created - retrieved, err := svc.GetUser(ctx, 1, user.ID) + retrieved, err := svc.GetUser(ctx, 1, user.User.ID) require.NoError(t, err) - assert.Equal(t, user.ID, retrieved.ID) - assert.Equal(t, user.Name, retrieved.Name) - assert.Equal(t, user.Email, retrieved.Email) + assert.Equal(t, user.User.ID, retrieved.ID) + assert.Equal(t, user.User.Name, retrieved.Name) + assert.Equal(t, user.User.Email, retrieved.Email) +} + +func TestPlatformUserService_CreateExistingUserReturnsExisting(t *testing.T) { + svc, permissibleRepo := setupPlatformUserServiceTest(t) + ctx := context.Background() + + first, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Original", Email: "same@example.com", Password: "password123"}) + require.NoError(t, err) + second, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Changed", Email: "same@example.com", Password: "password456"}) + require.NoError(t, err) + + assert.Equal(t, first.User.ID, second.User.ID) + assert.Equal(t, "Original", second.User.Name) + _, err = permissibleRepo.FindByPlatformAppAndResource(ctx, 1, model.PermissibleTypeUser, second.User.ID) + assert.NoError(t, err) } func TestPlatformUserService_GetUser_NonPermissible(t *testing.T) { @@ -73,11 +91,11 @@ func TestPlatformUserService_GetUser_NonPermissible(t *testing.T) { ctx := context.Background() // Create user under PlatformApp 1 - user, err := svc.CreateUser(ctx, 1, "Test User", "test1@example.com", "password123") + user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Test User", Email: "test1@example.com", Password: "password123"}) require.NoError(t, err) // Try to get user from a different PlatformApp (should fail — no permissible) - _, err = svc.GetUser(ctx, 2, user.ID) + _, err = svc.GetUser(ctx, 2, user.User.ID) assert.Error(t, err) assert.Equal(t, "non permissible resource", err.Error()) } @@ -86,27 +104,29 @@ func TestPlatformUserService_UpdateUser(t *testing.T) { svc, _ := setupPlatformUserServiceTest(t) ctx := context.Background() - user, err := svc.CreateUser(ctx, 1, "Original Name", "original@example.com", "password123") + user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Original Name", Email: "original@example.com", Password: "password123", CustomAttributes: map[string]any{"plan": "old"}}) require.NoError(t, err) - updated, err := svc.UpdateUser(ctx, 1, user.ID, "Updated Name", "updated@example.com") + updated, err := svc.UpdateUser(ctx, 1, user.User.ID, service.PlatformUserRequest{Name: "Updated Name", Email: "updated@example.com", CustomAttributes: map[string]any{"tier": "gold"}}) require.NoError(t, err) - assert.Equal(t, "Updated Name", updated.Name) - assert.Equal(t, "updated@example.com", updated.Email) + assert.Equal(t, "Updated Name", updated.User.Name) + assert.Equal(t, "updated@example.com", updated.User.Email) + assert.Contains(t, string(updated.User.CustomAttributes), "gold") + assert.Contains(t, string(updated.User.CustomAttributes), "old") } func TestPlatformUserService_DeleteUser(t *testing.T) { svc, _ := setupPlatformUserServiceTest(t) ctx := context.Background() - user, err := svc.CreateUser(ctx, 1, "Delete Me", "delete@example.com", "password123") + user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Delete Me", Email: "delete@example.com", Password: "password123"}) require.NoError(t, err) - err = svc.DeleteUser(ctx, 1, user.ID) + err = svc.DeleteUser(ctx, 1, user.User.ID) require.NoError(t, err) // Verify user is gone - _, err = svc.GetUser(ctx, 1, user.ID) + _, err = svc.GetUser(ctx, 1, user.User.ID) assert.Error(t, err) } @@ -115,13 +135,13 @@ func TestPlatformUserService_ListPermissibleUsers(t *testing.T) { ctx := context.Background() // Create multiple users under PlatformApp 1 - user1, err := svc.CreateUser(ctx, 1, "User One", "user1@example.com", "pass1") + user1, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "User One", Email: "user1@example.com", Password: "pass1"}) require.NoError(t, err) - user2, err := svc.CreateUser(ctx, 1, "User Two", "user2@example.com", "pass2") + user2, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "User Two", Email: "user2@example.com", Password: "pass2"}) require.NoError(t, err) // Create a user under PlatformApp 2 (should not appear in PlatformApp 1's list) - _, err = svc.CreateUser(ctx, 2, "User Three", "user3@example.com", "pass3") + _, err = svc.CreateUser(ctx, 2, service.PlatformUserRequest{Name: "User Three", Email: "user3@example.com", Password: "pass3"}) require.NoError(t, err) users, err := svc.ListPermissibleUsers(ctx, 1) @@ -133,6 +153,6 @@ func TestPlatformUserService_ListPermissibleUsers(t *testing.T) { for _, u := range users { userIDs[u.ID] = true } - assert.True(t, userIDs[user1.ID]) - assert.True(t, userIDs[user2.ID]) -} \ No newline at end of file + assert.True(t, userIDs[user1.User.ID]) + assert.True(t, userIDs[user2.User.ID]) +}