798 lines
26 KiB
Go
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)
|
|
}
|