Files
ai-agent/internal/services/customer_quick_action_service_test.go
T
t 18c9354095 refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
2026-08-28 22:23:13 +08:00

299 lines
12 KiB
Go

package services
import (
"context"
"testing"
"code.tczkiot.com/wlw/ai-agent/contract"
"code.tczkiot.com/wlw/ai-agent/identity"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
)
func TestCustomerQuickActionsResolveKnownCardWithoutChangingGuestOwnership(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
external := welcomeTestExternalUser("card:50506783")
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create conversation: %v", err)
}
originalCustomerType := conversation.CustomerType
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
if len(query.Types) == 1 && query.Types[0] == identity.SubjectCard && query.Keyword == "50506783" {
return []identity.Subject{{
Type: identity.SubjectCard, Category: identity.CategoryUser, ID: 17443,
Username: "50506783", Identifier: "898608691025D4186783", Name: "卡号 50506783", Enabled: true,
}}, nil
}
return nil, nil
})
t.Cleanup(func() { SetQuerySubjects(nil) })
var executedContext contract.BusinessReadContext
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{
{
Code: "card/traffic", Title: "查流量", Message: "请查询流量", CustomerTypes: []string{"card"},
Execute: func(_ context.Context, businessContext contract.BusinessReadContext) (string, error) {
executedContext = businessContext
return "剩余流量 30G", nil
},
},
{
Code: "device/status", Title: "查设备", Message: "请查询设备", CustomerTypes: []string{"device"},
Execute: func(context.Context, contract.BusinessReadContext) (string, error) { return "设备正常", nil },
},
}); err != nil {
t.Fatalf("SetCustomerQuickActions() error = %v", err)
}
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
actions, err := CustomerQuickActionService.ListForConversation(context.Background(), conversation)
if err != nil || len(actions) != 1 || actions[0].Code != "card/traffic" {
t.Fatalf("known card actions = %#v, err = %v", actions, err)
}
if conversation.CustomerType != originalCustomerType || !ConversationService.IsCustomerConversationOwner(conversation, external) {
t.Fatalf("quick-action resolution changed guest ownership: %#v", conversation)
}
if _, _, err := CustomerQuickActionService.ExecuteAndRecord(
context.Background(), conversation.ID, "card/traffic", "known-card-1", external, "known-card-request-1",
); err != nil {
t.Fatalf("ExecuteAndRecord() error = %v", err)
}
if executedContext.CustomerType != "card" || executedContext.CustomerID != 17443 || executedContext.CustomerExternalID != "50506783" {
t.Fatalf("unexpected business context: %#v", executedContext)
}
}
func TestCustomerQuickActionsResolveDeviceFromConversationHistory(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
external := welcomeTestExternalUser("quick-device-history")
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create conversation: %v", err)
}
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
if len(query.Types) == 1 && query.Types[0] == identity.SubjectDevice && query.Keyword == "37012627000987" {
return []identity.Subject{{
Type: identity.SubjectDevice, Category: identity.CategoryUser, ID: 27,
Username: "37012627000987", Identifier: "37012627000987", Name: "设备号 37012627000987", Enabled: true,
}}, nil
}
return nil, nil
})
t.Cleanup(func() { SetQuerySubjects(nil) })
if _, err := MessageService.SendCustomerMessageWithoutAIReplyWithRequestID(
conversation.ID, "known-device-message", enums.IMMessageTypeHTML,
"<p>设备号 37012627000987</p>", "", external, "known-device-request",
); err != nil {
t.Fatalf("send identity message: %v", err)
}
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
Code: "device/wifi", Title: "WiFi 信息", Message: "查询 WiFi", CustomerTypes: []string{"device"},
Execute: func(context.Context, contract.BusinessReadContext) (string, error) { return "WiFi 正常", nil },
}}); err != nil {
t.Fatalf("SetCustomerQuickActions() error = %v", err)
}
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
actions, err := CustomerQuickActionService.ListForConversation(context.Background(), conversation)
if err != nil || len(actions) != 1 || actions[0].Code != "device/wifi" {
t.Fatalf("known device actions = %#v, err = %v", actions, err)
}
}
func TestCustomerQuickActionRecordsReplyWithoutTriggeringAI(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
external := welcomeTestExternalUser("quick-action-user")
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create conversation: %v", err)
}
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
Code: "test/status",
Title: "查状态",
Message: "请查询状态",
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
return "当前状态正常", nil
},
}}); err != nil {
t.Fatalf("SetCustomerQuickActions() error = %v", err)
}
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
previousHook := TriggerAIReplyAsyncHook
called := false
TriggerAIReplyAsyncHook = func(context.Context, models.Conversation, models.Message) { called = true }
t.Cleanup(func() { TriggerAIReplyAsyncHook = previousHook })
customerMessage, replyMessage, err := CustomerQuickActionService.ExecuteAndRecord(
context.Background(), conversation.ID, "test/status", "quick-client-1", external, "quick-request-1",
)
if err != nil {
t.Fatalf("ExecuteAndRecord() error = %v", err)
}
if customerMessage.Content != "请查询状态" || customerMessage.SenderType != enums.IMSenderTypeCustomer {
t.Fatalf("unexpected customer message: %#v", customerMessage)
}
if replyMessage.Content != "当前状态正常" || replyMessage.SenderType != enums.IMSenderTypeAI {
t.Fatalf("unexpected automatic reply: %#v", replyMessage)
}
if called {
t.Fatalf("quick action customer message must not trigger an AI reply")
}
var count int64
if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil {
t.Fatalf("count messages: %v", err)
}
if count != 2 {
t.Fatalf("message count = %d, want 2", count)
}
}
func TestCustomerQuickActionAvailabilityIsCheckedForListAndExecution(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
external := welcomeTestExternalUser("quick-action-availability-user")
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create conversation: %v", err)
}
available := false
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
Code: "test/dynamic", Title: "动态操作", Message: "执行动态操作",
Available: func(context.Context, contract.BusinessReadContext) (bool, error) {
return available, nil
},
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
return "执行成功", nil
},
}}); err != nil {
t.Fatalf("SetCustomerQuickActions() error = %v", err)
}
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
actions, err := CustomerQuickActionService.ListForConversation(context.Background(), conversation)
if err != nil || len(actions) != 0 {
t.Fatalf("unavailable action leaked into list: actions=%#v err=%v", actions, err)
}
if _, _, err := CustomerQuickActionService.ExecuteAndRecord(context.Background(), conversation.ID, "test/dynamic", "quick-dynamic-1", external, "quick-dynamic-request-1"); err == nil {
t.Fatal("unavailable action was executed")
}
available = true
actions, err = CustomerQuickActionService.ListForConversation(context.Background(), conversation)
if err != nil || len(actions) != 1 || actions[0].Code != "test/dynamic" {
t.Fatalf("available action missing from list: actions=%#v err=%v", actions, err)
}
}
func TestCustomerQuickActionExecutesMatchedRecordedMessage(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
external := welcomeTestExternalUser("matched-action-user")
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create conversation: %v", err)
}
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
Code: "test/traffic", Title: "查流量", Message: "请查询流量",
MatchIntent: func(message string) bool { return message == "卡号 50506783,请查询流量" },
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
return "剩余流量:58.38G", nil
},
}}); err != nil {
t.Fatalf("SetCustomerQuickActions() error = %v", err)
}
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
message, err := MessageService.SendCustomerMessageWithoutAIReplyWithRequestID(
conversation.ID, "matched-customer-1", enums.IMMessageTypeText,
"卡号 50506783,请查询流量", "", external, "matched-request-1",
)
if err != nil {
t.Fatalf("send customer message: %v", err)
}
matched, err := CustomerQuickActionService.ExecuteMatchedReply(
context.Background(), conversation, message.Content, message.RequestID, message.ID,
)
if err != nil || !matched {
t.Fatalf("ExecuteMatchedReply() matched=%v err=%v", matched, err)
}
list, _, _ := MessageService.FindByConversationIDCursor(conversation.ID, 0, 20, "", "")
if len(list) != 2 || list[1].Content != "剩余流量:58.38G" || list[1].SenderType != enums.IMSenderTypeAI {
t.Fatalf("unexpected messages: %#v", list)
}
}
func TestCustomerQuickActionExecutesSelectedMenuItem(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
external := welcomeTestExternalUser("selected-action-user")
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create conversation: %v", err)
}
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
Code: "test/traffic", Title: "查流量", Message: "请查询流量", Sort: 10,
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
return "剩余流量:58.38G", nil
},
}}); err != nil {
t.Fatalf("SetCustomerQuickActions() error = %v", err)
}
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
matched, aiMessage, err := CustomerQuickActionService.ExecuteSelectedReply(
context.Background(), conversation, 1, "selected-request-1", 100,
)
if err != nil || !matched || aiMessage != "" {
t.Fatalf("ExecuteSelectedReply() matched=%v aiMessage=%q err=%v", matched, aiMessage, err)
}
list, _, _ := MessageService.FindByConversationIDCursor(conversation.ID, 0, 20, "", "")
if len(list) != 1 || list[0].Content != "剩余流量:58.38G" {
t.Fatalf("unexpected selected action messages: %#v", list)
}
}
func TestCustomerQuickActionExecutesActionByCode(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
external := welcomeTestExternalUser("coded-action-user")
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create conversation: %v", err)
}
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{
{
Code: "test/status", Title: "查状态", Message: "请查询状态", Sort: 10,
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
return "当前状态正常", nil
},
},
{
Code: "test/diagnosis", Title: "智能检测", Message: "请智能检测", Sort: 20,
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
return "智能检测结果:网络异常", nil
},
},
}); err != nil {
t.Fatalf("SetCustomerQuickActions() error = %v", err)
}
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
matched, aiMessage, err := CustomerQuickActionService.ExecuteActionReply(
context.Background(), conversation, "test/diagnosis", "coded-request-1", 101,
)
if err != nil || !matched || aiMessage != "" {
t.Fatalf("ExecuteActionReply() matched=%v aiMessage=%q err=%v", matched, aiMessage, err)
}
list, _, _ := MessageService.FindByConversationIDCursor(conversation.ID, 0, 20, "", "")
if len(list) != 1 || list[0].Content != "智能检测结果:网络异常" {
t.Fatalf("unexpected coded action messages: %#v", list)
}
}