Files
gochat/internal/handler/api/v1/profile_handler_test.go
T

1023 lines
35 KiB
Go

package v1
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
"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
mailer *fakeProfileConfirmationMailer
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{},
&model.InstallationConfig{},
))
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)
installationConfigRepo := repository.NewInstallationConfigRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo)
s.mailer = &fakeProfileConfirmationMailer{}
profileSvc.SetConfirmationMailer(s.mailer)
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",
"confirmation_token": "",
"unconfirmed_email": "",
})
s.db.Model(&model.User{}).Where("id = ?", s.userID).UpdateColumns(map[string]interface{}{
"confirmed_at": nil,
"confirmation_sent_at": nil,
"reset_password_token": "",
"reset_password_sent_at": nil,
})
s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).UpdateColumns(map[string]interface{}{
"role": "administrator",
"custom_role_id": 0,
"availability": "offline",
"auto_offline": true,
"inviter_id": 0,
})
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.db.Unscoped().Where("name = ?", "CHATWOOT_INBOX_HMAC_KEY").Delete(&model.InstallationConfig{})
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)
s.mailer.Reset()
}
type fakeProfileConfirmationMailer struct {
calls []service.ProfileConfirmationMailRequest
}
func (m *fakeProfileConfirmationMailer) SendConfirmationInstructions(_ context.Context, req service.ProfileConfirmationMailRequest) error {
m.calls = append(m.calls, req)
return nil
}
func (m *fakeProfileConfirmationMailer) Reset() {
m.calls = nil
}
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.NotContains(s.T(), dataMap, "hmac_identifier")
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_HMACIdentifierWhenConfigured() {
secret := "random_secret_key"
s.Require().NoError(s.db.Create(&model.InstallationConfig{Name: "CHATWOOT_INBOX_HMAC_KEY", Value: secret}).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)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte("profile@example.com"))
expected := hex.EncodeToString(mac.Sum(nil))
assert.Equal(s.T(), expected, payload["hmac_identifier"])
}
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)
}
func (s *ProfileHandlerTestSuite) TestResendConfirmation_DoesNotSendForConfirmedUser() {
now := time.Now().UTC()
s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Update("confirmed_at", now).Error)
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
assert.Empty(s.T(), s.mailer.calls)
}
func (s *ProfileHandlerTestSuite) TestResendConfirmation_SendsConfirmationInstructions() {
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
s.Require().Len(s.mailer.calls, 1)
mail := s.mailer.calls[0]
assert.Equal(s.T(), "confirmation", mail.Kind)
assert.Equal(s.T(), "profile@example.com", mail.ToEmail)
assert.Equal(s.T(), "Confirm your email to get started", mail.Heading)
assert.Equal(s.T(), "Confirm my account", mail.ActionText)
assert.Contains(s.T(), mail.ActionURL, "/app/auth/confirmation?confirmation_token=")
assert.NotEmpty(s.T(), mail.ConfirmationToken)
assert.Empty(s.T(), mail.ResetPasswordToken)
var user model.User
s.Require().NoError(s.db.First(&user, s.userID).Error)
assert.Equal(s.T(), mail.ConfirmationToken, user.ConfirmationToken)
assert.NotNil(s.T(), user.ConfirmationSentAt)
}
func (s *ProfileHandlerTestSuite) TestResendConfirmation_SendsWorkspaceInvitationForInvitedUser() {
inviter := &model.User{Name: "Inviter Admin", Email: "inviter-profile@example.com", Provider: "email", Active: true}
s.Require().NoError(s.db.Create(inviter).Error)
s.Require().NoError(s.db.Model(&model.AccountUser{}).
Where("account_id = ? AND user_id = ?", s.accountID, s.userID).
Update("inviter_id", inviter.ID).Error)
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
s.Require().Len(s.mailer.calls, 1)
mail := s.mailer.calls[0]
assert.Equal(s.T(), "invitation", mail.Kind)
assert.Equal(s.T(), "Workspace invitation", mail.Eyebrow)
assert.Equal(s.T(), "You're invited to join TestAccount", mail.Heading)
assert.Equal(s.T(), "Inviter Admin invited you to join the TestAccount workspace on Chatwoot.", mail.IntroText)
assert.Equal(s.T(), "Accept invitation", mail.ActionText)
assert.Contains(s.T(), mail.ActionURL, "/app/auth/password/edit?reset_password_token=")
assert.Empty(s.T(), mail.ConfirmationToken)
assert.NotEmpty(s.T(), mail.ResetPasswordToken)
var user model.User
s.Require().NoError(s.db.First(&user, s.userID).Error)
assert.NotEmpty(s.T(), user.ResetPasswordToken)
assert.NotEqual(s.T(), mail.ResetPasswordToken, user.ResetPasswordToken)
assert.NotNil(s.T(), user.ResetPasswordSentAt)
}
// ===================== 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)
}