Files
gochat/internal/service/message_service_test.go
T

969 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"gorm.io/gorm"
"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/search"
"github.com/gochat/gochat/internal/worker"
)
// mockMessageLLMProvider implements llm.Provider for message service testing.
// (Separate name to avoid conflict with mockLLMProvider in captain_task_service_test.go)
type mockMessageLLMProvider struct {
chatResponse *llm.ChatResponse
chatErr error
chatCalls int
}
func (m *mockMessageLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
m.chatCalls++
if m.chatErr != nil {
return nil, m.chatErr
}
return m.chatResponse, nil
}
func (m *mockMessageLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockMessageLLMProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error {
return fmt.Errorf("not implemented")
}
// mockRetryListener implements channel.EventListener for capturing dispatched events.
type mockRetryListener struct {
received bool
lastEventType channel.EventType
lastAccountID uint
lastInboxID uint
lastUserID uint
lastConvID uint
lastData map[string]interface{}
}
func (l *mockRetryListener) Name() string {
return "mock-retry-listener"
}
func (l *mockRetryListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error {
l.received = true
l.lastEventType = event.Type
l.lastAccountID = event.AccountID
l.lastInboxID = event.InboxID
l.lastUserID = event.UserID
l.lastConvID = event.ConversationID
l.lastData = event.Data
return nil
}
// setupMessageService creates MessageRepo + Dispatcher + mockLLM + MessageService test instances.
func setupMessageService(t *testing.T, mockLLM *mockMessageLLMProvider) (*gorm.DB, *repository.MessageRepo, *channel.Dispatcher, *MessageService) {
t.Helper()
db := setupServiceTestDB(t)
repo := repository.NewMessageRepo(db)
dispatcher := channel.NewDispatcher()
svc := NewMessageService(repo, dispatcher, mockLLM)
return db, repo, dispatcher, svc
}
// setupMessageServiceWithDefaultLLM creates a MessageService with a default mock LLM that returns "translated text".
func setupMessageServiceWithDefaultLLM(t *testing.T) (*gorm.DB, *repository.MessageRepo, *channel.Dispatcher, *MessageService) {
t.Helper()
mockLLM := &mockMessageLLMProvider{
chatResponse: &llm.ChatResponse{
Choices: []llm.ChatChoice{
{Message: llm.ChatMessage{Role: "assistant", Content: "translated text"}},
},
},
}
return setupMessageService(t, mockLLM)
}
// ========== ListByConversation 测试 ==========
func TestMessageService_ListByConversation(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
user := createTestUser(t, db, account.ID)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
// 创建多条消息
msg1 := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "消息1", MessageType: "incoming", ContentType: "text", SenderType: "contact", SenderID: &contact.ID,
}
msg2 := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "消息2", MessageType: "outgoing", ContentType: "text", SenderType: "user", SenderID: &user.ID,
}
require.NoError(t, db.Create(msg1).Error)
require.NoError(t, db.Create(msg2).Error)
// 正常路径:查询会话消息列表
messages, total, err := svc.ListByConversation(ctx, conv.ID, 0, 10)
assert.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, messages, 2)
// 正常路径:分页查询
messages2, total2, err2 := svc.ListByConversation(ctx, conv.ID, 1, 1)
assert.NoError(t, err2)
assert.Equal(t, int64(2), total2)
assert.Len(t, messages2, 1)
// 正常路径:空会话
messages3, total3, err3 := svc.ListByConversation(ctx, 9999, 0, 10)
assert.NoError(t, err3)
assert.Equal(t, int64(0), total3)
assert.Len(t, messages3, 0)
}
// ========== GetByID 测试 ==========
func TestMessageService_GetByID(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "Hello", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg).Error)
// 正常路径:按ID查询
found, err := svc.GetByID(ctx, msg.ID)
assert.NoError(t, err)
assert.Equal(t, "Hello", found.Content)
assert.Equal(t, "incoming", found.MessageType)
// 错误路径:不存在的ID
_, err = svc.GetByID(ctx, 9999)
assert.Error(t, err)
}
// ========== GetByAccountAndID 测试 ==========
func TestMessageService_GetByAccountAndID(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "ScopedMsg", MessageType: "outgoing", ContentType: "text", SenderType: "user",
}
require.NoError(t, db.Create(msg).Error)
// 正常路径:按account和ID查询
found, err := svc.GetByAccountAndID(ctx, account.ID, msg.ID)
assert.NoError(t, err)
assert.Equal(t, "ScopedMsg", found.Content)
// 错误路径:accountID不匹配
_, err = svc.GetByAccountAndID(ctx, 9999, msg.ID)
assert.Error(t, err)
// 错误路径:ID不存在
_, err = svc.GetByAccountAndID(ctx, account.ID, 9999)
assert.Error(t, err)
}
// ========== GetByConversationAndID 测试 ==========
func TestMessageService_GetByConversationAndID(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "ConvScopedMsg", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg).Error)
// 正常路径:按conversation和ID查询
found, err := svc.GetByConversationAndID(ctx, conv.ID, msg.ID)
assert.NoError(t, err)
assert.Equal(t, "ConvScopedMsg", found.Content)
// 错误路径:conversationID不匹配
_, err = svc.GetByConversationAndID(ctx, 9999, msg.ID)
assert.Error(t, err)
// 错误路径:ID不存在
_, err = svc.GetByConversationAndID(ctx, conv.ID, 9999)
assert.Error(t, err)
}
// ========== Search 测试 ==========
func TestMessageService_Search(t *testing.T) {
// Search底层使用ILIKESQLite不支持该语法
// 在SQLite环境下Search会返回SQL错误,Service层只做委托调用,这是预期行为
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg1 := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "important meeting", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
msg2 := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "casual chat", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg1).Error)
require.NoError(t, db.Create(msg2).Error)
// ILIKE不兼容SQLite,在SQLite下搜索会返回SQL语法错误
// 这属于Repo层SQL兼容性问题,Service层本身只做委托调用
results, total, err := svc.Search(ctx, account.ID, "meeting", 0, 10, search.SearchModeILike)
if err != nil {
// SQLite环境:ILIKE语法不被支持,Search返回错误属于预期行为
t.Logf("Search在SQLite下返回错误(ILIKE不兼容): %v", err)
} else {
// PostgreSQL环境:正常搜索
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
if len(results) > 0 {
assert.Equal(t, "important meeting", results[0].Content)
}
}
}
// ========== Create 测试 ==========
func TestMessageService_Create(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
user := createTestUser(t, db, account.ID)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
// 正常路径:创建消息
req := CreateMessageRequest{
ConversationID: conv.ID,
Content: "测试消息内容",
MessageType: "outgoing",
ContentType: "text",
}
created, err := svc.Create(ctx, account.ID, user.ID, req)
assert.NoError(t, err)
assert.Equal(t, conv.ID, created.ConversationID)
assert.Equal(t, account.ID, created.AccountID)
assert.Equal(t, "测试消息内容", created.Content)
assert.Equal(t, "outgoing", created.MessageType)
assert.Equal(t, "text", created.ContentType)
assert.Equal(t, user.ID, *created.SenderID)
assert.Equal(t, "user", created.SenderType)
assert.False(t, created.Private)
// 正常路径:默认ContentType为text
reqNoContentType := CreateMessageRequest{
ConversationID: conv.ID,
Content: "无ContentType",
MessageType: "outgoing",
}
created2, err2 := svc.Create(ctx, account.ID, user.ID, reqNoContentType)
assert.NoError(t, err2)
assert.Equal(t, "text", created2.ContentType)
// Chatwoot parity: incoming messages are only allowed in API inboxes
reqIncomingNonAPI := CreateMessageRequest{
ConversationID: conv.ID,
Content: "非 API 收件箱 incoming",
MessageType: "incoming",
}
_, errIncomingNonAPI := svc.Create(ctx, account.ID, user.ID, reqIncomingNonAPI)
assert.EqualError(t, errIncomingNonAPI, "Incoming messages are only allowed in Api inboxes")
apiInbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeAPI))
apiConv := createTestConversation(t, db, account.ID, apiInbox.ID, contact.ID)
reqIncomingAPI := CreateMessageRequest{
ConversationID: apiConv.ID,
Content: "API incoming",
MessageType: "incoming",
}
createdIncomingAPI, errIncomingAPI := svc.Create(ctx, account.ID, user.ID, reqIncomingAPI)
assert.NoError(t, errIncomingAPI)
assert.Equal(t, "incoming", createdIncomingAPI.MessageType)
// Chatwoot parity: campaign_id and template_params are stored in additional_attributes
templateParams := datatypes.JSON([]byte(`{"name":"customer_name","processed_params":{"body":"Hi"}}`))
reqWithAdditionalAttributes := CreateMessageRequest{
ConversationID: conv.ID,
Content: "带模板参数",
MessageType: "outgoing",
CampaignID: float64(42),
TemplateParams: templateParams,
}
createdWithAdditionalAttributes, errWithAdditionalAttributes := svc.Create(ctx, account.ID, user.ID, reqWithAdditionalAttributes)
assert.NoError(t, errWithAdditionalAttributes)
var additionalAttributes map[string]any
require.NoError(t, json.Unmarshal(createdWithAdditionalAttributes.AdditionalAttributes, &additionalAttributes))
assert.Equal(t, float64(42), additionalAttributes["campaign_id"])
assert.Equal(t, "customer_name", additionalAttributes["template_params"].(map[string]any)["name"])
// Chatwoot parity: email inbox recipient params are stored in content_attributes
emailInbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeEmail))
emailConv := createTestConversation(t, db, account.ID, emailInbox.ID, contact.ID)
reqEmailRecipients := CreateMessageRequest{
ConversationID: emailConv.ID,
Content: "Email reply",
MessageType: "outgoing",
EmailHTMLContent: "<p>Email reply</p>",
ContentAttributes: datatypes.JSON([]byte(`{"email":{"html_content":{"reply":"<p>Hello</p>"}}}`)),
CCEmails: "agent_cc1@example.com, agent_cc2@example.com",
BCCEmails: "agent_bcc@example.com",
ToEmails: "customer@example.com",
}
createdEmailMessage, errEmailMessage := svc.Create(ctx, account.ID, user.ID, reqEmailRecipients)
assert.NoError(t, errEmailMessage)
var emailContentAttributes map[string]any
require.NoError(t, json.Unmarshal(createdEmailMessage.ContentAttributes, &emailContentAttributes))
assert.Equal(t, []any{"agent_cc1@example.com", "agent_cc2@example.com"}, emailContentAttributes["cc_emails"])
assert.Equal(t, []any{"agent_bcc@example.com"}, emailContentAttributes["bcc_emails"])
assert.Equal(t, []any{"customer@example.com"}, emailContentAttributes["to_emails"])
emailAttrs := emailContentAttributes["email"].(map[string]any)
assert.Equal(t, "Email reply", emailAttrs["text_content"].(map[string]any)["reply"])
assert.Equal(t, "<p>Email reply</p>", emailAttrs["html_content"].(map[string]any)["reply"])
// Chatwoot parity: stringified content_attributes are parsed as JSON objects
encodedAttrs, err := json.Marshal(`{"automation_rule_id":123,"items":[{"title":"Plan","value":"pro"}]}`)
require.NoError(t, err)
reqStringContentAttributes := CreateMessageRequest{
ConversationID: conv.ID,
Content: "String attrs",
MessageType: "outgoing",
ExternalCreatedAt: "2026-06-09 10:30:00 UTC",
ContentAttributes: datatypes.JSON(encodedAttrs),
}
createdStringAttrsMessage, errStringAttrsMessage := svc.Create(ctx, account.ID, user.ID, reqStringContentAttributes)
assert.NoError(t, errStringAttrsMessage)
var stringContentAttributes map[string]any
require.NoError(t, json.Unmarshal(createdStringAttrsMessage.ContentAttributes, &stringContentAttributes))
assert.Equal(t, float64(123), stringContentAttributes["automation_rule_id"])
assert.Equal(t, "2026-06-09 10:30:00 UTC", stringContentAttributes["external_created_at"])
require.Len(t, stringContentAttributes["items"], 1)
// Chatwoot parity: in_reply_to is normalized to a message in the same conversation
quotedSource := "provider-reply-1"
quotedMessage := &model.Message{
ConversationID: conv.ID,
AccountID: account.ID,
InboxID: inbox.ID,
Content: "Quoted",
MessageType: "incoming",
ContentType: "text",
SenderType: "contact",
SourceID: quotedSource,
}
require.NoError(t, db.Create(quotedMessage).Error)
reqReplyContentAttributes := CreateMessageRequest{
ConversationID: conv.ID,
Content: "Reply",
MessageType: "outgoing",
ContentAttributes: datatypes.JSON([]byte(fmt.Sprintf(`{"in_reply_to":%d,"items":[{"title":"Plan"}]}`, quotedMessage.ID))),
}
createdReplyMessage, errReplyMessage := svc.Create(ctx, account.ID, user.ID, reqReplyContentAttributes)
assert.NoError(t, errReplyMessage)
var replyContentAttributes map[string]any
require.NoError(t, json.Unmarshal(createdReplyMessage.ContentAttributes, &replyContentAttributes))
assert.Equal(t, float64(quotedMessage.ID), replyContentAttributes["in_reply_to"])
assert.Equal(t, quotedSource, replyContentAttributes["in_reply_to_external_id"])
require.Len(t, replyContentAttributes["items"], 1)
otherConv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
reqCrossConversationReply := CreateMessageRequest{
ConversationID: otherConv.ID,
Content: "Cross reply",
MessageType: "outgoing",
ContentAttributes: datatypes.JSON([]byte(fmt.Sprintf(`{"in_reply_to":%d}`, quotedMessage.ID))),
}
createdCrossReplyMessage, errCrossReplyMessage := svc.Create(ctx, account.ID, user.ID, reqCrossConversationReply)
assert.NoError(t, errCrossReplyMessage)
var crossReplyContentAttributes map[string]any
require.NoError(t, json.Unmarshal(createdCrossReplyMessage.ContentAttributes, &crossReplyContentAttributes))
assert.Nil(t, crossReplyContentAttributes["in_reply_to"])
assert.Nil(t, crossReplyContentAttributes["in_reply_to_external_id"])
// Chatwoot parity: structured bot content types can be created without text content
for _, contentType := range []string{"input_select", "cards"} {
createdStructuredMessage, errStructuredMessage := svc.Create(ctx, account.ID, user.ID, CreateMessageRequest{
ConversationID: conv.ID,
MessageType: "outgoing",
ContentType: contentType,
ContentAttributes: datatypes.JSON([]byte(`{"items":[{"title":"Plan","value":"pro"}]}`)),
})
assert.NoError(t, errStructuredMessage)
assert.Equal(t, contentType, createdStructuredMessage.ContentType)
assert.Empty(t, createdStructuredMessage.Content)
}
// Chatwoot parity: sender_type=AgentBot uses an accessible agent bot as sender
require.NoError(t, db.AutoMigrate(&model.AgentBot{}))
agentBot := &model.AgentBot{AccountID: &account.ID, Name: "Reply Bot", BotType: "webhook"}
require.NoError(t, db.Create(agentBot).Error)
reqAgentBotSender := CreateMessageRequest{
ConversationID: conv.ID,
Content: "Agent bot reply",
MessageType: "outgoing",
SenderType: string(model.SenderTypeAgentBot),
SenderID: agentBot.ID,
}
createdAgentBotMessage, errAgentBotMessage := svc.Create(ctx, account.ID, user.ID, reqAgentBotSender)
assert.NoError(t, errAgentBotMessage)
assert.Equal(t, string(model.SenderTypeAgentBot), createdAgentBotMessage.SenderType)
assert.Equal(t, agentBot.ID, *createdAgentBotMessage.SenderID)
reqMissingAgentBotSender := CreateMessageRequest{
ConversationID: conv.ID,
Content: "Fallback reply",
MessageType: "outgoing",
SenderType: string(model.SenderTypeAgentBot),
SenderID: agentBot.ID + 999,
}
createdFallbackMessage, errFallbackMessage := svc.Create(ctx, account.ID, user.ID, reqMissingAgentBotSender)
assert.NoError(t, errFallbackMessage)
assert.Equal(t, "user", createdFallbackMessage.SenderType)
assert.Equal(t, user.ID, *createdFallbackMessage.SenderID)
// Chatwoot parity: is_voice_message tags only audio attachments
reqVoiceMessage := CreateMessageRequest{
ConversationID: conv.ID,
Content: "voice note",
MessageType: "outgoing",
IsVoiceMessage: true,
Attachments: []MessageAttachmentInput{
{FileName: "voice.mp3", FileSize: 1234, ContentType: "audio/mpeg"},
{FileName: "transcript.txt", FileSize: 12, ContentType: "text/plain"},
},
}
createdVoiceMessage, errVoiceMessage := svc.Create(ctx, account.ID, user.ID, reqVoiceMessage)
assert.NoError(t, errVoiceMessage)
var voiceAttachments []model.Attachment
require.NoError(t, db.Where("message_id = ?", createdVoiceMessage.ID).Order("id ASC").Find(&voiceAttachments).Error)
require.Len(t, voiceAttachments, 2)
assert.Equal(t, `{"is_voice_message":true}`, voiceAttachments[0].Metadata)
assert.Empty(t, voiceAttachments[1].Metadata)
// 正常路径:创建私密消息
reqPrivate := CreateMessageRequest{
ConversationID: conv.ID,
Content: "私密消息",
MessageType: "private_note",
Private: true,
}
created3, err3 := svc.Create(ctx, account.ID, user.ID, reqPrivate)
assert.NoError(t, err3)
assert.True(t, created3.Private)
// 错误路径:验证失败 - 空Content
reqEmpty := CreateMessageRequest{
ConversationID: conv.ID,
Content: "",
MessageType: "outgoing",
}
_, err4 := svc.Create(ctx, account.ID, user.ID, reqEmpty)
assert.Error(t, err4)
// 错误路径:验证失败 - 无效MessageType
reqInvalid := CreateMessageRequest{
ConversationID: conv.ID,
Content: "内容",
MessageType: "invalid_type",
}
_, err5 := svc.Create(ctx, account.ID, user.ID, reqInvalid)
assert.Error(t, err5)
// 错误路径:验证失败 - 无效ContentType
reqInvalidCT := CreateMessageRequest{
ConversationID: conv.ID,
Content: "内容",
MessageType: "outgoing",
ContentType: "invalid_ct",
}
_, err6 := svc.Create(ctx, account.ID, user.ID, reqInvalidCT)
assert.Error(t, err6)
// 错误路径:验证失败 - 缺少ConversationID
reqNoConvID := CreateMessageRequest{
Content: "内容",
MessageType: "outgoing",
}
_, err7 := svc.Create(ctx, account.ID, user.ID, reqNoConvID)
assert.Error(t, err7)
}
func TestMessageService_ConversationScopedMessageActions(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
user := createTestUser(t, db, account.ID)
inbox := createTestInbox(t, db, account.ID, "api")
contact := createTestContact(t, db, account.ID)
conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
otherConversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
message := &model.Message{
ConversationID: conversation.ID,
AccountID: account.ID,
InboxID: inbox.ID,
SenderID: &user.ID,
SenderType: "user",
Content: "route scoped",
MessageType: "outgoing",
ContentType: "text",
Status: "failed",
}
require.NoError(t, db.Create(message).Error)
found, err := svc.GetByAccountConversationAndID(ctx, account.ID, conversation.ID, message.ID)
require.NoError(t, err)
assert.Equal(t, message.ID, found.ID)
_, err = svc.GetByAccountConversationAndID(ctx, account.ID, otherConversation.ID, message.ID)
assert.Error(t, err)
_, err = svc.UpdateInConversation(ctx, account.ID, otherConversation.ID, message.ID, UpdateMessageRequest{Status: "delivered"})
assert.Error(t, err)
require.NoError(t, db.First(message, message.ID).Error)
assert.Equal(t, "route scoped", message.Content)
assert.Equal(t, "failed", message.Status)
updated, err := svc.UpdateInConversation(ctx, account.ID, conversation.ID, message.ID, UpdateMessageRequest{Status: "delivered"})
require.NoError(t, err)
assert.Equal(t, "route scoped", updated.Content)
assert.Equal(t, "delivered", updated.Status)
require.NoError(t, db.Model(message).Update("status", "failed").Error)
_, err = svc.RetryInConversation(ctx, account.ID, otherConversation.ID, message.ID)
assert.Error(t, err)
require.NoError(t, db.First(message, message.ID).Error)
assert.Equal(t, "failed", message.Status)
retried, err := svc.RetryInConversation(ctx, account.ID, conversation.ID, message.ID)
require.NoError(t, err)
assert.Equal(t, "sent", retried.Status)
_, err = svc.DeleteInConversation(ctx, account.ID, otherConversation.ID, message.ID)
assert.Error(t, err)
require.NoError(t, db.First(message, message.ID).Error)
assert.NotContains(t, string(message.ContentAttributes), "deleted")
deleted, err := svc.DeleteInConversation(ctx, account.ID, conversation.ID, message.ID)
require.NoError(t, err)
assert.Equal(t, "This message was deleted", deleted.Content)
assert.JSONEq(t, `{"deleted":true}`, string(deleted.ContentAttributes))
}
// ========== Update 测试 ==========
func TestMessageService_Update(t *testing.T) {
db, _, dispatcher, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "api")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "原始内容", MessageType: "outgoing", ContentType: "text", SenderType: "user", Status: "sent",
}
require.NoError(t, db.Create(msg).Error)
listener := &mockRetryListener{}
dispatcher.Register(listener)
req := UpdateMessageRequest{Status: "failed", ExternalError: "provider rejected message"}
updated, err := svc.Update(ctx, account.ID, msg.ID, req)
assert.NoError(t, err)
assert.Equal(t, "failed", updated.Status)
assert.Equal(t, "原始内容", updated.Content)
assert.JSONEq(t, `{"external_error":"provider rejected message"}`, string(updated.ContentAttributes))
assert.True(t, listener.received)
assert.Equal(t, channel.EventMessageUpdated, listener.lastEventType)
assert.Equal(t, account.ID, listener.lastAccountID)
assert.Equal(t, inbox.ID, listener.lastInboxID)
assert.Equal(t, conv.ID, listener.lastConvID)
eventMessage, ok := listener.lastData["message"].(*model.Message)
require.True(t, ok)
assert.Equal(t, msg.ID, eventMessage.ID)
assert.Equal(t, "failed", eventMessage.Status)
assert.JSONEq(t, `{"external_error":"provider rejected message"}`, string(eventMessage.ContentAttributes))
reqEmpty := UpdateMessageRequest{}
updated2, err2 := svc.Update(ctx, account.ID, msg.ID, reqEmpty)
assert.NoError(t, err2)
assert.Equal(t, "failed", updated2.Status)
assert.Equal(t, "原始内容", updated2.Content)
_, err = svc.Update(ctx, account.ID, msg.ID, UpdateMessageRequest{Status: "invalid"})
assert.Error(t, err)
webInbox := createTestInbox(t, db, account.ID, "web_widget")
webConv := createTestConversation(t, db, account.ID, webInbox.ID, contact.ID)
webMessage := &model.Message{
ConversationID: webConv.ID, AccountID: account.ID, InboxID: webInbox.ID,
Content: "web", MessageType: "outgoing", ContentType: "text", SenderType: "user", Status: "sent",
}
require.NoError(t, db.Create(webMessage).Error)
_, err = svc.Update(ctx, account.ID, webMessage.ID, UpdateMessageRequest{Status: "delivered"})
assert.Error(t, err)
// 错误路径:accountID不匹配
_, err = svc.Update(ctx, 9999, msg.ID, req)
assert.Error(t, err)
// 错误路径:消息ID不存在
_, err = svc.Update(ctx, account.ID, 9999, req)
assert.Error(t, err)
}
// ========== Delete 测试 ==========
func TestMessageService_Delete(t *testing.T) {
db, _, dispatcher, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "待删除消息", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg).Error)
attachment := &model.Attachment{MessageID: msg.ID, AccountID: account.ID, FileType: "file", FileName: "delete.txt"}
require.NoError(t, db.Create(attachment).Error)
listener := &mockRetryListener{}
dispatcher.Register(listener)
// 正常路径:Chatwoot 删除会保留消息并标记 content_attributes.deleted
deleted, err := svc.Delete(ctx, account.ID, msg.ID)
assert.NoError(t, err)
assert.Equal(t, "This message was deleted", deleted.Content)
assert.True(t, listener.received)
assert.Equal(t, channel.EventMessageDeleted, listener.lastEventType)
assert.Equal(t, account.ID, listener.lastAccountID)
assert.Equal(t, inbox.ID, listener.lastInboxID)
assert.Equal(t, conv.ID, listener.lastConvID)
eventMessage, ok := listener.lastData["message"].(*model.Message)
require.True(t, ok)
assert.Equal(t, msg.ID, eventMessage.ID)
assert.Equal(t, "This message was deleted", eventMessage.Content)
assert.JSONEq(t, `{"deleted":true}`, string(eventMessage.ContentAttributes))
stored, err := svc.GetByAccountAndID(ctx, account.ID, msg.ID)
assert.NoError(t, err)
assert.JSONEq(t, `{"deleted":true}`, string(stored.ContentAttributes))
var attachmentCount int64
require.NoError(t, db.Model(&model.Attachment{}).Where("message_id = ?", msg.ID).Count(&attachmentCount).Error)
assert.Equal(t, int64(0), attachmentCount)
// 错误路径:accountID不匹配
msg2 := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "另一条消息", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg2).Error)
_, err = svc.Delete(ctx, 9999, msg2.ID)
assert.Error(t, err)
// 错误路径:ID不存在
_, err = svc.Delete(ctx, account.ID, 9999)
assert.Error(t, err)
}
// ========== CountByConversation 测试 ==========
func TestMessageService_CountByConversation(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
ctx := context.Background()
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
// 创建3条消息
for i := 0; i < 3; i++ {
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: fmt.Sprintf("消息%c", 'A'+i),
MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg).Error)
}
// 正常路径:统计会话消息数量
count, err := svc.CountByConversation(ctx, conv.ID)
assert.NoError(t, err)
assert.Equal(t, int64(3), count)
// 正常路径:空会话
count2, err2 := svc.CountByConversation(ctx, 9999)
assert.NoError(t, err2)
assert.Equal(t, int64(0), count2)
}
// ========== Translate 测试 ==========
func TestMessageService_Translate(t *testing.T) {
ctx := context.Background()
t.Run("success", func(t *testing.T) {
mockLLM := &mockMessageLLMProvider{
chatResponse: &llm.ChatResponse{
Choices: []llm.ChatChoice{
{Message: llm.ChatMessage{Role: "assistant", Content: "Hello"}},
},
},
}
db, _, _, svc := setupMessageService(t, mockLLM)
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "你好", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg).Error)
result, err := svc.Translate(ctx, account.ID, msg.ID, TranslateMessageRequest{TargetLanguage: "en"})
assert.NoError(t, err)
assert.Equal(t, msg.ID, result.ID)
assert.Equal(t, "你好", result.OriginalContent)
assert.Equal(t, "Hello", result.TranslatedContent)
assert.Equal(t, "en", result.TargetLanguage)
assert.Equal(t, 1, mockLLM.chatCalls)
resultCached, errCached := svc.Translate(ctx, account.ID, msg.ID, TranslateMessageRequest{TargetLanguage: "en"})
assert.NoError(t, errCached)
assert.Equal(t, "Hello", resultCached.TranslatedContent)
assert.Equal(t, 1, mockLLM.chatCalls)
})
t.Run("empty_choices", func(t *testing.T) {
mockLLM := &mockMessageLLMProvider{
chatResponse: &llm.ChatResponse{
Choices: []llm.ChatChoice{},
},
}
db, _, _, svc := setupMessageService(t, mockLLM)
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "你好", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg).Error)
result, err := svc.Translate(ctx, account.ID, msg.ID, TranslateMessageRequest{TargetLanguage: "en"})
assert.NoError(t, err)
assert.Equal(t, "", result.TranslatedContent)
})
t.Run("llm_error", func(t *testing.T) {
mockLLM := &mockMessageLLMProvider{
chatErr: fmt.Errorf("LLM service unavailable"),
}
db, _, _, svc := setupMessageService(t, mockLLM)
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "你好", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg).Error)
_, err := svc.Translate(ctx, account.ID, msg.ID, TranslateMessageRequest{TargetLanguage: "en"})
assert.Error(t, err)
})
t.Run("message_not_found", func(t *testing.T) {
mockLLM := &mockMessageLLMProvider{
chatResponse: &llm.ChatResponse{
Choices: []llm.ChatChoice{
{Message: llm.ChatMessage{Role: "assistant", Content: "translated"}},
},
},
}
db, _, _, svc := setupMessageService(t, mockLLM)
account := createTestAccount(t, db)
_, err := svc.Translate(ctx, account.ID, 9999, TranslateMessageRequest{TargetLanguage: "en"})
assert.Error(t, err)
})
t.Run("validation_empty_target_language", func(t *testing.T) {
mockLLM := &mockMessageLLMProvider{}
db, _, _, svc := setupMessageService(t, mockLLM)
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "你好", MessageType: "incoming", ContentType: "text", SenderType: "contact",
}
require.NoError(t, db.Create(msg).Error)
_, err := svc.Translate(ctx, account.ID, msg.ID, TranslateMessageRequest{TargetLanguage: ""})
assert.Error(t, err)
})
}
// ========== Retry 测试 ==========
func TestMessageService_Retry(t *testing.T) {
ctx := context.Background()
t.Run("success", func(t *testing.T) {
db, _, dispatcher, svc := setupMessageServiceWithDefaultLLM(t)
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "failed message", MessageType: "outgoing", ContentType: "text", SenderType: "user",
Status: "failed", ContentAttributes: datatypes.JSON([]byte(`{"external_error":"provider failed"}`)),
}
require.NoError(t, db.Create(msg).Error)
// Register a listener to verify dispatch
listener := &mockRetryListener{}
dispatcher.Register(listener)
retried, err := svc.Retry(ctx, account.ID, msg.ID)
assert.NoError(t, err)
assert.Equal(t, "sent", retried.Status)
assert.JSONEq(t, `{}`, string(retried.ContentAttributes))
assert.True(t, listener.received)
assert.Equal(t, channel.EventMessageStatusUpdated, listener.lastEventType)
assert.Equal(t, account.ID, listener.lastAccountID)
assert.Equal(t, inbox.ID, listener.lastInboxID)
assert.Equal(t, conv.ID, listener.lastConvID)
assert.Equal(t, "sent", listener.lastData["status"])
assert.Equal(t, msg.ID, listener.lastData["message_id"])
var stored model.Message
require.NoError(t, db.First(&stored, msg.ID).Error)
assert.JSONEq(t, `{}`, string(stored.ContentAttributes))
})
t.Run("queues_send_reply_when_worker_configured", func(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
wp := worker.NewWorkerPool(db)
svc.SetWorkerPool(wp)
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "failed message", MessageType: "outgoing", ContentType: "text", SenderType: "user",
Status: "failed", ContentAttributes: datatypes.JSON([]byte(`{"external_error":"provider failed"}`)),
}
require.NoError(t, db.Create(msg).Error)
retried, err := svc.Retry(ctx, account.ID, msg.ID)
require.NoError(t, err)
assert.Equal(t, "sent", retried.Status)
var job model.BackgroundJob
require.NoError(t, db.Where("job_type = ?", TaskTypeMessageSendReply).First(&job).Error)
assert.Equal(t, model.BackgroundJobStatusQueued, job.Status)
assert.Equal(t, "message:send_reply", job.JobType)
assert.JSONEq(t, fmt.Sprintf(`{"message_id":%d}`, msg.ID), string(job.Payload))
})
t.Run("message_not_found", func(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
account := createTestAccount(t, db)
_, err := svc.Retry(ctx, account.ID, 9999)
assert.Error(t, err)
})
t.Run("account_mismatch", func(t *testing.T) {
db, _, _, svc := setupMessageServiceWithDefaultLLM(t)
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
msg := &model.Message{
ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID,
Content: "test", MessageType: "outgoing", ContentType: "text", SenderType: "user",
Status: "failed",
}
require.NoError(t, db.Create(msg).Error)
_, err := svc.Retry(ctx, 9999, msg.ID)
assert.Error(t, err)
})
}