package v1 import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" ) // --- Mock LLM Provider for handler tests --- type mockConvHandlerLLMProvider struct{} func (m *mockConvHandlerLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { return &llm.ChatResponse{}, nil } func (m *mockConvHandlerLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { return &llm.EmbeddingResponse{}, nil } func (m *mockConvHandlerLLMProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error { return nil } // --- Conversation Handler Test Suite --- type ConversationHandlerTestSuite struct { suite.Suite router *gin.Engine db *gorm.DB testAccount *model.Account testConv *model.Conversation testUser *model.User testInbox *model.Inbox } func (s *ConversationHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) s.db = db err = db.AutoMigrate( &model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.ContactInbox{}, &model.Conversation{}, &model.ConversationParticipant{}, &model.ReportingEvent{}, &model.Message{}, &model.Notification{}, &model.Attachment{}, &model.InboxMember{}, &model.AccountUser{}, &model.CustomRole{}, &model.Team{}, &model.TeamMember{}, &model.Tag{}, &model.ConversationLabel{}, ) s.Require().NoError(err) // Create test account account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true, FeatureFlags: `{"conversation_unread_counts":true}`} s.Require().NoError(db.Create(account).Error) s.testAccount = account user := &model.User{Name: "Conv Handler User", Email: "conv-handler@example.com"} s.Require().NoError(db.Create(user).Error) s.Require().NoError(db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) s.testUser = user // Create inbox and contact inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1} s.Require().NoError(db.Create(inbox).Error) s.testInbox = inbox s.Require().NoError(db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error) contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"} s.Require().NoError(db.Create(contact).Error) // Create conversation conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} s.Require().NoError(db.Create(conv).Error) s.testConv = conv // Wire up repos, services, handlers convRepo := repository.NewConversationRepo(db) msgRepo := repository.NewMessageRepo(db) dispatcher := channel.NewDispatcher() inboxMemberRepo := repository.NewInboxMemberRepo(db) inboxMemberSvc := service.NewInboxMemberService(inboxMemberRepo) accountUserRepo := repository.NewAccountUserRepo(db) teamRepo := repository.NewTeamRepo(db) teamMemberRepo := repository.NewTeamMemberRepo(db) conversationSvc := service.NewConversationService(convRepo, msgRepo, dispatcher, inboxMemberSvc, accountUserRepo, teamRepo, teamMemberRepo) mockLLM := &mockConvHandlerLLMProvider{} messageSvc := service.NewMessageService(msgRepo, dispatcher, mockLLM) handler := NewConversationHandler(conversationSvc, messageSvc) // Setup router r := gin.New() r.Use(func(c *gin.Context) { c.Set("user_id", user.ID) c.Next() }) s.router = r // Register routes accountGroup := r.Group("/api/v1/accounts/:account_id") { conversations := accountGroup.Group("/conversations") { conversations.GET("/meta", handler.Meta) conversations.GET("/unread_counts", handler.UnreadCounts) conversations.POST("/:conversation_id/unread", handler.Unread) conversations.POST("/:conversation_id/transcript", handler.Transcript) conversations.POST("/:conversation_id/custom_attributes", handler.UpdateCustomAttributes) conversations.GET("/:conversation_id/attachments", handler.ListAttachments) conversations.GET("/:conversation_id/reporting_events", handler.ReportingEvents) conversations.POST("/:conversation_id/toggle_typing", handler.ToggleTyping) conversations.POST("/:conversation_id/toggle_typing_status", handler.ToggleTyping) conversations.POST("/:conversation_id/update_last_seen", handler.UpdateLastSeen) conversations.DELETE("/:conversation_id", handler.Delete) } } } func (s *ConversationHandlerTestSuite) TearDownSuite() { if s.db != nil { sqlDB, _ := s.db.DB() sqlDB.Close() } } func (s *ConversationHandlerTestSuite) TearDownTest() { s.db.Exec("DELETE FROM attachments") s.db.Exec("DELETE FROM reporting_events") s.db.Exec("DELETE FROM conversations") s.db.Exec("DELETE FROM contact_inboxes") s.db.Exec("DELETE FROM contacts") s.db.Exec("DELETE FROM inbox_members") s.db.Exec("DELETE FROM account_users") s.db.Exec("DELETE FROM inboxes") s.db.Exec("DELETE FROM messages") s.db.Exec("DELETE FROM accounts") // Re-seed base data account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true, FeatureFlags: `{"conversation_unread_counts":true}`} s.Require().NoError(s.db.Create(account).Error) s.testAccount = account s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: account.ID, UserID: s.testUser.ID, Role: "administrator"}).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1} s.Require().NoError(s.db.Create(inbox).Error) s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: s.testUser.ID}).Error) contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"} s.Require().NoError(s.db.Create(contact).Error) conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} s.Require().NoError(s.db.Create(conv).Error) s.testConv = conv } // Helper to build account URL prefix func (s *ConversationHandlerTestSuite) accountURL() string { return "/api/v1/accounts/" + strconv.FormatUint(uint64(s.testAccount.ID), 10) } // ========== Meta Handler Tests ========== func (s *ConversationHandlerTestSuite) TestMeta_Success() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp struct { Meta struct { MineCount int64 `json:"mine_count"` AssignedCount int64 `json:"assigned_count"` UnassignedCount int64 `json:"unassigned_count"` AllCount int64 `json:"all_count"` } `json:"meta"` } err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.NotContains(s.T(), w.Body.String(), "success") assert.Equal(s.T(), int64(1), resp.Meta.AllCount) assert.Equal(s.T(), int64(1), resp.Meta.UnassignedCount) } func (s *ConversationHandlerTestSuite) TestMeta_FiltersStatusAndIgnoresAssigneeTypeForCounts() { assigned := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, AssigneeID: &s.testUser.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} s.Require().NoError(s.db.Create(assigned).Error) resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, AssigneeID: &s.testUser.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"} s.Require().NoError(s.db.Create(resolved).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta?assignee_type=assigned", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp struct { Meta struct { MineCount int64 `json:"mine_count"` AssignedCount int64 `json:"assigned_count"` UnassignedCount int64 `json:"unassigned_count"` AllCount int64 `json:"all_count"` } `json:"meta"` } s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), int64(1), resp.Meta.MineCount) assert.Equal(s.T(), int64(1), resp.Meta.AssignedCount) assert.Equal(s.T(), int64(1), resp.Meta.UnassignedCount) assert.Equal(s.T(), int64(2), resp.Meta.AllCount) } func (s *ConversationHandlerTestSuite) TestMeta_QueryAndSourceIDFilters() { contactInbox := &model.ContactInbox{ContactID: s.testConv.ContactID, InboxID: s.testConv.InboxID, SourceID: "widget-source"} s.Require().NoError(s.db.Create(contactInbox).Error) s.Require().NoError(s.db.Model(s.testConv).Updates(map[string]any{"contact_inbox_id": contactInbox.ID, "assignee_id": s.testUser.ID}).Error) other := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} s.Require().NoError(s.db.Create(other).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: s.testConv.ID, Content: "needle from widget", MessageType: string(model.MessageTypeIncoming)}).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: other.ID, Content: "needle from other", MessageType: string(model.MessageTypeIncoming)}).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta?q=needle&source_id=widget-source", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp struct { Meta struct { MineCount int64 `json:"mine_count"` AllCount int64 `json:"all_count"` } `json:"meta"` } s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), int64(1), resp.Meta.AllCount) assert.Equal(s.T(), int64(1), resp.Meta.MineCount) } func (s *ConversationHandlerTestSuite) TestMeta_QuerySkipsStatusFilter() { resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"} s.Require().NoError(s.db.Create(resolved).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: s.testConv.ID, Content: "needle open", MessageType: string(model.MessageTypeIncoming)}).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: resolved.ID, Content: "needle resolved", MessageType: string(model.MessageTypeOutgoing)}).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta?q=needle&status=open", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp struct { Meta struct { AllCount int64 `json:"all_count"` } `json:"meta"` } s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), int64(2), resp.Meta.AllCount) } func (s *ConversationHandlerTestSuite) TestMeta_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/invalid/conversations/meta", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } // ========== Unread Handler Tests ========== func (s *ConversationHandlerTestSuite) TestUnread_Success() { // Set agent_last_seen_at on the conversation seenAt := int64(1700000000) s.Require().NoError(s.db.Model(s.testConv).Update("agent_last_seen_at", seenAt).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/unread", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp struct { ID uint `json:"id"` Status string `json:"status"` } err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.Equal(s.T(), s.testConv.ID, resp.ID) } func (s *ConversationHandlerTestSuite) TestUnread_DisplayIDRouteSetsLastSeenBeforeIncoming() { displayID := uint(909) s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error) incomingCreatedAt := time.Now().Add(-10 * time.Minute) oldSeen := incomingCreatedAt.Add(5 * time.Minute).Unix() s.Require().NoError(s.db.Model(s.testConv).Updates(map[string]any{ "agent_last_seen_at": oldSeen, "assignee_last_seen_at": oldSeen, }).Error) message := &model.Message{ AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, MessageType: string(model.MessageTypeIncoming), Content: "unread transition", ContentType: "text", SenderType: "contact", } s.Require().NoError(s.db.Create(message).Error) s.Require().NoError(s.db.Model(message).Updates(map[string]any{"created_at": incomingCreatedAt, "updated_at": incomingCreatedAt}).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(displayID), 10)+"/unread", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) var resp map[string]any s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), float64(displayID), resp["id"]) assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), int64(resp["agent_last_seen_at"].(float64))) assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), int64(resp["assignee_last_seen_at"].(float64))) assert.NotContains(s.T(), resp, "success") var stored model.Conversation s.Require().NoError(s.db.First(&stored, s.testConv.ID).Error) s.Require().NotNil(stored.AgentLastSeenAt) s.Require().NotNil(stored.AssigneeLastSeenAt) assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), *stored.AgentLastSeenAt) assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), *stored.AssigneeLastSeenAt) } func (s *ConversationHandlerTestSuite) TestUnread_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/unread", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestUnread_InvalidConversationID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/invalid/unread", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestUnread_ConversationNotFound() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/9999/unread", nil) s.router.ServeHTTP(w, req) // Service returns "not found" error, handleServiceError maps to 404 assert.Equal(s.T(), http.StatusNotFound, w.Code) } // ========== Transcript Handler Tests ========== func (s *ConversationHandlerTestSuite) TestTranscript_Success() { body := map[string]string{"email": "test@example.com"} bodyBytes, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) } func (s *ConversationHandlerTestSuite) TestTranscript_InvalidAccountID() { body := map[string]string{"email": "test@example.com"} bodyBytes, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/transcript", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestTranscript_MissingEmail() { body := map[string]string{} // no email bodyBytes, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.Equal(s.T(), "email param missing", resp["error"]) } func (s *ConversationHandlerTestSuite) TestTranscript_InvalidEmail() { body := map[string]string{"email": "not-an-email"} bodyBytes, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) } func (s *ConversationHandlerTestSuite) TestTranscript_PaymentRequiredWhenDisabled() { s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{ "limits": datatypes.JSON(`{"email_transcript_enabled":false}`), "custom_attributes": datatypes.JSON(`{}`), }).Error) s.T().Cleanup(func() { _ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error }) bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"}) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusPaymentRequired, w.Code) var resp map[string]any s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), "Email transcript is not available on your plan", resp["error"]) } func (s *ConversationHandlerTestSuite) TestTranscript_TooManyRequestsWhenRateLimited() { s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{ "limits": datatypes.JSON(`{"emails":1}`), "custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`), }).Error) s.T().Cleanup(func() { _ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error }) bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"}) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusTooManyRequests, w.Code) assert.Empty(s.T(), w.Body.String()) } func (s *ConversationHandlerTestSuite) TestTranscript_ConversationNotFound() { body := map[string]string{"email": "test@example.com"} bodyBytes, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/9999/transcript", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ConversationHandlerTestSuite) TestDelete_SuccessReturnsChatwootHeadOK() { w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodDelete, s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) var deleted model.Conversation assert.Error(s.T(), s.db.First(&deleted, s.testConv.ID).Error) } func (s *ConversationHandlerTestSuite) TestDelete_ConversationNotFound() { w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodDelete, s.accountURL()+"/conversations/9999", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } // ========== UpdateCustomAttributes Handler Tests ========== func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_Success() { body := map[string]interface{}{ "custom_attributes": map[string]string{"priority_reason": "vip_customer", "region": "us-west"}, } bodyBytes, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/custom_attributes", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp struct { CustomAttributes map[string]interface{} `json:"custom_attributes"` } err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.Equal(s.T(), "vip_customer", resp.CustomAttributes["priority_reason"]) var rawResp map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &rawResp) assert.NoError(s.T(), err) assert.NotContains(s.T(), rawResp, "id") assert.NotContains(s.T(), rawResp, "success") assert.NotContains(s.T(), rawResp, "payload") assert.NotContains(s.T(), rawResp, "meta") } func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_EmptyObjectReturnsChatwootShape() { body := map[string]interface{}{ "custom_attributes": map[string]interface{}{}, } bodyBytes, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/custom_attributes", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var rawResp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &rawResp) assert.NoError(s.T(), err) assert.Equal(s.T(), map[string]interface{}{}, rawResp["custom_attributes"]) assert.Len(s.T(), rawResp, 1) } func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_InvalidAccountID() { body := map[string]interface{}{ "custom_attributes": map[string]string{"key": "value"}, } bodyBytes, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/custom_attributes", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_MissingBody() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/custom_attributes", nil) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) // ShouldBindJSON fails without body assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_ConversationNotFound() { body := map[string]interface{}{ "custom_attributes": map[string]string{"key": "value"}, } bodyBytes, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/9999/custom_attributes", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } // ========== UnreadCounts Handler Tests ========== func (s *ConversationHandlerTestSuite) TestUnreadCounts_Success() { s.Require().NoError(s.db.Model(s.testAccount).Update("feature_flags", `{"conversation_unread_counts":true}`).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/unread_counts", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp struct { Payload struct { Inboxes map[string]int64 `json:"inboxes"` Labels map[string]int64 `json:"labels"` Teams map[string]int64 `json:"teams"` } `json:"payload"` } err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.NotContains(s.T(), w.Body.String(), "success") assert.NotNil(s.T(), resp.Payload.Inboxes) } func (s *ConversationHandlerTestSuite) TestUnreadCounts_FeatureDisabled() { account := &model.Account{Name: "Unread Disabled", Locale: "en", Active: true} s.Require().NoError(s.db.Create(account).Error) s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: account.ID, UserID: s.testUser.ID, Role: "administrator"}).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/"+strconv.FormatUint(uint64(account.ID), 10)+"/conversations/unread_counts", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusForbidden, w.Code) var resp map[string]any s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), "Conversation unread counts feature not enabled for this account", resp["error"]) } func (s *ConversationHandlerTestSuite) TestUnreadCounts_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/invalid/conversations/unread_counts", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestListAttachments_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/conversations/1/attachments", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestListAttachments_InvalidConversationID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/attachments", s.testAccount.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestListAttachmentsReturnsChatwootPayload() { message := &model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: s.testConv.ID, Content: "file", MessageType: "incoming", ContentType: "text", Status: "sent"} s.Require().NoError(s.db.Create(message).Error) attachment := &model.Attachment{AccountID: s.testAccount.ID, MessageID: message.ID, FileType: "file", FileURL: "https://cdn.example.com/report.pdf", FileName: "report.pdf", FileSize: 2048} s.Require().NoError(s.db.Create(attachment).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/attachments", s.accountURL(), s.testConv.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) var resp map[string]any assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), float64(1), resp["meta"].(map[string]any)["total_count"]) payload := resp["payload"].([]any) assert.Len(s.T(), payload, 1) item := payload[0].(map[string]any) assert.Equal(s.T(), float64(attachment.ID), item["id"]) assert.Equal(s.T(), float64(message.ID), item["message_id"]) assert.Equal(s.T(), "file", item["file_type"]) assert.Equal(s.T(), "https://cdn.example.com/report.pdf", item["data_url"]) assert.Equal(s.T(), "pdf", item["extension"]) assert.Contains(s.T(), item, "created_at") } func (s *ConversationHandlerTestSuite) TestListAttachmentsUsesChatwootFixedPageSize() { conversation := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} s.Require().NoError(s.db.Create(conversation).Error) message := &model.Message{AccountID: s.testAccount.ID, InboxID: conversation.InboxID, ConversationID: conversation.ID, Content: "files", MessageType: "incoming", ContentType: "text", Status: "sent"} s.Require().NoError(s.db.Create(message).Error) baseTime := time.Now().Add(-time.Hour) var newestID uint for i := 0; i < 30; i++ { attachment := &model.Attachment{ Base: model.Base{CreatedAt: baseTime.Add(time.Duration(i) * time.Minute), UpdatedAt: baseTime.Add(time.Duration(i) * time.Minute)}, AccountID: s.testAccount.ID, MessageID: message.ID, FileType: "file", FileURL: fmt.Sprintf("https://cdn.example.com/file-%02d.txt", i), FileName: fmt.Sprintf("file-%02d.txt", i), } s.Require().NoError(s.db.Create(attachment).Error) newestID = attachment.ID } w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/attachments?per_page=5", s.accountURL(), conversation.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) var resp map[string]any assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), float64(30), resp["meta"].(map[string]any)["total_count"]) payload := resp["payload"].([]any) assert.Len(s.T(), payload, 30) first := payload[0].(map[string]any) assert.Equal(s.T(), float64(newestID), first["id"]) } func (s *ConversationHandlerTestSuite) TestReportingEventsReturnsRawArrayInCreatedOrder() { conversationID := s.testConv.ID baseTime := time.Now().Add(-2 * time.Hour).UTC() newer := &model.ReportingEvent{ Base: model.Base{CreatedAt: baseTime.Add(time.Hour), UpdatedAt: baseTime.Add(time.Hour)}, AccountID: s.testAccount.ID, Name: "resolution_time", Value: 42, ValueInBusinessHours: 21, ConversationID: &conversationID, EventStartTime: baseTime, EventEndTime: baseTime.Add(time.Minute), } older := &model.ReportingEvent{ Base: model.Base{CreatedAt: baseTime, UpdatedAt: baseTime}, AccountID: s.testAccount.ID, Name: "first_response", Value: 10, ValueInBusinessHours: 5, ConversationID: &conversationID, EventStartTime: baseTime.Add(-time.Minute), EventEndTime: baseTime, } s.Require().NoError(s.db.Create(newer).Error) s.Require().NoError(s.db.Create(older).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/reporting_events", s.accountURL(), s.testConv.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) var resp []map[string]any assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.Len(s.T(), resp, 2) assert.Equal(s.T(), float64(older.ID), resp[0]["id"]) assert.Equal(s.T(), "first_response", resp[0]["name"]) assert.Equal(s.T(), float64(10), resp[0]["value"]) assert.Equal(s.T(), float64(5), resp[0]["value_in_business_hours"]) assert.Equal(s.T(), float64(s.testAccount.ID), resp[0]["account_id"]) assert.Equal(s.T(), float64(s.testConv.ID), resp[0]["conversation_id"]) assert.Contains(s.T(), resp[0], "inbox_id") assert.Nil(s.T(), resp[0]["inbox_id"]) assert.Contains(s.T(), resp[0], "user_id") assert.Nil(s.T(), resp[0]["user_id"]) assert.Contains(s.T(), resp[0], "event_start_time") assert.Contains(s.T(), resp[0], "event_end_time") assert.NotContains(s.T(), resp[0], "success") assert.NotContains(s.T(), resp[0], "payload") assert.NotContains(s.T(), resp[0], "meta") } func (s *ConversationHandlerTestSuite) TestReportingEventsUsesDisplayIDAndScopesAccount() { displayID := uint(880) s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error) conversationID := s.testConv.ID event := &model.ReportingEvent{ AccountID: s.testAccount.ID, Name: "reply_time", Value: 7, ValueInBusinessHours: 3, ConversationID: &conversationID, EventStartTime: time.Now().Add(-time.Minute).UTC(), EventEndTime: time.Now().UTC(), } s.Require().NoError(s.db.Create(event).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/reporting_events", s.accountURL(), displayID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) var resp []map[string]any assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp)) assert.Len(s.T(), resp, 1) assert.Equal(s.T(), float64(event.ID), resp[0]["id"]) otherAccount := &model.Account{Name: "OtherOrg", Locale: "en", Active: true} s.Require().NoError(s.db.Create(otherAccount).Error) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/reporting_events", otherAccount.ID, displayID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ConversationHandlerTestSuite) TestReportingEventsInvalidParams() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/conversations/1/reporting_events", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/reporting_events", s.testAccount.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/toggle_typing", bytes.NewBufferString(`{"typing_status":"on"}`)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidConversationID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/toggle_typing", s.testAccount.ID), bytes.NewBufferString(`{"typing_status":"on"}`)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestToggleTyping_MissingFieldsIsNoopSuccess() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{}`)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) } func (s *ConversationHandlerTestSuite) TestToggleTypingStatus_ChatwootRouteSuccess() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing_status", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{"typing_status":"typing_on","is_private":false}`)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) } func (s *ConversationHandlerTestSuite) TestToggleTyping_EmptyBodyIsNoopSuccess() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) } func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidJSON() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{`)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_SuccessMarksNotificationRead() { oldSeen := time.Now().Add(-2 * time.Hour).Unix() s.Require().NoError(s.db.Model(s.testConv).Update("agent_last_seen_at", oldSeen).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new message"}).Error) accountID := s.testAccount.ID notification := &model.Notification{UserID: s.testUser.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: s.testConv.ID} s.Require().NoError(s.db.Create(notification).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/update_last_seen", s.testAccount.ID, s.testConv.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) var conversation model.Conversation s.Require().NoError(s.db.First(&conversation, s.testConv.ID).Error) s.Require().NotNil(conversation.AgentLastSeenAt) assert.Greater(s.T(), *conversation.AgentLastSeenAt, oldSeen) var updatedNotification model.Notification s.Require().NoError(s.db.First(&updatedNotification, notification.ID).Error) assert.NotNil(s.T(), updatedNotification.ReadAt) } func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_DisplayIDRouteMarksNotificationRead() { displayID := uint(910) s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error) oldSeen := time.Now().Add(-2 * time.Hour).Unix() s.Require().NoError(s.db.Model(s.testConv).Update("agent_last_seen_at", oldSeen).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new display-id message"}).Error) accountID := s.testAccount.ID notification := &model.Notification{UserID: s.testUser.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: s.testConv.ID} s.Require().NoError(s.db.Create(notification).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/update_last_seen", s.testAccount.ID, displayID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) var conversation model.Conversation s.Require().NoError(s.db.First(&conversation, s.testConv.ID).Error) s.Require().NotNil(conversation.AgentLastSeenAt) assert.Greater(s.T(), *conversation.AgentLastSeenAt, oldSeen) var updatedNotification model.Notification s.Require().NoError(s.db.First(&updatedNotification, notification.ID).Error) assert.NotNil(s.T(), updatedNotification.ReadAt) } func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/update_last_seen", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_InvalidConversationID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/update_last_seen", s.testAccount.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } // Run the test suite func TestConversationHandlerTestSuite(t *testing.T) { suite.Run(t, new(ConversationHandlerTestSuite)) }