feat(platform): align user payloads

This commit is contained in:
2026-06-06 11:25:48 +08:00
parent b8db9d41f5
commit 9c2a396d69
8 changed files with 390 additions and 123 deletions
+66 -23
View File
@@ -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
@@ -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)
}
}