package v1 import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "regexp" "testing" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) type AgentHandlerTestSuite struct { suite.Suite db *gorm.DB handler *AgentHandler mailer *fakeProfileConfirmationMailer account *model.Account user *model.User } func (s *AgentHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.InstallationConfig{})) s.db = db agentRepo := repository.NewAgentRepo(db) svc := service.NewAgentService(agentRepo, db) s.mailer = &fakeProfileConfirmationMailer{} svc.SetConfirmationMailer(s.mailer) s.handler = NewAgentHandler(svc) s.account = &model.Account{Name: "test-agent-account"} s.Require().NoError(db.Create(s.account).Error) s.user = &model.User{ Name: "Inviter Admin", Email: "inviter@test.com", Provider: "email", Active: true, } s.Require().NoError(db.Create(s.user).Error) } func (s *AgentHandlerTestSuite) SetupTest() { s.db.Exec("DELETE FROM account_users") // Don't delete users — we need the inviter user to persist // Only delete agent users (not the inviter) s.db.Exec("DELETE FROM users WHERE id != ?", s.user.ID) s.mailer.Reset() } func (s *AgentHandlerTestSuite) TearDownSuite() { if s.db != nil { sqlDB, _ := s.db.DB() sqlDB.Close() } } func TestAgentHandlerSuite(t *testing.T) { suite.Run(t, new(AgentHandlerTestSuite)) } func (s *AgentHandlerTestSuite) makeRequest(method, path string, body interface{}, accountID uint, userID uint) (*httptest.ResponseRecorder, *gin.Context) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) var bodyBytes []byte if body != nil { bodyBytes, _ = json.Marshal(body) } c.Request = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) if body != nil { c.Request.Header.Set("Content-Type", "application/json") } // Set account_id and id params (both refer to account ID in GoChat routes) params := gin.Params{ {Key: "account_id", Value: fmt.Sprintf("%d", accountID)}, {Key: "id", Value: fmt.Sprintf("%d", accountID)}, } // Extract agent_id from path like /api/v1/accounts/1/agents/123 // Match pattern: /agents/ or /agents//... re := regexp.MustCompile(`/agents/(\d+)`) matches := re.FindStringSubmatch(path) if len(matches) > 1 { params = append(params, gin.Param{Key: "agent_id", Value: matches[1]}) } c.Params = params c.Set("account_id", accountID) c.Set("user_id", userID) return w, c } func (s *AgentHandlerTestSuite) TestListEmpty() { w, c := s.makeRequest("GET", "/api/v1/accounts/1/agents", nil, s.account.ID, s.user.ID) s.handler.List(c) assert.Equal(s.T(), http.StatusOK, w.Code) var data []interface{} assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &data)) assert.Equal(s.T(), 0, len(data)) } func (s *AgentHandlerTestSuite) TestListOrdersByFullName() { agents := []service.CreateAgentRequest{ {Email: "charlie@test.com", Name: "charlie", Role: "agent"}, {Email: "alpha@test.com", Name: "Alpha", Role: "agent"}, {Email: "bravo@test.com", Name: "bravo", Role: "agent"}, } for _, req := range agents { w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) } w, c := s.makeRequest("GET", "/api/v1/accounts/1/agents", nil, s.account.ID, s.user.ID) s.handler.List(c) assert.Equal(s.T(), http.StatusOK, w.Code) var data []map[string]interface{} json.Unmarshal(w.Body.Bytes(), &data) s.Require().Len(data, 3) assert.Equal(s.T(), "Alpha", data[0]["name"]) assert.Equal(s.T(), "bravo", data[1]["name"]) assert.Equal(s.T(), "charlie", data[2]["name"]) } func (s *AgentHandlerTestSuite) TestListIgnoresPerPageLikeChatwoot() { for i := 0; i < 30; i++ { req := service.CreateAgentRequest{ Email: fmt.Sprintf("full-list-%02d@test.com", i), Name: fmt.Sprintf("Full List %02d", i), Role: "agent", } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) } w, c := s.makeRequest("GET", "/api/v1/accounts/1/agents?per_page=5", nil, s.account.ID, s.user.ID) s.handler.List(c) assert.Equal(s.T(), http.StatusOK, w.Code) var data []map[string]interface{} json.Unmarshal(w.Body.Bytes(), &data) s.Require().Len(data, 30) assert.Equal(s.T(), "Full List 00", data[0]["name"]) assert.Equal(s.T(), "Full List 29", data[29]["name"]) } func (s *AgentHandlerTestSuite) TestCreateAgent() { customRoleID := uint(7) req := service.CreateAgentRequest{ Email: "agent1@test.com", Name: "Agent One", Role: "agent", CustomRoleID: &customRoleID, } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) var data map[string]interface{} json.Unmarshal(w.Body.Bytes(), &data) assert.Equal(s.T(), "agent1@test.com", data["email"]) assert.Equal(s.T(), "Agent One", data["name"]) assert.Equal(s.T(), "offline", data["availability_status"]) assert.Equal(s.T(), float64(7), data["custom_role_id"]) assert.Contains(s.T(), data, "confirmed") assert.NotContains(s.T(), data, "invited_by") assert.NotContains(s.T(), data, "inviter_id") assert.NotContains(s.T(), data, "account_user_id") var membership model.AccountUser s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.account.ID, uint(data["id"].(float64))).First(&membership).Error) assert.Equal(s.T(), s.user.ID, membership.InvitedBy) } func (s *AgentHandlerTestSuite) TestCreateAgentSendsWorkspaceInvitation() { req := service.CreateAgentRequest{Email: "invite-mail@test.com", Name: "Invite Mail", Role: "agent"} w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) s.Require().Len(s.mailer.calls, 1) mail := s.mailer.calls[0] assert.Equal(s.T(), "invitation", mail.Kind) assert.Equal(s.T(), "invite-mail@test.com", mail.ToEmail) assert.Equal(s.T(), "You're invited to join test-agent-account", mail.Heading) assert.Equal(s.T(), "Inviter Admin invited you to join the test-agent-account 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.NotEmpty(s.T(), mail.ResetPasswordToken) var invited model.User s.Require().NoError(s.db.Where("email = ?", "invite-mail@test.com").First(&invited).Error) assert.NotEmpty(s.T(), invited.ResetPasswordToken) assert.NotEqual(s.T(), mail.ResetPasswordToken, invited.ResetPasswordToken) assert.NotNil(s.T(), invited.ResetPasswordSentAt) } func (s *AgentHandlerTestSuite) TestCreateAgentDefaultsBlankNameFromEmail() { req := map[string]interface{}{ "agent": map[string]interface{}{ "email": "fallback-name@test.com", "name": "", }, } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) var data map[string]interface{} json.Unmarshal(w.Body.Bytes(), &data) assert.Equal(s.T(), "fallback-name@test.com", data["email"]) assert.Equal(s.T(), "fallback-name", data["name"]) var user model.User s.Require().NoError(s.db.Where("email = ?", "fallback-name@test.com").First(&user).Error) assert.Equal(s.T(), "fallback-name", user.Name) } func (s *AgentHandlerTestSuite) TestCreateAgentDuplicate() { // Create first agent req := service.CreateAgentRequest{ Email: "agent2@test.com", Name: "Agent Two", Role: "agent", } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) // Try creating again — Chatwoot renders ActiveRecord::RecordInvalid as 422. w2, c2 := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c2) assert.Equal(s.T(), http.StatusUnprocessableEntity, w2.Code) var data map[string]interface{} json.Unmarshal(w2.Body.Bytes(), &data) assert.Equal(s.T(), "User has already been taken", data["message"]) assert.Equal(s.T(), []interface{}{"user_id"}, data["attributes"]) } func (s *AgentHandlerTestSuite) TestCreateAgentValidation() { req := service.CreateAgentRequest{ Email: "", // required Name: "", // required } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *AgentHandlerTestSuite) TestGetAgent() { // Create an agent first req := service.CreateAgentRequest{ Email: "agent3@test.com", Name: "Agent Three", Role: "agent", } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) var data map[string]interface{} json.Unmarshal(w.Body.Bytes(), &data) agentID := uint(data["id"].(float64)) // Get the agent w2, c2 := s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/1/agents/%d", agentID), nil, s.account.ID, s.user.ID) c2.Params = append(c2.Params, gin.Param{Key: "id", Value: fmt.Sprintf("%d", agentID)}) s.handler.Get(c2) assert.Equal(s.T(), http.StatusOK, w2.Code) var getData map[string]interface{} json.Unmarshal(w2.Body.Bytes(), &getData) assert.Equal(s.T(), "agent3@test.com", getData["email"]) } func (s *AgentHandlerTestSuite) TestGetAgentNotFound() { w, c := s.makeRequest("GET", "/api/v1/accounts/1/agents/9999", nil, s.account.ID, s.user.ID) s.handler.Get(c) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *AgentHandlerTestSuite) TestUpdateAgent() { // Create an agent req := service.CreateAgentRequest{ Email: "agent4@test.com", Name: "Agent Four", Role: "agent", AutoOffline: true, } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) var data map[string]interface{} json.Unmarshal(w.Body.Bytes(), &data) agentID := uint(data["id"].(float64)) // Update the agent updateReq := map[string]any{ "agent": map[string]any{ "name": "Updated Name", "role": "administrator", "availability": "online", }, } w2, c2 := s.makeRequest("PATCH", fmt.Sprintf("/api/v1/accounts/1/agents/%d", agentID), updateReq, s.account.ID, s.user.ID) c2.Params = append(c2.Params, gin.Param{Key: "id", Value: fmt.Sprintf("%d", agentID)}) s.handler.Update(c2) assert.Equal(s.T(), http.StatusOK, w2.Code) var updateData map[string]interface{} json.Unmarshal(w2.Body.Bytes(), &updateData) assert.Equal(s.T(), "Updated Name", updateData["name"]) assert.Equal(s.T(), "administrator", updateData["role"]) assert.Equal(s.T(), "online", updateData["availability_status"]) assert.Equal(s.T(), true, updateData["auto_offline"]) // Explicit false should still update, matching Rails compact semantics. disableReq := map[string]any{ "agent": map[string]any{ "auto_offline": false, }, } w3, c3 := s.makeRequest("PATCH", fmt.Sprintf("/api/v1/accounts/1/agents/%d", agentID), disableReq, s.account.ID, s.user.ID) s.handler.Update(c3) assert.Equal(s.T(), http.StatusOK, w3.Code) var disableData map[string]interface{} json.Unmarshal(w3.Body.Bytes(), &disableData) assert.Equal(s.T(), false, disableData["auto_offline"]) } func (s *AgentHandlerTestSuite) TestUpdateAgentBlankNameReturnsRecordInvalidShape() { req := service.CreateAgentRequest{ Email: "blank-update@test.com", Name: "Blank Update", Role: "agent", } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) var created map[string]interface{} json.Unmarshal(w.Body.Bytes(), &created) agentID := uint(created["id"].(float64)) updateReq := map[string]any{ "agent": map[string]any{ "name": "", }, } w2, c2 := s.makeRequest("PATCH", fmt.Sprintf("/api/v1/accounts/1/agents/%d", agentID), updateReq, s.account.ID, s.user.ID) s.handler.Update(c2) assert.Equal(s.T(), http.StatusUnprocessableEntity, w2.Code, w2.Body.String()) var data map[string]interface{} json.Unmarshal(w2.Body.Bytes(), &data) assert.Equal(s.T(), "Name can't be blank", data["message"]) assert.Equal(s.T(), []interface{}{"name"}, data["attributes"]) var user model.User s.Require().NoError(s.db.First(&user, agentID).Error) assert.Equal(s.T(), "Blank Update", user.Name) } func (s *AgentHandlerTestSuite) TestDeleteAgent() { // Create an agent req := service.CreateAgentRequest{ Email: "agent5@test.com", Name: "Agent Five", Role: "agent", } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) var data map[string]interface{} json.Unmarshal(w.Body.Bytes(), &data) agentID := uint(data["id"].(float64)) // Delete the agent w2, c2 := s.makeRequest("DELETE", fmt.Sprintf("/api/v1/accounts/1/agents/%d", agentID), nil, s.account.ID, s.user.ID) c2.Params = append(c2.Params, gin.Param{Key: "id", Value: fmt.Sprintf("%d", agentID)}) s.handler.Delete(c2) assert.Equal(s.T(), http.StatusOK, w2.Code) // Verify agent is gone w3, c3 := s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/1/agents/%d", agentID), nil, s.account.ID, s.user.ID) c3.Params = append(c3.Params, gin.Param{Key: "id", Value: fmt.Sprintf("%d", agentID)}) s.handler.Get(c3) assert.Equal(s.T(), http.StatusNotFound, w3.Code) } func (s *AgentHandlerTestSuite) TestDeleteAgentNotInAccountReturnsNotFound() { otherAccount := &model.Account{Name: "Other Account", Locale: "en", Active: true} s.Require().NoError(s.db.Create(otherAccount).Error) otherUser := &model.User{Name: "Other Agent", Email: "other-agent@test.com", Provider: "email", Active: true} s.Require().NoError(s.db.Create(otherUser).Error) s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: otherAccount.ID, UserID: otherUser.ID, Role: "agent", Availability: "offline", AutoOffline: true}).Error) w, c := s.makeRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/agents/%d", s.account.ID, otherUser.ID), nil, s.account.ID, s.user.ID) s.handler.Delete(c) assert.Equal(s.T(), http.StatusNotFound, w.Code, w.Body.String()) var userCount int64 s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", otherUser.ID).Count(&userCount).Error) assert.Equal(s.T(), int64(1), userCount) var membershipCount int64 s.Require().NoError(s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", otherAccount.ID, otherUser.ID).Count(&membershipCount).Error) assert.Equal(s.T(), int64(1), membershipCount) } func (s *AgentHandlerTestSuite) TestBulkCreate() { req := service.BulkCreateAgentRequest{ Emails: []string{"bulk1@test.com", "bulk2@test.com", "bulk3@test.com"}, } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents/bulk_create", req, s.account.ID, s.user.ID) s.handler.BulkCreate(c) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) } func (s *AgentHandlerTestSuite) TestBulkCreateValidation() { req := service.BulkCreateAgentRequest{ Emails: []string{}, // min=1 } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents/bulk_create", req, s.account.ID, s.user.ID) s.handler.BulkCreate(c) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) } func (s *AgentHandlerTestSuite) TestBulkCreateSkipsInvalidEmailsAndClearsOnboardingStep() { s.Require().NoError(s.db.Model(s.account).Update("onboarding_step", "invite_team").Error) req := service.BulkCreateAgentRequest{ Emails: []string{"valid-bulk@test.com", "invalid-email"}, } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents/bulk_create", req, s.account.ID, s.user.ID) s.handler.BulkCreate(c) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) var validUser model.User s.Require().NoError(s.db.Where("email = ?", "valid-bulk@test.com").First(&validUser).Error) var invalidCount int64 s.Require().NoError(s.db.Model(&model.User{}).Where("email = ?", "invalid-email").Count(&invalidCount).Error) assert.Equal(s.T(), int64(0), invalidCount) var account model.Account s.Require().NoError(s.db.First(&account, s.account.ID).Error) assert.Equal(s.T(), "", account.OnboardingStep) } func (s *AgentHandlerTestSuite) TestBulkCreateSkipsDuplicates() { // Pre-create one agent preReq := service.CreateAgentRequest{ Email: "existing@test.com", Name: "Existing", Role: "agent", } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", preReq, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) // Bulk create with a duplicate + a new one req := service.BulkCreateAgentRequest{ Emails: []string{"existing@test.com", "new@test.com"}, } w2, c2 := s.makeRequest("POST", "/api/v1/accounts/1/agents/bulk_create", req, s.account.ID, s.user.ID) s.handler.BulkCreate(c2) assert.Equal(s.T(), http.StatusOK, w2.Code) assert.Empty(s.T(), w2.Body.String()) } func (s *AgentHandlerTestSuite) TestListAfterCreate() { // Create two agents for i := 1; i <= 2; i++ { req := service.CreateAgentRequest{ Email: fmt.Sprintf("list%d@test.com", i), Name: fmt.Sprintf("List Agent %d", i), Role: "agent", } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) assert.Equal(s.T(), http.StatusOK, w.Code) } // List agents w, c := s.makeRequest("GET", "/api/v1/accounts/1/agents", nil, s.account.ID, s.user.ID) s.handler.List(c) assert.Equal(s.T(), http.StatusOK, w.Code) var data []interface{} json.Unmarshal(w.Body.Bytes(), &data) assert.Equal(s.T(), 2, len(data)) } func (s *AgentHandlerTestSuite) TestListNoAccountID() { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/api/v1/accounts/agents", nil) // No account_id set s.handler.List(c) assert.Equal(s.T(), http.StatusUnauthorized, w.Code) }