package v1 import ( "bytes" "context" "encoding/json" "fmt" "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/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 message handler tests --- // Named separately to avoid conflict with mockLLMProvider in captain_task_service_test.go type mockMsgHandlerLLMProvider struct { chatResponse *llm.ChatResponse chatError error } func (m *mockMsgHandlerLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { if m.chatError != nil { return nil, m.chatError } return m.chatResponse, nil } func (m *mockMsgHandlerLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { return &llm.EmbeddingResponse{}, nil } func (m *mockMsgHandlerLLMProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error { return nil } // --- Message Handler Test Suite --- (CRUD, Retry, Translate) type MessageHandlerTestSuite struct { suite.Suite router *gin.Engine handler *MessageHandler db *gorm.DB testAccount *model.Account testInbox *model.Inbox testContact *model.Contact testConv *model.Conversation testMessage *model.Message mockLLM *mockMsgHandlerLLMProvider dispatcher *channel.Dispatcher } func (s *MessageHandlerTestSuite) 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 // AutoMigrate all required models err = db.AutoMigrate( &model.Account{}, &model.Inbox{}, &model.Contact{}, &model.ContactInbox{}, &model.Conversation{}, &model.ConversationParticipant{}, &model.Message{}, &model.InboxMember{}, ) s.Require().NoError(err) // Wire up repos, services, handlers msgRepo := repository.NewMessageRepo(db) s.dispatcher = channel.NewDispatcher() s.mockLLM = &mockMsgHandlerLLMProvider{ chatResponse: &llm.ChatResponse{ Choices: []llm.ChatChoice{ { Message: llm.ChatMessage{Role: "assistant", Content: "Bonjour le monde"}, }, }, }, } msgSvc := service.NewMessageService(msgRepo, s.dispatcher, s.mockLLM) s.handler = NewMessageHandler(msgSvc) // Setup router with all message routes r := gin.New() r.RedirectTrailingSlash = false s.router = r // Auth middleware: inject user_id into context for all message routes r.Use(func(c *gin.Context) { c.Set("user_id", uint(1)) c.Next() }) accountGroup := r.Group("/api/v1/accounts/:account_id") { conversations := accountGroup.Group("/conversations") { msgs := conversations.Group("/:conversation_id/messages") { msgs.GET("/", s.handler.List) msgs.POST("/", s.handler.Create) msgs.GET("/:message_id", s.handler.Get) msgs.PATCH("/:message_id", s.handler.Update) msgs.DELETE("/:message_id", s.handler.Delete) msgs.POST("/:message_id/retry", s.handler.Retry) msgs.POST("/:message_id/translate", s.handler.Translate) } } } } func (s *MessageHandlerTestSuite) TearDownSuite() { if s.db != nil { sqlDB, _ := s.db.DB() sqlDB.Close() } } func (s *MessageHandlerTestSuite) SetupTest() { // Seed data for each test account := &model.Account{Name: "MsgHandlerTestAccount", Locale: "en", Active: true} s.Require().NoError(s.db.Create(account).Error) s.testAccount = account inbox := &model.Inbox{AccountID: account.ID, Name: "MsgHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1} s.Require().NoError(s.db.Create(inbox).Error) s.testInbox = inbox contact := &model.Contact{AccountID: account.ID, Name: "MsgHandlerTestContact"} s.Require().NoError(s.db.Create(contact).Error) s.testContact = contact conv := &model.Conversation{ AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), Priority: string(model.ConversationPriorityMedium), ChannelType: "web_widget", Channel: "web_widget", } s.Require().NoError(s.db.Create(conv).Error) s.testConv = conv msg := &model.Message{ ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "Hello world", ContentType: "text", MessageType: "outgoing", SenderType: "user", } s.Require().NoError(s.db.Create(msg).Error) s.testMessage = msg // Reset mock LLM to default success response s.mockLLM.chatResponse = &llm.ChatResponse{ Choices: []llm.ChatChoice{ { Message: llm.ChatMessage{Role: "assistant", Content: "Bonjour le monde"}, }, }, } s.mockLLM.chatError = nil } func (s *MessageHandlerTestSuite) TearDownTest() { s.db.Exec("DELETE FROM messages") s.db.Exec("DELETE FROM conversation_participants") 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 accounts") } func TestMessageHandlerTestSuite(t *testing.T) { suite.Run(t, new(MessageHandlerTestSuite)) } // --- Helper for building message list URL --- func msgListURL(accountID, convID uint) string { return fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages/", accountID, convID) } func msgDetailURL(accountID, convID, msgID uint) string { return fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages/%d", accountID, convID, msgID) } func msgRetryURL(accountID, convID, msgID uint) string { return fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages/%d/retry", accountID, convID, msgID) } func msgTranslateURL(accountID, convID, msgID uint) string { return fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages/%d/translate", accountID, convID, msgID) } // --- List Tests --- func (s *MessageHandlerTestSuite) TestList_Success() { w := httptest.NewRecorder() url := msgListURL(s.testAccount.ID, s.testConv.ID) req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.True(s.T(), resp["success"].(bool)) data, ok := resp["data"].([]interface{}) assert.True(s.T(), ok) assert.GreaterOrEqual(s.T(), len(data), 1) } func (s *MessageHandlerTestSuite) TestList_Empty() { // Delete seeded messages to test empty list s.db.Exec("DELETE FROM messages") w := httptest.NewRecorder() url := msgListURL(s.testAccount.ID, s.testConv.ID) req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) data, ok := resp["data"].([]interface{}) assert.True(s.T(), ok) assert.Equal(s.T(), 0, len(data)) } func (s *MessageHandlerTestSuite) TestList_InvalidConversationID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/abc/messages/", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } // --- Create Tests --- func (s *MessageHandlerTestSuite) TestCreate_Success() { payload := map[string]interface{}{ "content": "New message", "content_type": "text", "message_type": "outgoing", "private": false, } body, _ := json.Marshal(payload) w := httptest.NewRecorder() url := msgListURL(s.testAccount.ID, s.testConv.ID) req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusCreated, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.True(s.T(), resp["success"].(bool)) data := resp["data"].(map[string]interface{}) assert.NotNil(s.T(), data["id"]) assert.Equal(s.T(), "New message", data["content"]) } func (s *MessageHandlerTestSuite) TestCreate_MissingContent() { payload := map[string]interface{}{ "content_type": "text", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() url := msgListURL(s.testAccount.ID, s.testConv.ID) req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *MessageHandlerTestSuite) TestCreate_InvalidConversationID() { payload := map[string]interface{}{ "content": "Test", "content_type": "text", "message_type": "outgoing", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/abc/messages/", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } // --- Get Tests --- func (s *MessageHandlerTestSuite) TestGet_Success() { w := httptest.NewRecorder() url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.True(s.T(), resp["success"].(bool)) data := resp["data"].(map[string]interface{}) assert.Equal(s.T(), float64(s.testMessage.ID), data["id"]) assert.Equal(s.T(), "Hello world", data["content"]) } func (s *MessageHandlerTestSuite) TestGet_NotFound() { w := httptest.NewRecorder() url := msgDetailURL(s.testAccount.ID, s.testConv.ID, 999) req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *MessageHandlerTestSuite) TestGet_InvalidID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/1/messages/abc", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } // --- Update Tests --- func (s *MessageHandlerTestSuite) TestUpdate_Success() { payload := map[string]interface{}{ "content": "Updated content", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.True(s.T(), resp["success"].(bool)) data := resp["data"].(map[string]interface{}) assert.Equal(s.T(), "Updated content", data["content"]) } func (s *MessageHandlerTestSuite) TestUpdate_NotFound() { payload := map[string]interface{}{ "content": "Updated content", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() url := msgDetailURL(s.testAccount.ID, s.testConv.ID, 999) req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } // --- Delete Tests --- func (s *MessageHandlerTestSuite) TestDelete_Success() { w := httptest.NewRecorder() url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("DELETE", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNoContent, w.Code) } func (s *MessageHandlerTestSuite) TestDelete_NotFound() { w := httptest.NewRecorder() url := msgDetailURL(s.testAccount.ID, s.testConv.ID, 999) req, _ := http.NewRequest("DELETE", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } // --- Retry Tests --- func (s *MessageHandlerTestSuite) TestRetry_Success() { w := httptest.NewRecorder() url := msgRetryURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("POST", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.True(s.T(), resp["success"].(bool)) data := resp["data"].(map[string]interface{}) assert.Equal(s.T(), float64(s.testMessage.ID), data["id"]) } func (s *MessageHandlerTestSuite) TestRetry_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/messages/1/retry", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *MessageHandlerTestSuite) TestRetry_InvalidMessageID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/1/messages/abc/retry", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *MessageHandlerTestSuite) TestRetry_NotFound() { w := httptest.NewRecorder() url := msgRetryURL(s.testAccount.ID, s.testConv.ID, 999) req, _ := http.NewRequest("POST", url, nil) s.router.ServeHTTP(w, req) // Service returns "not found" error → handleServiceError → 404 assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *MessageHandlerTestSuite) TestRetry_AccountMismatch() { // Create a second account with its own conversation account2 := &model.Account{Name: "OtherAccount", Locale: "en", Active: true} s.Require().NoError(s.db.Create(account2).Error) // Try to retry message from account 1 using account 2 context w := httptest.NewRecorder() url := msgRetryURL(account2.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("POST", url, nil) s.router.ServeHTTP(w, req) // The service's FindByAccountAndID won't find the message under account 2 → "not found" → 404 assert.Equal(s.T(), http.StatusNotFound, w.Code) } // --- Translate Tests --- func (s *MessageHandlerTestSuite) TestTranslate_Success() { payload := map[string]interface{}{ "target_language": "fr", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.True(s.T(), resp["success"].(bool)) data := resp["data"].(map[string]interface{}) assert.Equal(s.T(), "Bonjour le monde", data["translated_content"]) assert.Equal(s.T(), "Hello world", data["original_content"]) assert.Equal(s.T(), "fr", data["target_language"]) } func (s *MessageHandlerTestSuite) TestTranslate_EmptyTargetLanguage() { payload := map[string]interface{}{ "target_language": "", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) // Validation fails: "required" → handleServiceError → 400 assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *MessageHandlerTestSuite) TestTranslate_MissingBody() { w := httptest.NewRecorder() url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("POST", url, nil) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) // ShouldBindJSON fails → 400 assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *MessageHandlerTestSuite) TestTranslate_InvalidAccountID() { payload := map[string]interface{}{ "target_language": "fr", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/messages/1/translate", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *MessageHandlerTestSuite) TestTranslate_InvalidMessageID() { payload := map[string]interface{}{ "target_language": "fr", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/1/messages/abc/translate", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *MessageHandlerTestSuite) TestTranslate_NotFound() { payload := map[string]interface{}{ "target_language": "fr", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, 999) req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) // Service returns "not found" → 404 assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *MessageHandlerTestSuite) TestTranslate_LLMError() { // Set LLM to return error s.mockLLM.chatError = fmt.Errorf("LLM service unavailable") payload := map[string]interface{}{ "target_language": "fr", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) // LLM error → service wraps it → handleServiceError → 500 (no "not found"/"invalid"/"validation"/"required" keywords) assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) } func (s *MessageHandlerTestSuite) TestTranslate_EmptyChoices() { // Set LLM to return response with no choices s.mockLLM.chatResponse = &llm.ChatResponse{ Choices: []llm.ChatChoice{}, } payload := map[string]interface{}{ "target_language": "fr", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) // Empty choices → translated_content is empty string → still returns 200 OK assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.True(s.T(), resp["success"].(bool)) data := resp["data"].(map[string]interface{}) assert.Equal(s.T(), "", data["translated_content"]) assert.Equal(s.T(), "Hello world", data["original_content"]) } // --- DirectUpload Handler Tests (upload_handler_test.go extension) --- // These test DirectUpload endpoint validation independently using nil service pattern. func TestDirectUploadHandler_NoFile(t *testing.T) { // Create handler with nil service — we only test validation before service call h := &UploadHandler{svc: nil} gin.SetMode(gin.TestMode) r := gin.New() widget := r.Group("/widget") widget.POST("/direct_uploads", h.DirectUpload) req, _ := http.NewRequest("POST", "/widget/direct_uploads", nil) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestDirectUploadHandler_InvalidContentType(t *testing.T) { h := &UploadHandler{svc: nil} gin.SetMode(gin.TestMode) r := gin.New() widget := r.Group("/widget") widget.POST("/direct_uploads", h.DirectUpload) req, _ := http.NewRequest("POST", "/widget/direct_uploads", bytes.NewReader([]byte("not multipart"))) req.Header.Set("Content-Type", "text/plain") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) }