package v1 import ( "bytes" "context" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/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.Attachment{}, &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 attachments") 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) data, ok := resp["payload"].([]interface{}) assert.True(s.T(), ok) assert.GreaterOrEqual(s.T(), len(data), 1) meta, ok := resp["meta"].(map[string]interface{}) assert.True(s.T(), ok) assert.NotNil(s.T(), meta["contact"]) } 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["payload"].([]interface{}) assert.True(s.T(), ok) assert.Equal(s.T(), 0, len(data)) } func (s *MessageHandlerTestSuite) TestList_BeforeAfterMessageFinder() { s.db.Exec("DELETE FROM messages") var ids []uint for i := 0; i < 5; i++ { msg := &model.Message{ ConversationID: s.testConv.ID, AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, Content: fmt.Sprintf("message-%d", i+1), ContentType: "text", MessageType: "incoming", SenderType: "contact", } s.Require().NoError(s.db.Create(msg).Error) ids = append(ids, msg.ID) } w := httptest.NewRecorder() req, _ := http.NewRequest("GET", msgListURL(s.testAccount.ID, s.testConv.ID)+fmt.Sprintf("?before=%d", ids[3]), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var beforeResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &beforeResp) beforePayload := beforeResp["payload"].([]interface{}) assert.Len(s.T(), beforePayload, 3) assert.Equal(s.T(), float64(ids[0]), beforePayload[0].(map[string]interface{})["id"]) assert.Equal(s.T(), float64(ids[2]), beforePayload[2].(map[string]interface{})["id"]) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", msgListURL(s.testAccount.ID, s.testConv.ID)+fmt.Sprintf("?after=%d", ids[2]), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var afterResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &afterResp) afterPayload := afterResp["payload"].([]interface{}) assert.Len(s.T(), afterPayload, 2) assert.Equal(s.T(), float64(ids[3]), afterPayload[0].(map[string]interface{})["id"]) } 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.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.NotNil(s.T(), resp["id"]) assert.Equal(s.T(), "New message", resp["content"]) assert.Equal(s.T(), float64(1), resp["message_type"]) assert.Equal(s.T(), float64(s.testConv.ID), resp["conversation_id"]) } func (s *MessageHandlerTestSuite) TestCreate_ChatwootFrontendPayloadDefaultsOutgoing() { payload := map[string]interface{}{ "content": "Frontend payload", "private": true, "echo_id": "tmp-123", "content_attributes": map[string]interface{}{"submitted_values": []interface{}{}}, } 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.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.Equal(s.T(), "Frontend payload", resp["content"]) assert.Equal(s.T(), true, resp["private"]) assert.Equal(s.T(), "tmp-123", resp["echo_id"]) assert.Equal(s.T(), float64(1), resp["message_type"]) assert.Equal(s.T(), "text", resp["content_type"]) assert.NotNil(s.T(), resp["content_attributes"]) } func (s *MessageHandlerTestSuite) TestCreate_MultipartAttachmentPersistsAndSerializes() { var body bytes.Buffer writer := multipart.NewWriter(&body) s.Require().NoError(writer.WriteField("content", "Attachment message")) s.Require().NoError(writer.WriteField("private", "false")) file, err := writer.CreateFormFile("attachments[]", "hello.txt") s.Require().NoError(err) _, err = file.Write([]byte("hello world")) s.Require().NoError(err) s.Require().NoError(writer.Close()) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", msgListURL(s.testAccount.ID, s.testConv.ID), &body) req.Header.Set("Content-Type", writer.FormDataContentType()) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) attachments, ok := resp["attachments"].([]interface{}) assert.True(s.T(), ok) assert.Len(s.T(), attachments, 1) attachment := attachments[0].(map[string]interface{}) assert.Equal(s.T(), "file", attachment["file_type"]) assert.Contains(s.T(), attachment["data_url"], "hello.txt") var count int64 s.Require().NoError(s.db.Model(&model.Attachment{}).Where("message_id = ?", uint(resp["id"].(float64))).Count(&count).Error) assert.Equal(s.T(), int64(1), count) } 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.Equal(s.T(), float64(s.testMessage.ID), resp["id"]) assert.Equal(s.T(), "Hello world", resp["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.Equal(s.T(), "Updated content", resp["content"]) } func (s *MessageHandlerTestSuite) TestUpdate_StatusExternalError() { s.Require().NoError(s.db.Model(s.testInbox).Update("channel_type", "api").Error) s.testInbox.ChannelType = "api" payload := map[string]interface{}{ "status": "failed", "external_error": "provider rejected message", } 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.Equal(s.T(), "failed", resp["status"]) attrs := resp["content_attributes"].(map[string]interface{}) assert.Equal(s.T(), "provider rejected message", attrs["external_error"]) } func (s *MessageHandlerTestSuite) TestUpdate_StatusForbiddenForNonAPIInbox() { payload := map[string]interface{}{"status": "delivered"} 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.StatusForbidden, w.Code) } 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() { attachment := &model.Attachment{MessageID: s.testMessage.ID, AccountID: s.testAccount.ID, FileType: "file", FileName: "delete.txt"} s.Require().NoError(s.db.Create(attachment).Error) 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.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.Equal(s.T(), "This message was deleted", resp["content"]) attrs := resp["content_attributes"].(map[string]interface{}) assert.Equal(s.T(), true, attrs["deleted"]) _, hasAttachments := resp["attachments"] assert.False(s.T(), hasAttachments) var count int64 s.Require().NoError(s.db.Model(&model.Attachment{}).Where("message_id = ?", s.testMessage.ID).Count(&count).Error) assert.Equal(s.T(), int64(0), count) } 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.Equal(s.T(), float64(s.testMessage.ID), resp["id"]) assert.Equal(s.T(), float64(1), resp["message_type"]) } 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) }