// direct_extract_test.go — task 6:extractor 模式端到端(本地萃卡→POST rag_ingest_card)。 package main import ( "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // 完整鏈(claude stub 版):丟原稿 → 萃卡落地本地 → 只有「卡片」被 POST 到 rag_ingest_card // → 原文從未離開本機 → manifest 標 ingested(下一輪不重送)。 func TestDirectExtractorModeE2E(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 原稿機密內容 XYZZY"), 0o644); err != nil { t.Fatal(err) } // 假 cypher:收 rag_ingest_card、驗 payload、記帳 var posted []map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(r.URL.Path, "/webhooks/named/demo/rag_ingest_card/trigger") { t.Errorf("打錯端點:%s", r.URL.Path) } body, _ := io.ReadAll(r.Body) var m map[string]any _ = json.Unmarshal(body, &m) posted = append(posted, m) _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) })) defer srv.Close() // stub claude:把原稿萃成卡(模擬 /wiki-capture 行為) stubDir := t.TempDir() stub := filepath.Join(stubDir, "claude") script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n## 關聯\\n- 報銷規則 >> 屬於 >> 財務\\n' > 'system-dev/wiki/cards/報銷規則.md'\n" if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { t.Fatal(err) } cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(t.TempDir(), "m.json"), CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", Library: "kb", Extractor: "claude", ClaudeBin: stub, CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio, } results, exit, _ := RunDirectOnce(cfg, false) if exit != 0 { t.Fatalf("exit=%d results=%+v", exit, results) } if len(results) != 1 || results[0].Status != "ingested" { t.Fatalf("results=%+v", results) } // 卡片落地本地(用戶看得到自己的 wiki) if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "報銷規則.md")); err != nil { t.Fatalf("卡片未落地:%v", err) } // 上雲的是卡片、不是原文 if len(posted) != 1 { t.Fatalf("應恰好 POST 一張卡,got %d", len(posted)) } cc, _ := posted[0]["card_content"].(string) if !strings.Contains(cc, "## 一句話定義") { t.Fatalf("card_content 不是卡片:%.80s", cc) } if strings.Contains(cc, "XYZZY") { t.Fatal("原文內容洩上雲=違反四步定稿邊界") } if p, _ := posted[0]["path"].(string); p != "system-dev/wiki/cards/報銷規則.md" { t.Fatalf("path=%q", p) } // 第二輪:原稿沒變 → 不重萃不重送 results2, exit2, _ := RunDirectOnce(cfg, false) if exit2 != 0 || len(results2) != 0 || len(posted) != 1 { t.Fatalf("第二輪應零事件:results=%+v posted=%d", results2, len(posted)) } } // 萃取失敗=該檔標 failed、exit=1、manifest 不標(下輪重試),其他檔不受影響。 func TestDirectExtractorFailKeepsRetry(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil { t.Fatal(err) } stubDir := t.TempDir() stub := filepath.Join(stubDir, "claude") if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 3\n"), 0o755); err != nil { t.Fatal(err) } cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(t.TempDir(), "m.json"), CypherURL: "https://x.example", Namespace: "demo", APIKey: "demo", Extractor: "claude", ClaudeBin: stub, MaxRemoved: DefaultMaxRemovedRatio, } results, exit, _ := RunDirectOnce(cfg, false) if exit != 1 || len(results) != 1 || results[0].Status != "failed" { t.Fatalf("exit=%d results=%+v", exit, results) } // 再跑一輪:仍是同一個事件(manifest 沒標 ingested=會重試) results2, _, _ := RunDirectOnce(cfg, false) if len(results2) != 1 { t.Fatalf("失敗檔應重試:%+v", results2) } }