package main import ( "os" "strings" "testing" "time" ) func TestAfterSalesResolveIssueArchivesKnowledgeCase(t *testing.T) { cleanupAfterSalesKnowledgeTestFiles(t) rebuildCh := stubAfterSalesKnowledgeRebuild(t) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "i1", CreatedAt: "2026-06-04T09:00:00+08:00", Status: afterSalesIssueStatusPending, RoomName: "售后群", CustomerName: "华南客户", IssueContent: "镜头无法调焦", AISuggestion: "检查调焦机构", AssignedEngineerID: "engineer-a", ImagePaths: []string{"a.jpg"}, }}} knowledgeCase, err := engine.resolveIssue("i1", "已确认调焦环松动,重新固定后恢复。") if err != nil { t.Fatalf("resolve issue: %v", err) } if engine.issues[0].Status != afterSalesIssueStatusResolved { t.Fatalf("expected resolved issue, got %#v", engine.issues[0]) } if engine.issues[0].KnowledgeSourcePath == "" || engine.issues[0].KnowledgeArchivedAt == "" { t.Fatalf("expected issue knowledge metadata, got %#v", engine.issues[0]) } if knowledgeCase.IssueID != "i1" || knowledgeCase.ImageCount != 1 { t.Fatalf("unexpected case: %#v", knowledgeCase) } data, err := os.ReadFile(knowledgeCase.MarkdownPath) if err != nil { t.Fatalf("read markdown: %v", err) } text := string(data) for _, want := range []string{"问题ID:i1", "镜头无法调焦", "已确认调焦环松动", "检查调焦机构", "售后群", "华南客户"} { if !strings.Contains(text, want) { t.Fatalf("expected markdown to contain %q, got %s", want, text) } } cases, err := listAfterSalesKnowledgeCases() if err != nil { t.Fatalf("list cases: %v", err) } if len(cases) != 1 || cases[0].IssueID != "i1" { t.Fatalf("expected one listed case, got %#v", cases) } select { case <-rebuildCh: case <-time.After(time.Second): t.Fatal("expected knowledge rebuild hook") } } func TestAfterSalesResolveIssueRequiresResolution(t *testing.T) { cleanupAfterSalesKnowledgeTestFiles(t) rebuildCh := stubAfterSalesKnowledgeRebuild(t) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "i1", Status: afterSalesIssueStatusPending, RoomName: "售后群", }}} if _, err := engine.resolveIssue("i1", " "); err == nil { t.Fatal("expected empty resolution error") } if engine.issues[0].Status != afterSalesIssueStatusPending { t.Fatalf("expected status unchanged, got %#v", engine.issues[0]) } select { case <-rebuildCh: t.Fatal("did not expect rebuild") default: } } func TestAfterSalesKnowledgeCaseOverwrite(t *testing.T) { cleanupAfterSalesKnowledgeTestFiles(t) stubAfterSalesKnowledgeRebuild(t) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "i1", CreatedAt: "2026-06-04T09:00:00+08:00", Status: afterSalesIssueStatusPending, IssueContent: "图像模糊", AISuggestion: "检查焦距", }}} first, err := engine.resolveIssue("i1", "第一次处理方案") if err != nil { t.Fatalf("first resolve: %v", err) } second, err := engine.resolveIssue("i1", "第二次处理方案") if err != nil { t.Fatalf("second resolve: %v", err) } if first.MarkdownPath != second.MarkdownPath { t.Fatalf("expected same markdown path, got %s and %s", first.MarkdownPath, second.MarkdownPath) } cases, err := listAfterSalesKnowledgeCases() if err != nil { t.Fatalf("list cases: %v", err) } if len(cases) != 1 || cases[0].ResolutionContent != "第二次处理方案" { t.Fatalf("expected overwritten single case, got %#v", cases) } data, err := os.ReadFile(second.MarkdownPath) if err != nil { t.Fatalf("read markdown: %v", err) } if strings.Contains(string(data), "第一次处理方案") || !strings.Contains(string(data), "第二次处理方案") { t.Fatalf("expected markdown overwrite, got %s", string(data)) } } func TestAfterSalesKnowledgeSyncLegacyResolvedIssue(t *testing.T) { cleanupAfterSalesKnowledgeTestFiles(t) rebuildCh := stubAfterSalesKnowledgeRebuild(t) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ ID: "legacy", CreatedAt: "2026-06-02T11:10:24+08:00", UpdatedAt: "2026-06-04T11:03:34+08:00", Status: afterSalesIssueStatusResolved, RoomName: "刘羽、JM.、C", CustomerName: "JM.", IssueContent: "客户询问客服身份", AISuggestion: "客户询问客服身份,需确认是否需要进一步解释或提供帮助", AssignedEngineerID: "1688855899845302", NotifyStatus: afterSalesNotifySent, }}} if err := engine.syncResolvedKnowledgeCases(); err != nil { t.Fatalf("sync legacy resolved: %v", err) } cases, err := listAfterSalesKnowledgeCases() if err != nil { t.Fatalf("list cases: %v", err) } if len(cases) != 1 || cases[0].IssueID != "legacy" { t.Fatalf("expected one synced legacy case, got %#v", cases) } if cases[0].ResolutionContent != "客户询问客服身份,需确认是否需要进一步解释或提供帮助" { t.Fatalf("expected AI suggestion fallback, got %#v", cases[0]) } if engine.issues[0].KnowledgeSourcePath == "" || engine.issues[0].ResolutionContent == "" { t.Fatalf("expected issue backfill metadata, got %#v", engine.issues[0]) } if !fileExists(cases[0].MarkdownPath) { t.Fatalf("expected markdown file at %s", cases[0].MarkdownPath) } select { case <-rebuildCh: case <-time.After(time.Second): t.Fatal("expected knowledge rebuild hook") } } func TestAfterSalesKnowledgeCaseMissingMarkdownFlag(t *testing.T) { cleanupAfterSalesKnowledgeTestFiles(t) stubAfterSalesKnowledgeRebuild(t) engine := &AfterSalesIssueEngine{issues: []AfterSalesIssue{{ID: "i1", Status: afterSalesIssueStatusPending, IssueContent: "报错"}}} knowledgeCase, err := engine.resolveIssue("i1", "重启设备恢复") if err != nil { t.Fatalf("resolve issue: %v", err) } if err := os.Remove(knowledgeCase.MarkdownPath); err != nil { t.Fatalf("remove markdown: %v", err) } cases, err := listAfterSalesKnowledgeCases() if err != nil { t.Fatalf("list cases: %v", err) } if len(cases) != 1 || !cases[0].MissingMarkdown { t.Fatalf("expected missing markdown flag, got %#v", cases) } } func stubAfterSalesKnowledgeRebuild(t *testing.T) chan struct{} { t.Helper() oldHook := afterSalesKnowledgeRebuildHook ch := make(chan struct{}, 8) afterSalesKnowledgeRebuildHook = func() { ch <- struct{}{} } t.Cleanup(func() { afterSalesKnowledgeRebuildHook = oldHook }) return ch } func cleanupAfterSalesKnowledgeTestFiles(t *testing.T) { t.Helper() t.Cleanup(func() { _ = os.Remove(afterSalesKnowledgeCasesPath()) _ = os.Remove(afterSalesIssuesPath()) _ = os.RemoveAll(afterSalesKnowledgeMarkdownDir()) }) _ = os.Remove(afterSalesKnowledgeCasesPath()) _ = os.Remove(afterSalesIssuesPath()) _ = os.RemoveAll(afterSalesKnowledgeMarkdownDir()) }