931 lines
31 KiB
Go
931 lines
31 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"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{},
|
|
&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.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.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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// ===================== 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)
|
|
}
|