Initial qiwei secondary development handoff
This commit is contained in:
675
helper/after_sales_test.go
Normal file
675
helper/after_sales_test.go
Normal file
@@ -0,0 +1,675 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"qiweimanager/config"
|
||||
)
|
||||
|
||||
func TestAfterSalesAtomicJSONReadWrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "issues.json")
|
||||
want := []AfterSalesIssue{{
|
||||
ID: "issue-1",
|
||||
RoomName: "VIP客户售后群-001",
|
||||
CustomerName: "张三",
|
||||
Status: afterSalesIssueStatusPending,
|
||||
}}
|
||||
if err := atomicWriteJSON(path, want); err != nil {
|
||||
t.Fatalf("atomicWriteJSON failed: %v", err)
|
||||
}
|
||||
var got []AfterSalesIssue
|
||||
if err := readJSONFile(path, &got); err != nil {
|
||||
t.Fatalf("readJSONFile failed: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != want[0].ID || got[0].CustomerName != want[0].CustomerName {
|
||||
t.Fatalf("unexpected issues: %#v", got)
|
||||
}
|
||||
|
||||
badPath := filepath.Join(t.TempDir(), "bad.json")
|
||||
if err := os.WriteFile(badPath, []byte("{bad json"), 0644); err != nil {
|
||||
t.Fatalf("write bad json: %v", err)
|
||||
}
|
||||
if err := readJSONFile(badPath, &got); err == nil {
|
||||
t.Fatal("expected bad JSON to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAfterSalesAIResponseFromMarkdownFence(t *testing.T) {
|
||||
text := "```json\n" +
|
||||
`[{"room_name":"售后群","customer_user_id":"wm_1","customer_name":"李四","issue_content":"软件无法登录","image_paths":["C:\\tmp\\a.png",""],"image_refs":["cdn-1"],"ai_suggestion":"建议先检查网络。","source_message_ids":["m1"],"confidence":0.82}]` +
|
||||
"\n```"
|
||||
got, err := parseAfterSalesAIResponse(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAfterSalesAIResponse failed: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 candidate, got %d", len(got))
|
||||
}
|
||||
if got[0].CustomerName != "李四" || got[0].IssueContent != "软件无法登录" {
|
||||
t.Fatalf("unexpected candidate: %#v", got[0])
|
||||
}
|
||||
if len(got[0].ImagePaths) != 1 || got[0].ImagePaths[0] != `C:\tmp\a.png` {
|
||||
t.Fatalf("image paths were not normalized: %#v", got[0].ImagePaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesCollectUsesFirstHourWindowAndAdvancesOnSuccess(t *testing.T) {
|
||||
restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
defer restoreCollector()
|
||||
|
||||
now := time.Now()
|
||||
engine := &AfterSalesIssueEngine{
|
||||
messages: []AfterSalesMessage{
|
||||
{
|
||||
MessageID: "old",
|
||||
ConversationID: "room-1",
|
||||
RoomName: "售后群",
|
||||
SenderUserID: "wm_1",
|
||||
SenderName: "张三",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "两小时前的问题",
|
||||
MessageType: "text",
|
||||
SendTime: now.Add(-2 * time.Hour).Unix(),
|
||||
ReceivedAt: now.Add(-2 * time.Hour).Unix(),
|
||||
},
|
||||
{
|
||||
MessageID: "new",
|
||||
ConversationID: "room-1",
|
||||
RoomName: "售后群",
|
||||
SenderUserID: "wm_1",
|
||||
SenderName: "张三",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "刚才登录报错",
|
||||
MessageType: "text",
|
||||
SendTime: now.Add(-30 * time.Minute).Unix(),
|
||||
ReceivedAt: now.Add(-30 * time.Minute).Unix(),
|
||||
},
|
||||
},
|
||||
}
|
||||
var seen []AfterSalesMessage
|
||||
afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) {
|
||||
seen = append([]AfterSalesMessage(nil), messages...)
|
||||
return []afterSalesAIIssueCandidate{{
|
||||
RoomName: "售后群",
|
||||
CustomerUserID: "wm_1",
|
||||
CustomerName: "张三",
|
||||
IssueContent: "登录报错",
|
||||
AISuggestion: "建议先核对网络和账号状态。",
|
||||
SourceMessageIDs: []string{"new"},
|
||||
Confidence: 0.9,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
engine.collectLockedAsync("", false)
|
||||
|
||||
if engine.state.LastCollectAt == 0 {
|
||||
t.Fatal("expected successful collect to advance LastCollectAt")
|
||||
}
|
||||
if engine.state.LastAddedCount != 1 || len(engine.issues) != 1 {
|
||||
t.Fatalf("expected one added issue, state=%#v issues=%#v", engine.state, engine.issues)
|
||||
}
|
||||
if len(seen) != 1 || seen[0].MessageID != "new" {
|
||||
t.Fatalf("first collection should only analyze the latest hour, saw %#v", seen)
|
||||
}
|
||||
if engine.state.LastError != "" {
|
||||
t.Fatalf("unexpected LastError: %s", engine.state.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesCollectDoesNotAdvanceOnFailure(t *testing.T) {
|
||||
restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
defer restoreCollector()
|
||||
|
||||
now := time.Now()
|
||||
engine := &AfterSalesIssueEngine{
|
||||
messages: []AfterSalesMessage{{
|
||||
MessageID: "m1",
|
||||
ConversationID: "room-1",
|
||||
RoomName: "售后群",
|
||||
SenderUserID: "wm_1",
|
||||
SenderName: "张三",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "登录报错",
|
||||
MessageType: "text",
|
||||
SendTime: now.Add(-10 * time.Minute).Unix(),
|
||||
ReceivedAt: now.Add(-10 * time.Minute).Unix(),
|
||||
}},
|
||||
}
|
||||
afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) {
|
||||
return nil, errors.New("AI failed")
|
||||
}
|
||||
|
||||
engine.collectLockedAsync("", false)
|
||||
|
||||
if engine.state.LastCollectAt != 0 {
|
||||
t.Fatalf("failed collect must not advance LastCollectAt, got %d", engine.state.LastCollectAt)
|
||||
}
|
||||
if engine.state.LastError == "" {
|
||||
t.Fatal("expected LastError after failed collect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesManualCollectScansAllCachedMessages(t *testing.T) {
|
||||
restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
defer restoreCollector()
|
||||
|
||||
now := time.Now()
|
||||
engine := &AfterSalesIssueEngine{
|
||||
state: AfterSalesCollectState{LastCollectAt: now.Add(-10 * time.Minute).Unix()},
|
||||
messages: []AfterSalesMessage{
|
||||
{
|
||||
MessageID: "old-room-1",
|
||||
ConversationID: "room-1",
|
||||
RoomName: "Room 1",
|
||||
SenderUserID: "wm_1",
|
||||
SenderName: "Customer 1",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "old issue",
|
||||
MessageType: "text",
|
||||
SendTime: now.Add(-3 * time.Hour).Unix(),
|
||||
ReceivedAt: now.Add(-3 * time.Hour).Unix(),
|
||||
},
|
||||
{
|
||||
MessageID: "new-room-2",
|
||||
ConversationID: "room-2",
|
||||
RoomName: "Room 2",
|
||||
SenderUserID: "wm_2",
|
||||
SenderName: "Customer 2",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "new issue",
|
||||
MessageType: "text",
|
||||
SendTime: now.Add(-5 * time.Minute).Unix(),
|
||||
ReceivedAt: now.Add(-5 * time.Minute).Unix(),
|
||||
},
|
||||
},
|
||||
}
|
||||
var seen []AfterSalesMessage
|
||||
afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) {
|
||||
seen = append(seen, messages...)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
added, scanned, err := engine.collectNow("", true)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("manual collect failed: %v", err)
|
||||
}
|
||||
if added != 0 || scanned != 2 || len(seen) != 2 {
|
||||
t.Fatalf("expected manual collect to scan all cached messages, added=%d scanned=%d seen=%#v", added, scanned, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesManualCollectFiltersSelectedGroup(t *testing.T) {
|
||||
restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
defer restoreCollector()
|
||||
|
||||
now := time.Now()
|
||||
engine := &AfterSalesIssueEngine{
|
||||
messages: []AfterSalesMessage{
|
||||
{
|
||||
MessageID: "room-1-msg",
|
||||
ConversationID: "room-1",
|
||||
RoomName: "Room 1",
|
||||
SenderUserID: "wm_1",
|
||||
SenderName: "Customer 1",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "room 1 issue",
|
||||
MessageType: "text",
|
||||
SendTime: now.Add(-30 * time.Minute).Unix(),
|
||||
ReceivedAt: now.Add(-30 * time.Minute).Unix(),
|
||||
},
|
||||
{
|
||||
MessageID: "room-2-msg",
|
||||
ConversationID: "room-2",
|
||||
RoomName: "Room 2",
|
||||
SenderUserID: "wm_2",
|
||||
SenderName: "Customer 2",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "room 2 issue",
|
||||
MessageType: "text",
|
||||
SendTime: now.Add(-20 * time.Minute).Unix(),
|
||||
ReceivedAt: now.Add(-20 * time.Minute).Unix(),
|
||||
},
|
||||
},
|
||||
}
|
||||
var seen []AfterSalesMessage
|
||||
afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) {
|
||||
seen = append([]AfterSalesMessage(nil), messages...)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
_, scanned, err := engine.collectNow("room-2", true)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("selected manual collect failed: %v", err)
|
||||
}
|
||||
if scanned != 1 || len(seen) != 1 || seen[0].MessageID != "room-2-msg" {
|
||||
t.Fatalf("expected only selected group message, scanned=%d seen=%#v", scanned, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesManualCollectDoesNotAdvanceAutoCursor(t *testing.T) {
|
||||
restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
defer restoreCollector()
|
||||
|
||||
now := time.Now()
|
||||
lastCollectAt := now.Add(-15 * time.Minute).Unix()
|
||||
engine := &AfterSalesIssueEngine{
|
||||
state: AfterSalesCollectState{Collecting: true, LastCollectAt: lastCollectAt},
|
||||
messages: []AfterSalesMessage{{
|
||||
MessageID: "m1",
|
||||
ConversationID: "room-1",
|
||||
RoomName: "Room 1",
|
||||
SenderUserID: "wm_1",
|
||||
SenderName: "Customer",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "old cached issue",
|
||||
MessageType: "text",
|
||||
SendTime: now.Add(-2 * time.Hour).Unix(),
|
||||
ReceivedAt: now.Add(-2 * time.Hour).Unix(),
|
||||
}},
|
||||
}
|
||||
afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
engine.collectLockedAsync("", true)
|
||||
|
||||
if engine.state.LastCollectAt != lastCollectAt {
|
||||
t.Fatalf("manual collect should not advance automatic cursor, got %d want %d", engine.state.LastCollectAt, lastCollectAt)
|
||||
}
|
||||
if engine.state.Collecting {
|
||||
t.Fatal("manual collect should clear collecting flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesManualCollectSelectedGroupNoCachedMessages(t *testing.T) {
|
||||
engine := &AfterSalesIssueEngine{}
|
||||
|
||||
added, scanned, err := engine.collectNow("room-missing", true)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("empty selected collect failed: %v", err)
|
||||
}
|
||||
if added != 0 || scanned != 0 {
|
||||
t.Fatalf("expected no additions for empty selected group, added=%d scanned=%d", added, scanned)
|
||||
}
|
||||
if msg := afterSalesCollectEmptyMessage("room-missing", true, scanned); msg == "" {
|
||||
t.Fatal("expected empty selected group message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesHistoryImportParsesCopiedText(t *testing.T) {
|
||||
base := time.Date(2026, 5, 28, 15, 30, 0, 0, time.Local)
|
||||
raw := "郑悦滨 2026-05-28 15:13 那玩意儿没电了,存不住数据。\n你量一下电压,肯定低于3V了。\n梁锦明 15:14 郑工\n郑悦滨:梁师傅好"
|
||||
|
||||
messages := parseAfterSalesHistoryMessages("R:room-1", "设备售后问题交流群", raw, base)
|
||||
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("expected 3 parsed messages, got %d: %#v", len(messages), messages)
|
||||
}
|
||||
if messages[0].SenderName != "郑悦滨" || !strings.Contains(messages[0].Content, "存不住数据") || !strings.Contains(messages[0].Content, "低于3V") {
|
||||
t.Fatalf("unexpected first message: %#v", messages[0])
|
||||
}
|
||||
if messages[2].SenderName != "郑悦滨" || messages[2].Content != "梁师傅好" {
|
||||
t.Fatalf("colon format was not parsed: %#v", messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesHistoryImportDedupesMessages(t *testing.T) {
|
||||
engine := &AfterSalesIssueEngine{}
|
||||
raw := "Customer " + time.Now().Format("2006-01-02") + " 15:13 device broken"
|
||||
messages := parseAfterSalesHistoryMessages("room-1", "Room 1", raw, time.Now())
|
||||
|
||||
first := engine.mergeHistoryMessages(messages)
|
||||
second := engine.mergeHistoryMessages(messages)
|
||||
|
||||
if first != 1 || second != 0 {
|
||||
t.Fatalf("expected first import only, first=%d second=%d messages=%#v", first, second, engine.messages)
|
||||
}
|
||||
if engine.state.MessageBufferCount != 1 {
|
||||
t.Fatalf("expected message buffer count 1, got %d", engine.state.MessageBufferCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesHistoryImportCollectsIssue(t *testing.T) {
|
||||
restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
defer restoreCollector()
|
||||
|
||||
engine := &AfterSalesIssueEngine{}
|
||||
afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) {
|
||||
if len(messages) != 1 || messages[0].ConversationID != "room-1" {
|
||||
t.Fatalf("unexpected imported messages sent to collector: %#v", messages)
|
||||
}
|
||||
return []afterSalesAIIssueCandidate{{
|
||||
CustomerUserID: messages[0].SenderUserID,
|
||||
CustomerName: messages[0].SenderName,
|
||||
IssueContent: "device cannot store data",
|
||||
SourceMessageIDs: []string{messages[0].MessageID},
|
||||
Confidence: 0.9,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
imported, added, err := engine.importHistoryAndCollect(AfterSalesHistoryImportRequest{
|
||||
ConversationID: "room-1",
|
||||
RoomName: "Room 1",
|
||||
RawText: "Customer 2026-05-28 15:13 device cannot store data",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("history import failed: %v", err)
|
||||
}
|
||||
if imported != 1 || added != 1 || len(engine.issues) != 1 {
|
||||
t.Fatalf("expected imported issue, imported=%d added=%d issues=%#v", imported, added, engine.issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesHistoryImportSegmentsMultipleIssues(t *testing.T) {
|
||||
restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
defer restoreCollector()
|
||||
|
||||
engine := &AfterSalesIssueEngine{}
|
||||
calls := 0
|
||||
afterSalesAICollector = func(aiCfg config.AIConfig, messages []AfterSalesMessage) ([]afterSalesAIIssueCandidate, error) {
|
||||
calls++
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected one imported issue segment per AI call, got %#v", messages)
|
||||
}
|
||||
msg := messages[0]
|
||||
return []afterSalesAIIssueCandidate{{
|
||||
CustomerUserID: msg.SenderUserID,
|
||||
CustomerName: msg.SenderName,
|
||||
IssueContent: msg.Content,
|
||||
SourceMessageIDs: []string{msg.MessageID},
|
||||
Confidence: 0.9,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
result, err := engine.importHistoryAndCollectDetailed(AfterSalesHistoryImportRequest{
|
||||
ConversationID: "room-1",
|
||||
RoomName: "Room 1",
|
||||
RawText: strings.Join([]string{
|
||||
"Customer 2026-05-28 15:13 first device cannot store data and needs repair",
|
||||
"Customer 2026-05-28 15:20 second device reports voltage error and cannot start",
|
||||
}, "\n"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("history import failed: %v", err)
|
||||
}
|
||||
if result.Imported != 2 || result.Segments != 2 || result.Added != 2 || calls != 2 || len(engine.issues) != 2 {
|
||||
t.Fatalf("expected two segmented issues, result=%#v calls=%d issues=%#v", result, calls, engine.issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesMergeSkipsExistingResolvedFingerprint(t *testing.T) {
|
||||
content := "软件无法登录"
|
||||
fingerprint := afterSalesFingerprint("room-1", "wm_1", content)
|
||||
existingIssue := AfterSalesIssue{
|
||||
ID: "existing",
|
||||
ConversationID: "room-1",
|
||||
CustomerUserID: "wm_1",
|
||||
IssueContent: content,
|
||||
Status: afterSalesIssueStatusResolved,
|
||||
Fingerprint: fingerprint,
|
||||
}
|
||||
engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{existingIssue}}
|
||||
batch := []AfterSalesMessage{{
|
||||
MessageID: "m1",
|
||||
ConversationID: "room-1",
|
||||
RoomName: "售后群",
|
||||
SenderUserID: "wm_1",
|
||||
SenderName: "张三",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: content,
|
||||
MessageType: "text",
|
||||
SendTime: time.Now().Unix(),
|
||||
}}
|
||||
added := engine.mergeAIIssueCandidates([]afterSalesAIIssueCandidate{{
|
||||
RoomName: "售后群",
|
||||
CustomerUserID: "wm_1",
|
||||
CustomerName: "张三",
|
||||
IssueContent: content,
|
||||
AISuggestion: "建议检查账号状态。",
|
||||
SourceMessageIDs: []string{"m1"},
|
||||
}}, batch, map[string]AfterSalesIssue{fingerprint: existingIssue})
|
||||
|
||||
if added != 0 {
|
||||
t.Fatalf("expected duplicate resolved issue to be skipped, added=%d", added)
|
||||
}
|
||||
if len(engine.issues) != 1 || engine.issues[0].Status != afterSalesIssueStatusResolved {
|
||||
t.Fatalf("existing resolved issue was changed: %#v", engine.issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesCollectResolvesMissingRoomNameFromCache(t *testing.T) {
|
||||
restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
defer restoreCollector()
|
||||
|
||||
autoReplyEngine.groupNames = map[string]string{"R:room-1": "Resolved Group"}
|
||||
engine := &AfterSalesIssueEngine{}
|
||||
batch := []AfterSalesMessage{{
|
||||
MessageID: "m1",
|
||||
ConversationID: "R:room-1",
|
||||
RoomName: "R:room-1",
|
||||
SenderUserID: "wm_1",
|
||||
SenderName: "Customer",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "error",
|
||||
MessageType: "text",
|
||||
SendTime: time.Now().Unix(),
|
||||
}}
|
||||
added := engine.mergeAIIssueCandidates([]afterSalesAIIssueCandidate{{
|
||||
CustomerUserID: "wm_1",
|
||||
CustomerName: "Customer",
|
||||
IssueContent: "error",
|
||||
SourceMessageIDs: []string{"m1"},
|
||||
}}, batch, map[string]AfterSalesIssue{})
|
||||
|
||||
if added != 1 || len(engine.issues) != 1 {
|
||||
t.Fatalf("expected one issue, added=%d issues=%#v", added, engine.issues)
|
||||
}
|
||||
if engine.issues[0].RoomName != "Resolved Group" {
|
||||
t.Fatalf("expected resolved group name, got %q", engine.issues[0].RoomName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesCollectRecordsSourceAccount(t *testing.T) {
|
||||
restoreAutoReply, restoreCollector := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
defer restoreCollector()
|
||||
|
||||
restoreClients := setTestIdentifiedClients(t, map[uint32]string{7: "robot-a"})
|
||||
defer restoreClients()
|
||||
autoReplyEngine.rememberCurrentAccountNames(7, "robot-a", "售后A")
|
||||
|
||||
engine := &AfterSalesIssueEngine{}
|
||||
batch := []AfterSalesMessage{{
|
||||
MessageID: "m1",
|
||||
ClientID: 7,
|
||||
ConversationID: "R:room-1",
|
||||
RoomName: "售后群",
|
||||
SenderUserID: "wm_1",
|
||||
SenderName: "Customer",
|
||||
SenderIdentity: senderIdentityExternal,
|
||||
Content: "error",
|
||||
MessageType: "text",
|
||||
SendTime: time.Now().Unix(),
|
||||
}}
|
||||
added := engine.mergeAIIssueCandidates([]afterSalesAIIssueCandidate{{
|
||||
CustomerUserID: "wm_1",
|
||||
CustomerName: "Customer",
|
||||
IssueContent: "error",
|
||||
SourceMessageIDs: []string{"m1"},
|
||||
}}, batch, map[string]AfterSalesIssue{})
|
||||
|
||||
if added != 1 || len(engine.issues) != 1 {
|
||||
t.Fatalf("expected one issue, added=%d issues=%#v", added, engine.issues)
|
||||
}
|
||||
issue := engine.issues[0]
|
||||
if issue.SourceClientID != 7 || issue.SourceAccountUserID != "robot-a" || issue.SourceAccountName != "售后A" {
|
||||
t.Fatalf("expected source account fields, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesImageExtractionPrefersLocalPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "image.jpg")
|
||||
if err := os.WriteFile(path, []byte{0xff, 0xd8, 0xff, 0xd9}, 0644); err != nil {
|
||||
t.Fatalf("write image: %v", err)
|
||||
}
|
||||
gotPath, gotRef := extractAfterSalesImageFromMessage(autoReplyMessage{MediaLocalPath: path, MediaKind: "image"}, nil)
|
||||
if gotPath != path || gotRef != "" {
|
||||
t.Fatalf("expected local image path, got path=%q ref=%q", gotPath, gotRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesFileContentExtractionReadsTextFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "issue.txt")
|
||||
if err := os.WriteFile(path, []byte("motor alarm E42\nneeds service"), 0644); err != nil {
|
||||
t.Fatalf("write text file: %v", err)
|
||||
}
|
||||
|
||||
content, status := extractAfterSalesFileContent(path)
|
||||
if status != afterSalesFileStatusReady {
|
||||
t.Fatalf("expected parsed status, got %q content=%q", status, content)
|
||||
}
|
||||
if !strings.Contains(content, "motor alarm E42") || !strings.Contains(content, "needs service") {
|
||||
t.Fatalf("expected extracted file content, got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesCandidateFileAttachmentsFollowSourceMessageIDs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "issue.csv")
|
||||
if err := os.WriteFile(path, []byte("part,status\nlens,error"), 0644); err != nil {
|
||||
t.Fatalf("write csv file: %v", err)
|
||||
}
|
||||
content, status := extractAfterSalesFileContent(path)
|
||||
batch := []AfterSalesMessage{
|
||||
{
|
||||
MessageID: "text-1",
|
||||
ConversationID: "room-1",
|
||||
Content: "please check attached file",
|
||||
MessageType: "text",
|
||||
},
|
||||
{
|
||||
MessageID: "file-1",
|
||||
ConversationID: "room-1",
|
||||
Content: "file: issue.csv",
|
||||
MessageType: "file",
|
||||
FilePath: path,
|
||||
FileName: "issue.csv",
|
||||
FileContent: content,
|
||||
FileExtractStatus: status,
|
||||
},
|
||||
}
|
||||
|
||||
files := collectCandidateFileAttachments([]string{"file-1"}, batch, map[string]AfterSalesMessage{"file-1": batch[1]})
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected one file attachment, got %#v", files)
|
||||
}
|
||||
if files[0].Name != "issue.csv" || files[0].Path != path || files[0].SourceMessageID != "file-1" {
|
||||
t.Fatalf("unexpected file attachment metadata: %#v", files[0])
|
||||
}
|
||||
if files[0].ExtractStatus != afterSalesFileStatusReady || !strings.Contains(files[0].Content, "lens") {
|
||||
t.Fatalf("unexpected file attachment content/status: %#v", files[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesRepairKeepsEditedRoomNameAndDedupesImages(t *testing.T) {
|
||||
restoreAutoReply, _ := installAfterSalesCollectTestHooks(t)
|
||||
defer restoreAutoReply()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "image.jpg")
|
||||
if err := os.WriteFile(path, []byte{0xff, 0xd8, 0xff, 0xd9}, 0644); err != nil {
|
||||
t.Fatalf("write image: %v", err)
|
||||
}
|
||||
autoReplyEngine.groupNames = map[string]string{"R:room-1": "Resolved Group"}
|
||||
engine := &AfterSalesIssueEngine{
|
||||
issues: []AfterSalesIssue{{
|
||||
ID: "issue-1",
|
||||
ConversationID: "R:room-1",
|
||||
RoomName: "Edited Group",
|
||||
SourceMessageIDs: []string{"m1"},
|
||||
}},
|
||||
messages: []AfterSalesMessage{{
|
||||
MessageID: "m1",
|
||||
ImagePath: path,
|
||||
}},
|
||||
}
|
||||
|
||||
if !engine.repairIssuesLocked() {
|
||||
t.Fatal("expected image dedupe repair to report a change")
|
||||
}
|
||||
if engine.issues[0].RoomName != "Edited Group" {
|
||||
t.Fatalf("edited room name was overwritten: %q", engine.issues[0].RoomName)
|
||||
}
|
||||
if len(engine.issues[0].ImagePaths) != 1 || engine.issues[0].ImagePaths[0] != path {
|
||||
t.Fatalf("expected one deduped image path, got %#v", engine.issues[0].ImagePaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoReplyGroupNamePersistsAcrossReload(t *testing.T) {
|
||||
old := identityCachePathOverride
|
||||
identityCachePathOverride = filepath.Join(t.TempDir(), "identity.json")
|
||||
defer func() { identityCachePathOverride = old }()
|
||||
|
||||
engine := &AutoReplyEngine{
|
||||
groupNames: make(map[string]string),
|
||||
identityGroups: make(map[int32]map[string]autoReplyGroupOption),
|
||||
identityCaches: make(map[int32]*autoReplyIdentityCache),
|
||||
status: AutoReplyStatus{ReasonCounts: map[string]int{}},
|
||||
}
|
||||
engine.rememberGroupName(7, "R:all-hands", "All Hands", 26)
|
||||
|
||||
reloaded := &AutoReplyEngine{
|
||||
groupNames: make(map[string]string),
|
||||
identityGroups: make(map[int32]map[string]autoReplyGroupOption),
|
||||
identityCaches: make(map[int32]*autoReplyIdentityCache),
|
||||
status: AutoReplyStatus{ReasonCounts: map[string]int{}},
|
||||
}
|
||||
if err := reloaded.loadIdentityCache(); err != nil {
|
||||
t.Fatalf("load identity cache failed: %v", err)
|
||||
}
|
||||
if got := reloaded.ResolveGroupName("R:all-hands"); got != "All Hands" {
|
||||
t.Fatalf("expected persisted group name, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func installAfterSalesCollectTestHooks(t *testing.T) (func(), func()) {
|
||||
t.Helper()
|
||||
originalAutoReplyEngine := autoReplyEngine
|
||||
autoReplyEngine = &AutoReplyEngine{
|
||||
config: config.AutoReplyConfig{
|
||||
AI: config.AIConfig{
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: "http://127.0.0.1",
|
||||
Model: "test-model",
|
||||
TimeoutSeconds: 1,
|
||||
MaxTokens: 128,
|
||||
},
|
||||
},
|
||||
}
|
||||
originalCollector := afterSalesAICollector
|
||||
return func() {
|
||||
autoReplyEngine = originalAutoReplyEngine
|
||||
}, func() {
|
||||
afterSalesAICollector = originalCollector
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user