* H-162: make message routes display-ID only * H-162: cover frontend display-ID message flow --------- Co-authored-by: Rogee <rogee@ipao.vip>
948 lines
32 KiB
Go
948 lines
32 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"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"
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/service"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
)
|
|
|
|
// --- 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
|
|
chatCalls int
|
|
}
|
|
|
|
func (m *mockMsgHandlerLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
m.chatCalls++
|
|
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
|
|
testUser *model.User
|
|
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.User{},
|
|
&model.Inbox{},
|
|
&model.Contact{},
|
|
&model.ContactInbox{},
|
|
&model.Conversation{},
|
|
&model.ConversationParticipant{},
|
|
&model.Message{},
|
|
&model.Attachment{},
|
|
&model.InboxMember{},
|
|
&model.BackgroundJob{},
|
|
&channelmodel.ChannelAPI{},
|
|
)
|
|
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) {
|
|
userID := uint(1)
|
|
if s.testUser != nil {
|
|
userID = s.testUser.ID
|
|
}
|
|
c.Set("user_id", userID)
|
|
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
|
|
|
|
user := &model.User{AccountID: account.ID, Name: "Msg Handler Agent", DisplayName: "Message Agent", Email: "message-agent@example.com", Provider: "email", PubsubToken: "pubsub-message-agent"}
|
|
s.Require().NoError(s.db.Create(user).Error)
|
|
s.testUser = user
|
|
|
|
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
|
|
|
|
displayID := uint(4242)
|
|
conv := &model.Conversation{
|
|
AccountID: account.ID,
|
|
DisplayID: &displayID,
|
|
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
|
|
s.mockLLM.chatCalls = 0
|
|
}
|
|
|
|
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 background_jobs")
|
|
s.db.Exec("DELETE FROM channel_api")
|
|
s.db.Exec("DELETE FROM users")
|
|
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.DisplayID)
|
|
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.DisplayID)
|
|
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.DisplayID)+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.DisplayID)+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.DisplayID)
|
|
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.DisplayID), resp["conversation_id"])
|
|
}
|
|
|
|
func (s *MessageHandlerTestSuite) TestCreate_UsesRouteConversationOverBodyConversationID() {
|
|
otherConv := &model.Conversation{
|
|
AccountID: s.testAccount.ID,
|
|
InboxID: s.testInbox.ID,
|
|
ContactID: s.testContact.ID,
|
|
Status: string(model.ConversationStatusOpen),
|
|
Priority: string(model.ConversationPriorityMedium),
|
|
ChannelType: "web_widget",
|
|
Channel: "web_widget",
|
|
}
|
|
s.Require().NoError(s.db.Create(otherConv).Error)
|
|
|
|
payload := map[string]interface{}{
|
|
"conversation_id": otherConv.ID,
|
|
"content": "Route scoped message",
|
|
"message_type": "outgoing",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
w := httptest.NewRecorder()
|
|
url := msgListURL(s.testAccount.ID, *s.testConv.DisplayID)
|
|
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 created model.Message
|
|
s.Require().NoError(s.db.Where("content = ?", "Route scoped message").First(&created).Error)
|
|
assert.Equal(s.T(), s.testConv.ID, created.ConversationID)
|
|
}
|
|
|
|
func (s *MessageHandlerTestSuite) TestCreate_FrontendConversationIDKeepsMessageWebhookAndSIDOnTargetConversation() {
|
|
var webhookBody []byte
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
webhookBody, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer server.Close()
|
|
|
|
s.Require().NoError(s.db.Model(s.testInbox).Update("channel_type", "shangwutong").Error)
|
|
s.Require().NoError(s.db.Model(s.testConv).Update("custom_attributes", datatypes.JSON([]byte(`{"swt_sid":"old-sid"}`))).Error)
|
|
s.Require().NoError(s.db.Create(&channelmodel.ChannelAPI{
|
|
InboxID: s.testInbox.ID, WebhookURL: server.URL, Secret: "test-secret",
|
|
}).Error)
|
|
|
|
targetDisplayID := uint(7777)
|
|
target := &model.Conversation{
|
|
Base: model.Base{ID: *s.testConv.DisplayID},
|
|
AccountID: s.testAccount.ID,
|
|
DisplayID: &targetDisplayID,
|
|
InboxID: s.testInbox.ID,
|
|
ContactID: s.testContact.ID,
|
|
Status: string(model.ConversationStatusOpen),
|
|
Priority: string(model.ConversationPriorityMedium),
|
|
ChannelType: "shangwutong",
|
|
Channel: "shangwutong",
|
|
CustomAttributes: datatypes.JSON([]byte(`{"swt_sid":"target-sid"}`)),
|
|
}
|
|
s.Require().NoError(s.db.Create(target).Error)
|
|
s.Require().Equal(target.ID, *s.testConv.DisplayID, "fixture must reproduce internal/display ID collision")
|
|
frontendConversationID := serializeConversation(context.Background(), s.db, target).ID
|
|
s.Require().Equal(targetDisplayID, frontendConversationID)
|
|
s.Require().NotEqual(target.ID, frontendConversationID, "frontend conversation.id must be the display ID, not the internal ID")
|
|
|
|
workers := worker.NewWorkerPool(s.db)
|
|
service.RegisterShangwutongWebhookDeliveryJobs(workers, s.db)
|
|
s.handler.svc.SetWorkerPool(workers)
|
|
defer s.handler.svc.SetWorkerPool(nil)
|
|
|
|
body, _ := json.Marshal(map[string]any{"content": "collision-safe reply", "message_type": "outgoing"})
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodPost, msgListURL(s.testAccount.ID, frontendConversationID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
s.Require().Equal(http.StatusOK, w.Code, w.Body.String())
|
|
|
|
var message model.Message
|
|
s.Require().NoError(s.db.Where("content = ?", "collision-safe reply").First(&message).Error)
|
|
s.Equal(target.ID, message.ConversationID)
|
|
|
|
processed, err := workers.ProcessOne(context.Background())
|
|
s.Require().NoError(err)
|
|
s.Require().True(processed)
|
|
var envelope map[string]any
|
|
s.Require().NoError(json.Unmarshal(webhookBody, &envelope))
|
|
conversation := envelope["data"].(map[string]any)["conversation"].(map[string]any)
|
|
s.EqualValues(target.ID, conversation["id"])
|
|
s.EqualValues(targetDisplayID, conversation["display_id"])
|
|
s.Equal("target-sid", conversation["custom_attributes"].(map[string]any)["swt_sid"])
|
|
}
|
|
|
|
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.DisplayID)
|
|
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(), float64(s.testAccount.ID), resp["account_id"])
|
|
assert.Equal(s.T(), float64(s.testInbox.ID), resp["inbox_id"])
|
|
assert.Equal(s.T(), float64(*s.testConv.DisplayID), resp["conversation_id"])
|
|
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.Equal(s.T(), "sent", resp["status"])
|
|
assert.Equal(s.T(), "", resp["source_id"])
|
|
assert.NotContains(s.T(), resp, "success")
|
|
contentAttrs, ok := resp["content_attributes"].(map[string]interface{})
|
|
s.Require().True(ok)
|
|
assert.Equal(s.T(), []interface{}{}, contentAttrs["submitted_values"])
|
|
sender, ok := resp["sender"].(map[string]interface{})
|
|
s.Require().True(ok)
|
|
assert.Equal(s.T(), float64(s.testUser.ID), sender["id"])
|
|
assert.Equal(s.T(), "Msg Handler Agent", sender["name"])
|
|
assert.Equal(s.T(), "Message Agent", sender["available_name"])
|
|
assert.Equal(s.T(), "message-agent@example.com", sender["email"])
|
|
}
|
|
|
|
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.DisplayID), &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.DisplayID)
|
|
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.DisplayID, 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"])
|
|
assert.Equal(s.T(), float64(*s.testConv.DisplayID), resp["conversation_id"])
|
|
}
|
|
|
|
func (s *MessageHandlerTestSuite) TestGet_NotFound() {
|
|
w := httptest.NewRecorder()
|
|
url := msgDetailURL(s.testAccount.ID, *s.testConv.DisplayID, 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() {
|
|
s.Require().NoError(s.db.Model(s.testInbox).Update("channel_type", "api").Error)
|
|
s.testInbox.ChannelType = "api"
|
|
|
|
payload := map[string]interface{}{
|
|
"status": "delivered",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
w := httptest.NewRecorder()
|
|
url := msgDetailURL(s.testAccount.ID, *s.testConv.DisplayID, 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(), "Hello world", resp["content"])
|
|
assert.Equal(s.T(), "delivered", resp["status"])
|
|
}
|
|
|
|
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.DisplayID, 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.DisplayID, 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.DisplayID, 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.DisplayID, 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.DisplayID, 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.DisplayID, 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.DisplayID, 999)
|
|
req, _ := http.NewRequest("POST", url, nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// Chatwoot retry rescues lookup/status-update failures as unprocessable entity.
|
|
assert.Equal(s.T(), http.StatusUnprocessableEntity, 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.DisplayID, s.testMessage.ID)
|
|
req, _ := http.NewRequest("POST", url, nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusUnprocessableEntity, 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.DisplayID, 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.Equal(s.T(), "Bonjour le monde", resp["content"])
|
|
}
|
|
|
|
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.DisplayID, 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.DisplayID, 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.DisplayID, 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.DisplayID, 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.DisplayID, 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.Equal(s.T(), "", resp["content"])
|
|
}
|
|
|
|
func (s *MessageHandlerTestSuite) TestTranslate_CachesSecondCall() {
|
|
payload := map[string]interface{}{
|
|
"target_language": "fr",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
w := httptest.NewRecorder()
|
|
url := msgTranslateURL(s.testAccount.ID, *s.testConv.DisplayID, 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)
|
|
assert.Equal(s.T(), 1, s.mockLLM.chatCalls)
|
|
|
|
w2 := httptest.NewRecorder()
|
|
req2, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w2, req2)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w2.Code)
|
|
assert.Equal(s.T(), 1, s.mockLLM.chatCalls)
|
|
assert.Empty(s.T(), w2.Body.String())
|
|
}
|
|
|
|
// --- 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)
|
|
}
|