Initial qiwei secondary development handoff

This commit is contained in:
2026-06-23 21:11:20 +08:00
commit 858cb68f4f
207 changed files with 52782 additions and 0 deletions

675
helper/after_sales_test.go Normal file
View 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
}
}