Files
arcrun-collector/direct_multi_test.go
T
Leo 4d3a6a09a6 v0.18.9:collector 併進同一支執行檔——磁碟上不再攤出第二支 exe
leo 08-06 裁決:「不要兩支,寫成一支檔案」。

## 為什麼
v0.18.7-8 的「單一 exe」其實是**一支包著另一支**:collector.exe 被 go:embed
進 Arcrun.exe,執行時攤到 ~/.arcrun-rag/bin/ 再跑。
那正是防毒軟體眼中的 dropper 特徵 —— 封測者實撞
`Trojan:Win32/Sabsik.FL.A!ml`,檔案當場被隔離、自動刪除。

⚠️ 誠實界定:`!ml` 結尾=**機器學習判定**,Sabsik 是最常見的通用誤判家族,
   主因是「未簽章+下載次數少」,**不是**特別指向 dropper 行為。
   所以本次改動**不保證**解除誤判——真正的解是上架 MS Store(微軟簽章)。
   但「執行時把第二支 PE 寫到磁碟再執行」本來就該拿掉,這是對的方向且順手變小。

## 怎麼做
- `collector/` 39 個檔 `package main` → `package collector`,`main()` → 匯出的 `Run(args) int`
- 新增 `collector/cmd/collector/`(薄殼 CLI,讓單獨跑 collector 這條路仍可用)
- App 直接 import 該套件;`main()` 第一件事就判 `--collector`,是的話走 `collector.Run` 不碰 GUI
- `supervisor` 加 `ArgPrefix`,App 把 `BinPath` 指向 `os.Executable()` 自己
- 刪掉 `bundled_collector_{windows,other}.go`(embed + 攤檔那套)
- 三支打包腳本不再編/複製第二支;版本注入同時打到兩個 package
- build-win.sh 的機械閘改成**直接問它**:`--collector --version` 回得出版本才放行
  (舊閘是比大小,只能證明「有 embed」,證明不了「分派是對的」)

## 驗(真機實跑)
· `.app/Contents/MacOS/` 只有 **一個** 執行檔(原本兩個)
· 跑起來兩個行程是**同一個 exe**:
    …/MacOS/arcrun-app
    …/MacOS/arcrun-app --collector direct --config …
· `~/.arcrun-rag/bin` **不存在**(沒有任何東西被攤出來)
· 端到端:丟檔進看守資料夾 → collector.log `"status":"ingested","http_status":200`
· `lsappinfo` 仍是 `type="UIElement"`、`Version="0.18.9"`
· collector 39 檔測試全過;app 測試過;go vet 全綠;mac + windows 交叉編譯皆過
· 單檔 26MB → 22MB(不再夾帶第二份完整程式)
2026-08-06 16:00:47 +08:00

788 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// direct_multi_test.go — daemon-beta task 1 多資料夾 config(單數相容/manifest 分根/多根掃描)。
package collector
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"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("同路徑尾碼應穩定")
}
}
// t104cypher_url 有值但無 watch_folders → 合法(遷移後空帳號,等待用戶加資料夾)。
// 真正缺必填:無 accounts 且無 cypher_url。
func TestLoadDirectConfigMissingFolders(t *testing.T) {
dir := t.TempDir()
// 有 cypher_url 無 watch_folders → 合法
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.Fatalf("t104: 有 cypher_url 無 watch_folders 應合法,got: %v", err)
}
// 無 cypher_url 無 accounts → 缺必填
p2 := writeDirectConfig(t, dir, map[string]any{
"manifest": filepath.Join(dir, "m.json"),
})
if _, err := LoadDirectConfig(p2); err == nil {
t.Fatal("無帳號且無 cypher_url 時應報缺必填")
}
}
// 多根 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])
}
}
// t39config 的 `~/` 由 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])
}
}
// ── t86bmanifest 分實例 ────────────────────────────────────────────────────
// ① 同資料夾、不同 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")
}
}
// ── t104:多帳號同時看守 ──────────────────────────────────────────────────────────
// ①遷移:舊格式(頂層 cypher_url + watch_folders)→ LoadDirectConfig → accounts[0]。
func TestMigrateOldConfigToAccounts(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"watch_folders": []string{"/tmp/a", "/tmp/b"},
"manifest": filepath.Join(dir, "m.json"),
"cypher_url": "https://instance.example.workers.dev",
"namespace": "ns1",
"email": "user@example.com",
"instance_name": "My Library",
})
cfg, err := LoadDirectConfig(p)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
if len(cfg.Accounts) != 1 {
t.Fatalf("① 應自動建 accounts[0]got %d accounts", len(cfg.Accounts))
}
acc := cfg.Accounts[0]
if acc.CypherURL != "https://instance.example.workers.dev" {
t.Errorf("accounts[0].cypher_url 錯:%s", acc.CypherURL)
}
if acc.Namespace != "ns1" {
t.Errorf("accounts[0].namespace 錯:%s", acc.Namespace)
}
if acc.Email != "user@example.com" {
t.Errorf("accounts[0].email 錯:%s", acc.Email)
}
if acc.InstanceName != "My Library" {
t.Errorf("accounts[0].instance_name 錯:%s", acc.InstanceName)
}
if len(acc.WatchFolders) != 2 {
t.Errorf("accounts[0].watch_folders 應含 2 根,got %v", acc.WatchFolders)
}
}
// ①冪等:已有 accounts 時不再遷移(accounts 數量不增加)。
func TestMigrateAlreadyHasAccountsIsIdempotent(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"accounts": []map[string]any{
{"cypher_url": "https://a.example", "namespace": "nsA", "watch_folders": []string{"/tmp/a"}},
{"cypher_url": "https://b.example", "namespace": "nsB", "watch_folders": []string{"/tmp/b"}},
},
"manifest": filepath.Join(dir, "m.json"),
})
cfg, err := LoadDirectConfig(p)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
if len(cfg.Accounts) != 2 {
t.Fatalf("① 已有 2 個 accounts 時不應再新增,got %d", len(cfg.Accounts))
}
}
// ②雙帳號同輪同步互不干擾:各帳號事件打到各自的 fake server,結果標正確的 Account host。
func TestRunDirectOnceMultiAccount(t *testing.T) {
var serverACalled, serverBCalled int
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
serverACalled++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer serverA.Close()
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
serverBCalled++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer serverB.Close()
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)
}
orig := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "2026-07-28+stub", true }
defer func() { fetchCloudVersion = orig }()
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
Accounts: []AccountConfig{
{CypherURL: serverA.URL, Namespace: "nsA", WatchFolders: []string{rootA}},
{CypherURL: serverB.URL, Namespace: "nsB", WatchFolders: []string{rootB}},
},
MaxRemoved: DefaultMaxRemovedRatio,
// t181:本測驗的是「雙帳號各自送到自己的實例」,不是萃取。
// 預設已改成 workers-ai(會打雲端萃取端點),為隔離變因這裡明確走舊制直送
// Extractor 空+ExtractorExplicit=true ⇒ 不被預設覆蓋)。
ExtractorExplicit: true,
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 0 {
t.Fatalf("② 雙帳號同步應成功,exit=%dresults=%+v", exit, results)
}
hostA := instanceHostOf(serverA.URL)
hostB := instanceHostOf(serverB.URL)
var countA, countB int
for _, r := range results {
if r.Account == hostA {
countA++
}
if r.Account == hostB {
countB++
}
}
if countA == 0 {
t.Errorf("② account A 結果應標 host %sgot results: %+v", hostA, results)
}
if countB == 0 {
t.Errorf("② account B 結果應標 host %sgot results: %+v", hostB, results)
}
// 各自的 fake server 收到請求(a.md → serverAb.md → serverB
if serverACalled == 0 {
t.Error("② serverA 未收到請求")
}
if serverBCalled == 0 {
t.Error("② serverB 未收到請求")
}
}
// ③一帳號 HTTP 失敗不擋另一帳號。
func TestRunDirectOnceOneAccountFailDoesNotBlock(t *testing.T) {
serverFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal"}`))
}))
defer serverFail.Close()
var serverOKCalled bool
serverOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
serverOKCalled = true
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer serverOK.Close()
base := t.TempDir()
rootFail := filepath.Join(base, "rootFail")
rootOK := filepath.Join(base, "rootOK")
for _, d := range []string{rootFail, rootOK} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(rootFail, "f.md"), []byte("# F"), 0o644)
_ = os.WriteFile(filepath.Join(rootOK, "ok.md"), []byte("# OK"), 0o644)
orig := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = orig }()
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
Accounts: []AccountConfig{
{CypherURL: serverFail.URL, Namespace: "nsFail", WatchFolders: []string{rootFail}},
{CypherURL: serverOK.URL, Namespace: "nsOK", WatchFolders: []string{rootOK}},
},
MaxRemoved: DefaultMaxRemovedRatio,
}
_, exit, _ := RunDirectOnce(cfg, false)
if exit != 1 {
t.Errorf("③ 帳號失敗時 exit 應為 1got %d", exit)
}
if !serverOKCalled {
t.Error("③ 帳號 A 失敗不應擋住帳號 B 同步")
}
}
// ④托盤分組資料結構:accountDisplayName 與 removeAccountWatchFolder 正確作用於指定帳號。
// (純資料結構測試,不依賴 fyne GUI)
func TestAccountDataStructureFunctions(t *testing.T) {
type localAccCfg struct {
InstanceName string
Email string
CypherURL string
WatchFolders []string
}
// 用 makeAccountSubConfig 驗證 per-account sub-config 繼承機器層級欄位
parent := &DirectConfig{
Manifest: "/tmp/m.json",
PollSec: 10,
Extractor: "claude", ExtractorExplicit: true,
Accounts: []AccountConfig{
{CypherURL: "https://a.example", Namespace: "nsA", WatchFolders: []string{"/folder1"}},
},
Library: "kb",
IngestWF: "rag_ingest_direct",
RemovedWF: "rag_takedown_direct",
CardIngestWF: "rag_ingest_card",
LLMModel: "gemma-4-31b-it",
}
sub := parent.makeAccountSubConfig(parent.Accounts[0])
if sub.CypherURL != "https://a.example" {
t.Errorf("makeAccountSubConfig CypherURL 錯:%s", sub.CypherURL)
}
if sub.PollSec != 10 {
t.Errorf("makeAccountSubConfig 機器層級 PollSec 應繼承:%d", sub.PollSec)
}
if sub.Extractor != "claude" {
t.Errorf("makeAccountSubConfig 機器層級 Extractor 應繼承:%s", sub.Extractor)
}
if len(sub.Accounts) != 0 {
t.Errorf("makeAccountSubConfig Accounts 應清空(避免遞迴),got %d", len(sub.Accounts))
}
}
// ⑤t101 刪除在多帳號下作用於正確帳號:帳號 A 刪 folder1 不影響帳號 B。
func TestRunDirectOnceAccountResultsTagged(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
orig := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = orig }()
base := t.TempDir()
rootA := filepath.Join(base, "rA")
rootB := filepath.Join(base, "rB")
for _, d := range []string{rootA, rootB} {
_ = os.MkdirAll(d, 0o755)
}
_ = os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644)
_ = os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644)
// 同一個 server,兩個帳號(用不同 namespace 區分)
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
Accounts: []AccountConfig{
{CypherURL: server.URL, Namespace: "nsA", WatchFolders: []string{rootA}},
{CypherURL: server.URL + "/", Namespace: "nsB", WatchFolders: []string{rootB}},
},
MaxRemoved: DefaultMaxRemovedRatio,
}
results, _, _ := RunDirectOnce(cfg, true) // dry-run:驗結果標籤
if len(results) == 0 {
t.Fatal("⑤ dry-run 應有計畫事件")
}
for _, r := range results {
if r.Account == "" {
t.Errorf("⑤ 結果應標 Account hostgot %+v", r)
}
}
}
// ── t126:每帳號獨立萃取設定 ──────────────────────────────────────────────────
// t126①:帳號層 Extractor/GeminiAPIKey/LLMModel 有值時優先覆蓋機器層。
func TestMakeAccountSubConfigExtractorOverride(t *testing.T) {
parent := &DirectConfig{
Manifest: "/tmp/m.json",
Extractor: "claude", ExtractorExplicit: true,
GeminiAPIKey: "machine-key",
LLMModel: "model-machine",
PollSec: 5,
MaxRemoved: DefaultMaxRemovedRatio,
}
acc := AccountConfig{
CypherURL: "https://a.example",
Namespace: "ns",
WatchFolders: []string{"/tmp"},
Extractor: "gemma", // 帳號層覆蓋(AccountConfig 無 ExtractorExplicit,那是機器層旗標)
GeminiAPIKey: "account-key", // 帳號層覆蓋
LLMModel: "model-acc", // 帳號層覆蓋
}
sub := parent.makeAccountSubConfig(acc)
if sub.Extractor != "gemma" {
t.Errorf("① 帳號層 Extractor 應優先,got %q", sub.Extractor)
}
if sub.GeminiAPIKey != "account-key" {
t.Errorf("① 帳號層 GeminiAPIKey 應優先,got %q", sub.GeminiAPIKey)
}
if sub.LLMModel != "model-acc" {
t.Errorf("① 帳號層 LLMModel 應優先,got %q", sub.LLMModel)
}
}
// t126②:帳號層空字串不算「有值」,應繼承機器層。
func TestMakeAccountSubConfigExtractorFallback(t *testing.T) {
parent := &DirectConfig{
Manifest: "/tmp/m.json",
Extractor: "gemma", ExtractorExplicit: true,
GeminiAPIKey: "machine-key",
LLMModel: "model-machine",
PollSec: 5,
MaxRemoved: DefaultMaxRemovedRatio,
}
acc := AccountConfig{
CypherURL: "https://a.example",
Namespace: "ns",
WatchFolders: []string{"/tmp"},
// Extractor/GeminiAPIKey/LLMModel 全空 → 繼承機器層
}
sub := parent.makeAccountSubConfig(acc)
if sub.Extractor != "gemma" {
t.Errorf("② 帳號層空時應繼承機器層 Extractorgot %q", sub.Extractor)
}
if sub.GeminiAPIKey != "machine-key" {
t.Errorf("② 帳號層空時應繼承機器層 GeminiAPIKeygot %q", sub.GeminiAPIKey)
}
if sub.LLMModel != "model-machine" {
t.Errorf("② 帳號層空時應繼承機器層 LLMModelgot %q", sub.LLMModel)
}
}
// t126③ 遷移:機器層金鑰複製到沒有金鑰的帳號;已有金鑰的帳號不覆蓋。
func TestLoadDirectConfigMigratesCopyKeyToAccounts(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"manifest": filepath.Join(dir, "m.json"),
"extractor": "gemma",
"extractor_explicit": true, // t182:主動選過 ⇒ 不被抹成 workers-ai
"gemini_api_key": "top-level-key",
"llm_model": "model-x",
"accounts": []map[string]any{
{
"cypher_url": "https://a.example",
"namespace": "nsA",
"watch_folders": []string{"/tmp/a"},
// 沒有 gemini_api_key → 應複製頂層
},
{
"cypher_url": "https://b.example",
"namespace": "nsB",
"watch_folders": []string{"/tmp/b"},
"gemini_api_key": "b-own-key", // 已有自己的 key → 不覆蓋
},
},
})
cfg, err := LoadDirectConfig(p)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
// account A:無 key → 應複製頂層
if cfg.Accounts[0].GeminiAPIKey != "top-level-key" {
t.Errorf("③ account A 應複製頂層 keygot %q", cfg.Accounts[0].GeminiAPIKey)
}
// t182:加 extractor_explicit(見下方 writeDirectConfig)=使用者主動選過 Gemini
// ⇒ 不觸發 workers-ai 抹除,t126 的「複製頂層 extractor」行為照舊可測。
if cfg.Accounts[0].Extractor != "gemma" {
t.Errorf("③ account A 應複製頂層 extractorgot %q", cfg.Accounts[0].Extractor)
}
if cfg.Accounts[0].LLMModel != "model-x" {
t.Errorf("③ account A 應複製頂層 llm_modelgot %q", cfg.Accounts[0].LLMModel)
}
// account B:已有自己的 key → 不覆蓋
if cfg.Accounts[1].GeminiAPIKey != "b-own-key" {
t.Errorf("③ account B 已有 key 不應被頂層覆蓋,got %q", cfg.Accounts[1].GeminiAPIKey)
}
// 頂層保留(複製非搬移)
if cfg.GeminiAPIKey != "top-level-key" {
t.Errorf("③ 頂層 key 應保留(複製非搬移),got %q", cfg.GeminiAPIKey)
}
}
// t126④ 冪等:帳號已有 extractor/key 時再跑遷移不覆蓋。
func TestLoadDirectConfigMigrationIdempotent(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"manifest": filepath.Join(dir, "m.json"),
"extractor": "gemma",
"extractor_explicit": true, // t182:主動選過 ⇒ 不被抹成 workers-ai
"gemini_api_key": "top-key",
"accounts": []map[string]any{
{
"cypher_url": "https://a.example",
"namespace": "nsA",
"watch_folders": []string{"/tmp/a"},
"extractor": "claude", // 帳號已有 → 不應被頂層 "gemma" 覆蓋
"gemini_api_key": "own-key", // 帳號已有 → 不應被頂層 key 覆蓋
},
},
})
cfg, err := LoadDirectConfig(p)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
if cfg.Accounts[0].Extractor != "claude" {
t.Errorf("④ 帳號已有 extractor 不應被頂層覆蓋,got %q", cfg.Accounts[0].Extractor)
}
if cfg.Accounts[0].GeminiAPIKey != "own-key" {
t.Errorf("④ 帳號已有 key 不應被頂層覆蓋,got %q", cfg.Accounts[0].GeminiAPIKey)
}
}
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)%qwant %q", in, got, want)
}
}
}