// direct_multi_test.go — daemon-beta task 1 多資料夾 config(單數相容/manifest 分根/多根掃描)。 package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "os" "path/filepath" "strings" "testing" ) func writeDirectConfig(t *testing.T, dir string, cfg map[string]any) string { t.Helper() data, _ := json.Marshal(cfg) p := filepath.Join(dir, "config.json") if err := os.WriteFile(p, data, 0o600); err != nil { t.Fatal(err) } return p } // 單數舊制照舊可載入;Folders() 正規化=一根。 // t86b:單根也分實例,manifest 路徑含 instanceHost 尾碼,不再沿用 cfg.Manifest 原名。 func TestLoadDirectConfigSingularCompat(t *testing.T) { dir := t.TempDir() p := writeDirectConfig(t, dir, map[string]any{ "watch_folder": "/tmp/kb", "manifest": filepath.Join(dir, "m.json"), "cypher_url": "https://x.example", "namespace": "demo", }) c, err := LoadDirectConfig(p) if err != nil { t.Fatal(err) } if got := c.Folders(); len(got) != 1 || got[0] != "/tmp/kb" { t.Fatalf("Folders()=%v", got) } // t86b:單根也含實例尾碼,不再沿用裸 cfg.Manifest mp := c.manifestPathFor("/tmp/kb") if mp == c.Manifest { t.Fatalf("t86b:單根 manifest 應含實例尾碼,不應沿用 cfg.Manifest 原路徑,got %s", mp) } // 穩定性:同路徑同 config 重算一致 if mp != c.manifestPathFor("/tmp/kb") { t.Fatal("manifestPathFor 應穩定") } } // 只填 watch_folders 也合法;單數與清單並存時去重保序。 func TestLoadDirectConfigMulti(t *testing.T) { dir := t.TempDir() p := writeDirectConfig(t, dir, map[string]any{ "watch_folder": "/tmp/a", "watch_folders": []string{"/tmp/b", "/tmp/a", "/tmp/c"}, "manifest": filepath.Join(dir, "m.json"), "cypher_url": "https://x.example", "namespace": "demo", }) c, err := LoadDirectConfig(p) if err != nil { t.Fatal(err) } got := c.Folders() want := []string{"/tmp/a", "/tmp/b", "/tmp/c"} if len(got) != len(want) { t.Fatalf("Folders()=%v want %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("Folders()[%d]=%s want %s", i, got[i], want[i]) } } // 多根=每根 manifest 各一份、彼此不同、且尾碼對同一路徑穩定 m1 := c.manifestPathFor("/tmp/a") m2 := c.manifestPathFor("/tmp/b") if m1 == m2 || m1 == c.Manifest { t.Fatalf("多根 manifest 應分根:%s vs %s", m1, m2) } if m1 != c.manifestPathFor("/tmp/a") { t.Fatal("同路徑尾碼應穩定") } } // 兩欄都空=缺必填。 func TestLoadDirectConfigMissingFolders(t *testing.T) { dir := t.TempDir() p := writeDirectConfig(t, dir, map[string]any{ "manifest": filepath.Join(dir, "m.json"), "cypher_url": "https://x.example", "namespace": "demo", }) if _, err := LoadDirectConfig(p); err == nil { t.Fatal("兩欄皆空應報缺必填") } } // 多根 dry-run:各根的檔案都被掃到、事件標 Root、manifest 各自獨立。 func TestRunDirectOnceMultiRootDryRun(t *testing.T) { base := t.TempDir() rootA := filepath.Join(base, "rootA") rootB := filepath.Join(base, "rootB") for _, d := range []string{rootA, rootB} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644); err != nil { t.Fatal(err) } cfg := &DirectConfig{ WatchFolders: []string{rootA, rootB}, Manifest: filepath.Join(base, "manifest.json"), CypherURL: "https://x.example", Namespace: "demo", MaxRemoved: DefaultMaxRemovedRatio, } results, exit, _ := RunDirectOnce(cfg, true) if exit != 0 { t.Fatalf("dry-run 不應失敗:%+v", results) } byRoot := map[string][]string{} for _, r := range results { if r.Status != "planned" { t.Fatalf("dry-run 事件應為 planned:%+v", r) } byRoot[r.Root] = append(byRoot[r.Root], r.Path) } if len(byRoot[rootA]) != 1 || byRoot[rootA][0] != "a.md" { t.Fatalf("rootA 事件錯:%v", byRoot[rootA]) } if len(byRoot[rootB]) != 1 || byRoot[rootB][0] != "b.md" { t.Fatalf("rootB 事件錯:%v", byRoot[rootB]) } } // t39:config 的 `~/` 由 daemon 端展開——安裝器成功頁下載的 config.json 用 // `~/.arcrun-rag/manifest.json` 這種人看的寫法,Go 不展開波浪號=會去找字面上叫 "~" 的資料夾。 func TestLoadDirectConfigExpandsHome(t *testing.T) { home, err := os.UserHomeDir() if err != nil || home == "" { t.Skip("拿不到家目錄,跳過") } dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") body := `{ "watch_folders": ["~/Documents/知識庫", "/tmp/絕對路徑不動"], "manifest": "~/.arcrun-rag/manifest.json", "cypher_url": "https://cypher.example.workers.dev", "namespace": "ns1" }` if err := os.WriteFile(cfgPath, []byte(body), 0o644); err != nil { t.Fatal(err) } cfg, err := LoadDirectConfig(cfgPath) if err != nil { t.Fatalf("LoadDirectConfig: %v", err) } wantManifest := filepath.Join(home, ".arcrun-rag/manifest.json") if cfg.Manifest != wantManifest { t.Errorf("manifest 未展開:got %q want %q", cfg.Manifest, wantManifest) } folders := cfg.Folders() wantFolder := filepath.Join(home, "Documents/知識庫") if folders[0] != wantFolder { t.Errorf("watch_folders[0] 未展開:got %q want %q", folders[0], wantFolder) } if folders[1] != "/tmp/絕對路徑不動" { t.Errorf("絕對路徑不該被動:got %q", folders[1]) } } // ── t86b:manifest 分實例 ──────────────────────────────────────────────────── // ① 同資料夾、不同 instanceHost → 不同 manifest 路徑。 func TestManifestPathDifferentInstances(t *testing.T) { dir := t.TempDir() cfgA := &DirectConfig{ WatchFolder: "/tmp/kb", Manifest: filepath.Join(dir, "m.json"), CypherURL: "https://instanceA.example.workers.dev", Namespace: "demo", } cfgB := &DirectConfig{ WatchFolder: "/tmp/kb", Manifest: filepath.Join(dir, "m.json"), CypherURL: "https://instanceB.example.workers.dev", Namespace: "demo", } pA := cfgA.manifestPathFor("/tmp/kb") pB := cfgB.manifestPathFor("/tmp/kb") if pA == pB { t.Fatalf("① 不同實例相同資料夾應產生不同 manifest 路徑,got same: %s", pA) } } // ② 同 instance 同夾路徑 → 穩定且含尾碼。 func TestManifestPathStable(t *testing.T) { dir := t.TempDir() cfg := &DirectConfig{ WatchFolder: "/tmp/kb", Manifest: filepath.Join(dir, "m.json"), CypherURL: "https://instance.workers.dev", Namespace: "demo", } p1 := cfg.manifestPathFor("/tmp/kb") p2 := cfg.manifestPathFor("/tmp/kb") if p1 != p2 { t.Fatalf("② 相同 instance+路徑應穩定,got %s vs %s", p1, p2) } if p1 == cfg.Manifest { t.Fatal("② manifestPathFor 應含實例尾碼,不應沿用 cfg.Manifest 原路徑") } } // ③ 遷移:舊單根名(cfg.Manifest)存在、新名不存在 → rename 後讀得到既有 entries。 func TestManifestMigrateSingleRoot(t *testing.T) { dir := t.TempDir() cfg := &DirectConfig{ WatchFolder: dir, Manifest: filepath.Join(dir, "manifest.json"), CypherURL: "https://instance.workers.dev", Namespace: "demo", MaxRemoved: DefaultMaxRemovedRatio, } absRoot, _ := filepath.Abs(dir) // 在舊路徑(cfg.Manifest)寫一份有資料的 manifest old := &Manifest{ FolderID: "old-folder-id", Root: absRoot, Entries: map[string]*ManifestEntry{"doc.md": {ContentHash: "sha256:aabb", IngestedHash: "sha256:aabb", Size: 42}}, } if err := old.Save(cfg.Manifest); err != nil { t.Fatal(err) } newPath := cfg.manifestPathFor(absRoot) if newPath == cfg.Manifest { t.Fatal("前提失效:新路徑應與舊路徑不同") } if _, err := os.Stat(newPath); err == nil { t.Fatal("新路徑不應已存在") } cfg.migrateManifestIfNeeded(absRoot, newPath) if _, err := os.Stat(cfg.Manifest); err == nil { t.Fatal("③ 遷移後舊路徑應已消失") } m, err := LoadManifest(newPath, absRoot) if err != nil { t.Fatalf("③ 遷移後讀取新路徑失敗:%v", err) } if _, ok := m.Entries["doc.md"]; !ok { t.Fatal("③ 遷移後 entries 應完整保留") } } // ③ 遷移:舊多根名(sha256(absRoot only))存在 → rename 到新路徑(sha256(host+absRoot))。 func TestManifestMigrateMultiRootOldFormula(t *testing.T) { dir := t.TempDir() cfg := &DirectConfig{ WatchFolders: []string{dir}, Manifest: filepath.Join(dir, "manifest.json"), CypherURL: "https://instance.workers.dev", Namespace: "demo", MaxRemoved: DefaultMaxRemovedRatio, } absRoot, _ := filepath.Abs(dir) // 計算舊多根路徑(只含路徑雜湊,無 instanceHost) sum := sha256.Sum256([]byte(absRoot)) ext := filepath.Ext(cfg.Manifest) oldMultiPath := strings.TrimSuffix(cfg.Manifest, ext) + "-" + hex.EncodeToString(sum[:4]) + ext old := &Manifest{ FolderID: "multi-old-id", Root: absRoot, Entries: map[string]*ManifestEntry{"wiki.md": {ContentHash: "sha256:cc00"}}, } if err := old.Save(oldMultiPath); err != nil { t.Fatal(err) } newPath := cfg.manifestPathFor(absRoot) if newPath == oldMultiPath { t.Fatal("前提失效:新路徑應與舊多根路徑不同(instanceHost 加進去了)") } cfg.migrateManifestIfNeeded(absRoot, newPath) if _, err := os.Stat(oldMultiPath); err == nil { t.Fatal("③ 多根遷移後舊路徑應已消失") } m, err := LoadManifest(newPath, absRoot) if err != nil { t.Fatalf("③ 多根遷移後讀取失敗:%v", err) } if _, ok := m.Entries["wiki.md"]; !ok { t.Fatal("③ 多根遷移後 entries 應保留") } } // ④ 冪等:新名已存在 → 再跑 migrateManifestIfNeeded 不覆蓋新名內容。 func TestManifestMigrateIdempotent(t *testing.T) { dir := t.TempDir() cfg := &DirectConfig{ WatchFolder: dir, Manifest: filepath.Join(dir, "manifest.json"), CypherURL: "https://instance.workers.dev", Namespace: "demo", MaxRemoved: DefaultMaxRemovedRatio, } absRoot, _ := filepath.Abs(dir) newPath := cfg.manifestPathFor(absRoot) // 直接在新路徑寫一份 manifest fresh := &Manifest{ FolderID: "fresh-id", Root: absRoot, Entries: map[string]*ManifestEntry{"keep.md": {ContentHash: "sha256:dd11"}}, } if err := fresh.Save(newPath); err != nil { t.Fatal(err) } // 也在舊路徑留一份(確認冪等時不被搬來蓋掉新名) stale := &Manifest{ FolderID: "stale-id", Root: absRoot, Entries: map[string]*ManifestEntry{"old.md": {ContentHash: "sha256:ee22"}}, } if err := stale.Save(cfg.Manifest); err != nil { t.Fatal(err) } // 跑兩次 cfg.migrateManifestIfNeeded(absRoot, newPath) cfg.migrateManifestIfNeeded(absRoot, newPath) m, err := LoadManifest(newPath, absRoot) if err != nil { t.Fatalf("④ 冪等後讀取失敗:%v", err) } if _, ok := m.Entries["keep.md"]; !ok { t.Fatal("④ 冪等後新路徑內容不應被蓋掉") } if _, ok := m.Entries["old.md"]; ok { t.Fatal("④ 冪等後新路徑不應出現舊路徑的 entries") } } func TestExpandHomeEdgeCases(t *testing.T) { home, err := os.UserHomeDir() if err != nil || home == "" { t.Skip("拿不到家目錄,跳過") } cases := map[string]string{ "~": home, "~/a/b": filepath.Join(home, "a/b"), "/abs/path": "/abs/path", "relative/dir": "relative/dir", "": "", "~notme/x": "~notme/x", // 只認 `~/`;`~user` 形式不猜(Go 無法解析他人家目錄) } for in, want := range cases { if got := expandHome(in); got != want { t.Errorf("expandHome(%q)=%q,want %q", in, got, want) } } }