Files
gochat/internal/handler/api/v1/message_handler_test.go
T

798 lines
26 KiB
Go

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/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 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() {
s.Require().NoError(s.db.Model(s.testMessage).Updates(map[string]interface{}{
"status": "failed",
"content_attributes": datatypes.JSON([]byte(`{"external_error":"provider failed"}`)),
}).Error)
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"])
assert.Equal(s.T(), "sent", resp["status"])
assert.Equal(s.T(), map[string]interface{}{}, resp["content_attributes"])
}
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)
}