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/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 } 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.Message{}, &model.Attachment{}, &model.InboxMember{}, &model.Tag{}, &model.ConversationLabel{}, ) s.Require().NoError(err) // Create test account account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true} s.Require().NoError(db.Create(account).Error) s.testAccount = account // Create inbox and contact inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1} s.Require().NoError(db.Create(inbox).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() 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.POST("/:conversation_id/toggle_typing", handler.ToggleTyping) conversations.POST("/:conversation_id/update_last_seen", handler.UpdateLastSeen) } } } 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 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 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} s.Require().NoError(s.db.Create(account).Error) s.testAccount = account inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1} s.Require().NoError(s.db.Create(inbox).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 { Success bool `json:"success"` Data struct { TotalCount int64 `json:"total_count"` StatusCounts map[string]int64 `json:"status_counts"` LabelCounts map[string]int64 `json:"label_counts"` } `json:"data"` } err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.True(s.T(), resp.Success) assert.Equal(s.T(), int64(1), resp.Data.TotalCount) } 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_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_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) } // ========== 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() { 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 { Success bool `json:"success"` Data struct { Inboxes map[string]int64 `json:"inboxes"` Labels map[string]int64 `json:"labels"` Teams map[string]int64 `json:"teams"` } `json:"data"` } err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.True(s.T(), resp.Success) } 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) 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_MissingFields() { 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_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)) }