feat(t104): 多帳號同時看守——切換概念消滅

leo 架構依據:「它只是一個門,幾個帳號通過它同步並沒有影響」+
「我不是 Google Drive……不提供暫存空間,daemon 工作輕巧,多帳號只是頁簽問題」
(D-daemon-not-Drive)。
Accounts[] 每帳號獨立連線+資料夾;舊 config 冪等遷移 accounts[0];
逐帳號同步一敗不擋全;status.json 分帳;托盤每帳號一分組;
「連上知識庫」→「+新增帳號…」(append 非替換);t86 切換清空退役;
t101 刪除作用於正確帳號。+873/-149、collector 5+tray 5 新測試,
兩模組 go test 全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
This commit is contained in:
2026-07-28 16:45:14 +08:00
parent f8450815d3
commit bf1f0d9991
5 changed files with 871 additions and 147 deletions
+276 -3
View File
@@ -5,6 +5,8 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -80,14 +82,23 @@ func TestLoadDirectConfigMulti(t *testing.T) {
}
}
// 兩欄都空=缺必填
// 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.Fatal("兩欄皆空應報缺必填")
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 時應報缺必填")
}
}
@@ -349,6 +360,268 @@ func TestManifestMigrateIdempotent(t *testing.T) {
}
}
// ── 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,
}
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",
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)
}
}
}
func TestExpandHomeEdgeCases(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {