feat(profile): align chatwoot user serializer

This commit is contained in:
2026-06-05 01:07:40 +08:00
parent 6757e17c82
commit 56a8c5b806
9 changed files with 483 additions and 105 deletions
+20 -15
View File
@@ -30,14 +30,15 @@ func (h *ProfileHandler) Get(c *gin.Context) {
return
}
user, err := h.svc.Get(c.Request.Context(), userID)
accountID := c.GetUint("account_id")
user, err := h.svc.Get(c.Request.Context(), userID, accountID)
if err != nil {
applogger.L().Errorf("Get profile for user %d: %v", userID, err)
handleServiceError(c, err)
return
}
response.OK(c, user)
c.JSON(http.StatusOK, user)
}
// Update updates the current user's profile.
@@ -55,14 +56,15 @@ func (h *ProfileHandler) Update(c *gin.Context) {
return
}
user, svcErr := h.svc.Update(c.Request.Context(), userID, req.Profile)
accountID := c.GetUint("account_id")
user, svcErr := h.svc.Update(c.Request.Context(), userID, accountID, req.Profile)
if svcErr != nil {
applogger.L().Errorf("Update profile for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, user)
c.JSON(http.StatusOK, user)
}
// UpdateAvatar updates the current user's avatar.
@@ -80,14 +82,15 @@ func (h *ProfileHandler) UpdateAvatar(c *gin.Context) {
return
}
user, svcErr := h.svc.UpdateAvatar(c.Request.Context(), userID, req)
accountID := c.GetUint("account_id")
user, svcErr := h.svc.UpdateAvatar(c.Request.Context(), userID, accountID, req)
if svcErr != nil {
applogger.L().Errorf("Update avatar for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, user)
c.JSON(http.StatusOK, user)
}
// SetAvailability updates the user's availability status for a specific account.
@@ -106,14 +109,14 @@ func (h *ProfileHandler) SetAvailability(c *gin.Context) {
return
}
au, svcErr := h.svc.SetAvailability(c.Request.Context(), userID, req.Profile)
user, svcErr := h.svc.SetAvailability(c.Request.Context(), userID, req.Profile)
if svcErr != nil {
applogger.L().Errorf("SetAvailability for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, au)
c.JSON(http.StatusOK, user)
}
// SetAutoOffline updates the user's auto_offline setting for a specific account.
@@ -132,14 +135,14 @@ func (h *ProfileHandler) SetAutoOffline(c *gin.Context) {
return
}
au, svcErr := h.svc.SetAutoOffline(c.Request.Context(), userID, req.Profile)
user, svcErr := h.svc.SetAutoOffline(c.Request.Context(), userID, req.Profile)
if svcErr != nil {
applogger.L().Errorf("SetAutoOffline for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, au)
c.JSON(http.StatusOK, user)
}
// SetActiveAccount sets the user's currently active account.
@@ -198,14 +201,15 @@ func (h *ProfileHandler) ResetAccessToken(c *gin.Context) {
return
}
user, svcErr := h.svc.ResetAccessToken(c.Request.Context(), userID)
accountID := c.GetUint("account_id")
user, svcErr := h.svc.ResetAccessToken(c.Request.Context(), userID, accountID)
if svcErr != nil {
applogger.L().Errorf("ResetAccessToken for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, user)
c.JSON(http.StatusOK, user)
}
// DeleteAvatar removes the user's avatar.
@@ -217,11 +221,12 @@ func (h *ProfileHandler) DeleteAvatar(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
return
}
user, err := h.svc.DeleteAvatar(c.Request.Context(), userID)
accountID := c.GetUint("account_id")
user, err := h.svc.DeleteAvatar(c.Request.Context(), userID, accountID)
if err != nil {
applogger.L().Errorf("DeleteAvatar for user %d: %v", userID, err)
handleServiceError(c, err)
return
}
response.OK(c, user)
}
c.JSON(http.StatusOK, user)
}
+124 -40
View File
@@ -54,29 +54,40 @@ func (s *ProfileHandlerTestSuite) SetupSuite() {
&model.Account{},
&model.User{},
&model.AccountUser{},
&model.AccessToken{},
))
s.db = db
// Create test account
account := &model.Account{Name: "TestAccount"}
account := &model.Account{Name: "TestAccount", Status: "active", OnboardingStep: "profile"}
s.Require().NoError(db.Create(account).Error)
s.account = account
s.accountID = account.ID
// Create test user
user := &model.User{Name: "ProfileUser", Email: "profile@example.com", AccountID: account.ID}
user := &model.User{
Name: "ProfileUser",
Email: "profile@example.com",
AccountID: account.ID,
Provider: "email",
DisplayName: "Profile Display",
MessageSignature: "Regards",
PubsubToken: "pubsub-profile-user",
}
s.Require().NoError(db.Create(user).Error)
s.user = user
s.userID = user.ID
// Link user to account
accountUser := &model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}
accountUser := &model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator", Availability: "offline", AutoOffline: true}
s.Require().NoError(db.Create(accountUser).Error)
s.Require().NoError(db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: user.ID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error)
// Create real repos + service
userRepo := repository.NewUserRepo(db)
accountUserRepo := repository.NewAccountUserRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo)
accessTokenRepo := repository.NewAccessTokenRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo)
s.handler = NewProfileHandler(profileSvc)
// Build router with profile routes and auth middleware
@@ -110,11 +121,35 @@ func (s *ProfileHandlerTestSuite) buildRouter() *gin.Engine {
func (s *ProfileHandlerTestSuite) SetupTest() {
// Reset user to original state before each test
s.db.Model(&model.User{}).Where("id = ?", s.userID).Updates(map[string]interface{}{
"name": "ProfileUser",
"email": "profile@example.com",
"avatar_url": "",
"available": false,
"name": "ProfileUser",
"email": "profile@example.com",
"avatar_url": "",
"available": false,
"display_name": "Profile Display",
"message_signature": "Regards",
"pubsub_token": "pubsub-profile-user",
})
s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).Updates(map[string]interface{}{
"availability": "offline",
"auto_offline": true,
})
s.db.Unscoped().Where("owner_type = ? AND owner_id = ?", model.AccessTokenOwnerTypeUser, s.userID).Delete(&model.AccessToken{})
s.Require().NoError(s.db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: s.userID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error)
}
func (s *ProfileHandlerTestSuite) decodeProfileBody(w *httptest.ResponseRecorder) map[string]interface{} {
var payload map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
return payload
}
func (s *ProfileHandlerTestSuite) firstAccountFromProfile(payload map[string]interface{}) map[string]interface{} {
accounts, ok := payload["accounts"].([]interface{})
s.Require().True(ok)
s.Require().Len(accounts, 1)
account, ok := accounts[0].(map[string]interface{})
s.Require().True(ok)
return account
}
// ===================== Get Profile =====================
@@ -126,15 +161,23 @@ func (s *ProfileHandlerTestSuite) TestGet_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
// Data should contain user info
dataMap, ok := resp.Data.(map[string]interface{})
assert.True(s.T(), ok)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "ProfileUser", dataMap["name"])
assert.Equal(s.T(), "profile@example.com", dataMap["email"])
assert.Equal(s.T(), "profile-token-1", dataMap["access_token"])
assert.Equal(s.T(), "Profile Display", dataMap["available_name"])
assert.Equal(s.T(), "Regards", dataMap["message_signature"])
assert.Equal(s.T(), "pubsub-profile-user", dataMap["pubsub_token"])
assert.Equal(s.T(), "administrator", dataMap["role"])
accounts, ok := dataMap["accounts"].([]interface{})
assert.True(s.T(), ok)
if assert.Len(s.T(), accounts, 1) {
account := accounts[0].(map[string]interface{})
assert.Equal(s.T(), "TestAccount", account["name"])
assert.Equal(s.T(), "offline", account["availability"])
assert.Equal(s.T(), "offline", account["availability_status"])
assert.Equal(s.T(), true, account["auto_offline"])
}
}
func (s *ProfileHandlerTestSuite) TestGet_Unauthorized() {
@@ -217,12 +260,7 @@ func (s *ProfileHandlerTestSuite) TestUpdate_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap, ok := resp.Data.(map[string]interface{})
assert.True(s.T(), ok)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "UpdatedName", dataMap["name"])
}
@@ -241,9 +279,8 @@ func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithEmail() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "newemail@example.com", dataMap["email"])
}
func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithAvailabilityOffline() {
@@ -440,12 +477,7 @@ func (s *ProfileHandlerTestSuite) TestUpdateAvatar_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap, ok := resp.Data.(map[string]interface{})
assert.True(s.T(), ok)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "https://cdn.example.com/new-avatar.png", dataMap["avatar_url"])
}
@@ -570,6 +602,64 @@ func (s *ProfileHandlerTestSuite) TestUpdateAvatar_NilService() {
// Nil service causes panic — validates catastrophic failure path
}
// ===================== Chatwoot Profile Serializer Fixtures =====================
func (s *ProfileHandlerTestSuite) TestSetAvailability_ReturnsChatwootUserSerializer() {
body := map[string]interface{}{
"profile": map[string]interface{}{
"account_id": s.accountID,
"availability": "online",
},
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/api/v1/profile/availability", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
payload := s.decodeProfileBody(w)
assert.Equal(s.T(), "ProfileUser", payload["name"])
assert.Equal(s.T(), "administrator", payload["role"])
account := s.firstAccountFromProfile(payload)
assert.Equal(s.T(), "online", account["availability"])
assert.Equal(s.T(), "online", account["availability_status"])
}
func (s *ProfileHandlerTestSuite) TestSetAutoOffline_ReturnsChatwootUserSerializer() {
body := map[string]interface{}{
"profile": map[string]interface{}{
"account_id": s.accountID,
"auto_offline": false,
},
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/api/v1/profile/auto_offline", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
payload := s.decodeProfileBody(w)
account := s.firstAccountFromProfile(payload)
assert.Equal(s.T(), false, account["auto_offline"])
}
func (s *ProfileHandlerTestSuite) TestResetAccessToken_RegeneratesTokenInChatwootUserSerializer() {
req, _ := http.NewRequest("POST", "/api/v1/profile/reset_access_token", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
payload := s.decodeProfileBody(w)
token, ok := payload["access_token"].(string)
assert.True(s.T(), ok)
assert.NotEmpty(s.T(), token)
assert.NotEqual(s.T(), "profile-token-1", token)
}
// ===================== Edge Cases =====================
func (s *ProfileHandlerTestSuite) TestNewProfileHandler() {
@@ -591,9 +681,8 @@ func (s *ProfileHandlerTestSuite) TestGet_ViaHeaderUserID() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "ProfileUser", dataMap["name"])
}
func (s *ProfileHandlerTestSuite) TestGet_ZeroUserIDViaHeader() {
@@ -644,12 +733,7 @@ func (s *ProfileHandlerTestSuite) TestUpdate_MultipleFieldsAtOnce() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap, ok := resp.Data.(map[string]interface{})
assert.True(s.T(), ok)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "MultiUpdate", dataMap["name"])
assert.Equal(s.T(), "multi@example.com", dataMap["email"])
assert.Equal(s.T(), "https://example.com/multi.png", dataMap["avatar_url"])
@@ -682,4 +766,4 @@ func (s *ProfileHandlerTestSuite) TestUpdate_SoftDeletedUser() {
// strconvFormatUint helper for X-User-ID header tests
func strconvFormatUint(n uint) string {
return fmt.Sprintf("%d", n)
}
}