package v1 import ( "bytes" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/crypto" "github.com/gochat/gochat/pkg/response" ) // --- Profile Handler Test Suite --- // Uses real SQLite DB + real repos + real service. type ProfileHandlerTestSuite struct { suite.Suite router *gin.Engine handler *ProfileHandler db *gorm.DB user *model.User account *model.Account userID uint accountID uint } func TestProfileHandlerSuite(t *testing.T) { suite.Run(t, new(ProfileHandlerTestSuite)) } func (s *ProfileHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) // Create in-memory SQLite DB db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) // Migrate models needed for profile operations s.Require().NoError(db.AutoMigrate( &model.Account{}, &model.User{}, &model.AccountUser{}, &model.CustomRole{}, &model.AccessToken{}, )) s.db = db // Create test account 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 passwordDigest, err := crypto.HashPassword("oldpassword") s.Require().NoError(err) user := &model.User{ Name: "ProfileUser", Email: "profile@example.com", AccountID: account.ID, Password: passwordDigest, PasswordDigest: passwordDigest, 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", 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) accessTokenRepo := repository.NewAccessTokenRepo(db) profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo) s.handler = NewProfileHandler(profileSvc) // Build router with profile routes and auth middleware s.router = s.buildRouter() } func (s *ProfileHandlerTestSuite) buildRouter() *gin.Engine { r := gin.New() // Auth middleware that sets user_id in context authMW := func(c *gin.Context) { c.Set("user_id", s.userID) c.Set("account_id", s.accountID) c.Next() } profile := r.Group("/api/v1/profile") profile.Use(authMW) profile.GET("", s.handler.Get) profile.PUT("", s.handler.Update) profile.PUT("/avatar", s.handler.UpdateAvatar) profile.POST("/availability", s.handler.SetAvailability) profile.POST("/auto_offline", s.handler.SetAutoOffline) profile.PUT("/set_active_account", s.handler.SetActiveAccount) profile.POST("/resend_confirmation", s.handler.ResendConfirmation) profile.POST("/reset_access_token", s.handler.ResetAccessToken) return r } func (s *ProfileHandlerTestSuite) SetupTest() { passwordDigest, err := crypto.HashPassword("oldpassword") s.Require().NoError(err) // 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", "password": passwordDigest, "password_digest": passwordDigest, "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{}{ "role": "administrator", "custom_role_id": 0, "availability": "offline", "auto_offline": true, }) s.db.Unscoped().Where("account_id = ?", s.accountID).Delete(&model.CustomRole{}) 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 ===================== func (s *ProfileHandlerTestSuite) TestGet_Success() { req, _ := http.NewRequest("GET", "/api/v1/profile", nil) w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) 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"]) assert.Equal(s.T(), []interface{}{"administrator"}, account["permissions"]) } } func (s *ProfileHandlerTestSuite) TestGet_CustomRolePermissions() { role := &model.CustomRole{AccountID: s.accountID, Name: "Support Lead"} s.Require().NoError(role.SetPermissionKeys([]model.PermissionDimension{ model.DimensionConversationManage, model.DimensionContactManage, })) s.Require().NoError(s.db.Create(role).Error) s.Require().NoError(s.db.Model(&model.AccountUser{}). Where("account_id = ? AND user_id = ?", s.accountID, s.userID). Updates(map[string]interface{}{"role": "agent", "custom_role_id": role.ID}).Error) req, _ := http.NewRequest("GET", "/api/v1/profile", nil) 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(), "agent", account["role"]) assert.Equal(s.T(), []interface{}{"conversation_manage", "contact_manage", "custom_role"}, account["permissions"]) assert.Equal(s.T(), float64(role.ID), account["custom_role_id"]) customRole := account["custom_role"].(map[string]interface{}) assert.Equal(s.T(), "Support Lead", customRole["name"]) assert.Equal(s.T(), []interface{}{"conversation_manage", "contact_manage"}, customRole["permissions"]) } func (s *ProfileHandlerTestSuite) TestGet_Unauthorized() { // Create router without auth middleware — user_id will be 0 r := gin.New() r.GET("/api/v1/profile", s.handler.Get) req, _ := http.NewRequest("GET", "/api/v1/profile", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnauthorized, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp.Success) assert.NotNil(s.T(), resp.Error) assert.Equal(s.T(), response.ErrUnauthorized, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestGet_UserNotFound() { // Router that sets a non-existent user ID r := gin.New() r.GET("/api/v1/profile", func(c *gin.Context) { c.Set("user_id", uint(9999)) // non-existent c.Next() }, s.handler.Get) req, _ := http.NewRequest("GET", "/api/v1/profile", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp.Success) assert.NotNil(s.T(), resp.Error) assert.Equal(s.T(), response.ErrNotFound, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestGet_NilService() { // Handler with nil service should panic or return 500 h := &ProfileHandler{svc: nil} r := gin.New() r.GET("/api/v1/profile", func(c *gin.Context) { c.Set("user_id", uint(1)) c.Next() }, h.Get) req, _ := http.NewRequest("GET", "/api/v1/profile", nil) w := httptest.NewRecorder() // Expect panic from nil service call — recover and check func() { defer func() { _ = recover() }() r.ServeHTTP(w, req) }() // If no panic, it would be 500; if panic, we caught it // This test validates that nil svc is catastrophic } // ===================== Update Profile ===================== func (s *ProfileHandlerTestSuite) TestUpdate_Success() { body := map[string]interface{}{ "profile": map[string]interface{}{ "name": "UpdatedName", "availability": "online", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", 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) dataMap := s.decodeProfileBody(w) assert.Equal(s.T(), "UpdatedName", dataMap["name"]) } func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithEmail() { body := map[string]interface{}{ "profile": map[string]interface{}{ "email": "newemail@example.com", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", 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) dataMap := s.decodeProfileBody(w) assert.Equal(s.T(), "newemail@example.com", dataMap["email"]) } func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithAvailabilityOffline() { // First set user available=true, then set availability=offline s.db.Model(&model.User{}).Where("id = ?", s.userID).Update("available", true) body := map[string]interface{}{ "profile": map[string]interface{}{ "availability": "offline", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", 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) } func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithAvailabilityBusy() { body := map[string]interface{}{ "profile": map[string]interface{}{ "availability": "busy", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", 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) } func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithAvatarURL() { body := map[string]interface{}{ "profile": map[string]interface{}{ "avatar_url": "https://example.com/avatar.png", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", 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) } func (s *ProfileHandlerTestSuite) TestUpdate_SuccessEmptyBody() { // Empty body binds as zero values — all fields are omitempty so validation passes req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader([]byte("{}"))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) } func (s *ProfileHandlerTestSuite) TestUpdate_Unauthorized() { r := gin.New() r.PUT("/api/v1/profile", s.handler.Update) body, _ := json.Marshal(map[string]interface{}{"name": "Test"}) req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnauthorized, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), response.ErrUnauthorized, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdate_InvalidJSON() { req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader([]byte("{invalid json}"))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp.Success) assert.Equal(s.T(), response.ErrBadRequest, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdate_ValidationFail_InvalidAvailability() { // Availability must be one of: online, offline, busy body := map[string]interface{}{ "profile": map[string]interface{}{ "availability": "invalid_status", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp.Success) assert.Equal(s.T(), response.ErrValidation, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdate_ValidationFail_InvalidEmail() { body := map[string]interface{}{ "profile": map[string]interface{}{ "email": "not-an-email", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp.Success) assert.Equal(s.T(), response.ErrValidation, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdate_ValidationFail_NameTooShort() { // Name with min=2, sending "a" (1 char) should fail validation body := map[string]interface{}{ "profile": map[string]interface{}{ "name": "a", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp.Success) assert.Equal(s.T(), response.ErrValidation, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdate_UserNotFound() { r := gin.New() r.PUT("/api/v1/profile", func(c *gin.Context) { c.Set("user_id", uint(9999)) // non-existent c.Next() }, s.handler.Update) body := map[string]interface{}{"profile": map[string]interface{}{"name": "TestName"}} b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), response.ErrNotFound, resp.Error.Code) } // ===================== Update Avatar ===================== func (s *ProfileHandlerTestSuite) TestUpdateAvatar_Success() { body := map[string]interface{}{ "avatar_url": "https://cdn.example.com/new-avatar.png", } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", 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) dataMap := s.decodeProfileBody(w) assert.Equal(s.T(), "https://cdn.example.com/new-avatar.png", dataMap["avatar_url"]) } func (s *ProfileHandlerTestSuite) TestUpdateAvatar_Unauthorized() { r := gin.New() r.PUT("/api/v1/profile/avatar", s.handler.UpdateAvatar) body := map[string]interface{}{"avatar_url": "https://example.com/a.png"} b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnauthorized, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), response.ErrUnauthorized, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdateAvatar_InvalidJSON() { req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader([]byte("not json"))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), response.ErrBadRequest, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdateAvatar_MissingAvatarURL() { // UpdateAvatarRequest has validate:"required" on avatar_url // NOTE: ShouldBindJSON does NOT trigger validate tags. // Empty body binds as zero-value struct (AvatarURL=""), then service validation fails. body := map[string]interface{}{} b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.router.ServeHTTP(w, req) // The service validates required and returns validation error, // which handleServiceError maps to 400 validation error assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp.Success) assert.Equal(s.T(), response.ErrValidation, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdateAvatar_EmptyAvatarURL() { // Explicitly sending avatar_url as empty string body := map[string]interface{}{ "avatar_url": "", } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.router.ServeHTTP(w, req) // Service validation: required field fails for empty string assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp.Success) assert.Equal(s.T(), response.ErrValidation, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdateAvatar_UserNotFound() { r := gin.New() r.PUT("/api/v1/profile/avatar", func(c *gin.Context) { c.Set("user_id", uint(9999)) // non-existent c.Next() }, s.handler.UpdateAvatar) body := map[string]interface{}{"avatar_url": "https://example.com/a.png"} b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) var resp response.APIResponse assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), response.ErrNotFound, resp.Error.Code) } func (s *ProfileHandlerTestSuite) TestUpdateAvatar_NilService() { h := &ProfileHandler{svc: nil} r := gin.New() r.PUT("/api/v1/profile/avatar", func(c *gin.Context) { c.Set("user_id", uint(1)) c.Next() }, h.UpdateAvatar) body := map[string]interface{}{"avatar_url": "https://example.com/a.png"} b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() func() { defer func() { _ = recover() }() r.ServeHTTP(w, req) }() // 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() { svc := service.NewProfileService(repository.NewUserRepo(s.db), repository.NewAccountUserRepo(s.db)) h := NewProfileHandler(svc) assert.NotNil(s.T(), h) assert.NotNil(s.T(), h.svc) } func (s *ProfileHandlerTestSuite) TestGet_ViaHeaderUserID() { // getUserID also checks X-User-ID header as fallback r := gin.New() r.GET("/api/v1/profile", s.handler.Get) req, _ := http.NewRequest("GET", "/api/v1/profile", nil) req.Header.Set("X-User-ID", strconvFormatUint(s.userID)) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) dataMap := s.decodeProfileBody(w) assert.Equal(s.T(), "ProfileUser", dataMap["name"]) } func (s *ProfileHandlerTestSuite) TestGet_ZeroUserIDViaHeader() { // X-User-ID set to 0 should still result in unauthorized r := gin.New() r.GET("/api/v1/profile", s.handler.Get) req, _ := http.NewRequest("GET", "/api/v1/profile", nil) req.Header.Set("X-User-ID", "0") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnauthorized, w.Code) } func (s *ProfileHandlerTestSuite) TestUpdate_NoContentTypeHeader() { // Sending JSON body without Content-Type — ShouldBindJSON may still parse it // in Gin test mode depending on version. Verify the behavior. body, _ := json.Marshal(map[string]interface{}{"profile": map[string]interface{}{"name": "TestName"}}) req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(body)) // No Content-Type header set w := httptest.NewRecorder() s.router.ServeHTTP(w, req) // Gin ShouldBindJSON requires Content-Type application/json — without it, // binding fails and the handler returns 400. But some Gin versions auto-detect. // Accept either 400 (binding fail) or 200 (if Gin auto-binds): code := w.Code assert.True(s.T(), code == http.StatusBadRequest || code == http.StatusOK, "expected either 400 (binding fail) or 200 (auto-bind), got %d", code) } func (s *ProfileHandlerTestSuite) TestUpdate_MultipleFieldsAtOnce() { body := map[string]interface{}{ "profile": map[string]interface{}{ "name": "MultiUpdate", "email": "multi@example.com", "avatar_url": "https://example.com/multi.png", "availability": "online", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", 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) 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"]) } func (s *ProfileHandlerTestSuite) TestUpdate_SettingsJSONParity() { body := map[string]interface{}{ "profile": map[string]interface{}{ "display_name": "Display Changed", "message_signature": "Kind regards", "phone_number": "+15551234567", "ui_settings": map[string]interface{}{ "editor_message_key": "cmd_enter", "conversation_display_type": "expanded", }, }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", 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(), "Display Changed", payload["display_name"]) assert.Equal(s.T(), "Display Changed", payload["available_name"]) assert.Equal(s.T(), "Kind regards", payload["message_signature"]) uiSettings := payload["ui_settings"].(map[string]interface{}) assert.Equal(s.T(), "cmd_enter", uiSettings["editor_message_key"]) customAttrs := payload["custom_attributes"].(map[string]interface{}) assert.Equal(s.T(), "+15551234567", customAttrs["phone_number"]) } func (s *ProfileHandlerTestSuite) TestUpdate_PasswordJSONParity() { body := map[string]interface{}{ "profile": map[string]interface{}{ "current_password": "oldpassword", "password": "newpassword", "password_confirmation": "newpassword", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", 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) var user model.User s.Require().NoError(s.db.First(&user, s.userID).Error) assert.True(s.T(), crypto.CheckPassword("newpassword", user.PasswordDigest)) } func (s *ProfileHandlerTestSuite) TestUpdate_PasswordInvalidCurrentPassword() { body := map[string]interface{}{ "profile": map[string]interface{}{ "current_password": "wrongpassword", "password": "newpassword", "password_confirmation": "newpassword", }, } b, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ProfileHandlerTestSuite) TestUpdate_MultipartFormProfileParity() { var body bytes.Buffer writer := multipart.NewWriter(&body) s.Require().NoError(writer.WriteField("profile[name]", "Multipart User")) s.Require().NoError(writer.WriteField("profile[display_name]", "Multipart Display")) s.Require().NoError(writer.WriteField("profile[message_signature]", "Sent from multipart")) s.Require().NoError(writer.WriteField("profile[ui_settings][editor_message_key]", "enter")) fileWriter, err := writer.CreateFormFile("profile[avatar]", "avatar.png") s.Require().NoError(err) _, err = fileWriter.Write([]byte("fake image bytes")) s.Require().NoError(err) s.Require().NoError(writer.Close()) req, _ := http.NewRequest("PUT", "/api/v1/profile", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) payload := s.decodeProfileBody(w) assert.Equal(s.T(), "Multipart User", payload["name"]) assert.Equal(s.T(), "Multipart Display", payload["display_name"]) assert.Equal(s.T(), "Sent from multipart", payload["message_signature"]) assert.Equal(s.T(), "avatar.png", payload["avatar_url"]) uiSettings := payload["ui_settings"].(map[string]interface{}) assert.Equal(s.T(), "enter", uiSettings["editor_message_key"]) } func (s *ProfileHandlerTestSuite) TestUpdate_SoftDeletedUser() { // Soft-delete user, then try to get/update profile s.db.Delete(&model.User{}, s.userID) // Get should return not found req, _ := http.NewRequest("GET", "/api/v1/profile", nil) w := httptest.NewRecorder() s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) // Update should return not found body, _ := json.Marshal(map[string]interface{}{"name": "AfterDelete"}) req2, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(body)) req2.Header.Set("Content-Type", "application/json") w2 := httptest.NewRecorder() s.router.ServeHTTP(w2, req2) assert.Equal(s.T(), http.StatusNotFound, w.Code) // Restore user for subsequent tests s.db.Model(&model.User{}).Where("id = ?", s.userID).Unscoped().Update("deleted_at", nil) } // strconvFormatUint helper for X-User-ID header tests func strconvFormatUint(n uint) string { return fmt.Sprintf("%d", n) }