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:
+276
-3
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
// 兩欄都空=缺必填。
|
||||
// t104:cypher_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=%d,results=%+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 %s,got results: %+v", hostA, results)
|
||||
}
|
||||
if countB == 0 {
|
||||
t.Errorf("② account B 結果應標 host %s,got results: %+v", hostB, results)
|
||||
}
|
||||
// 各自的 fake server 收到請求(a.md → serverA,b.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 應為 1,got %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 host,got %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandHomeEdgeCases(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
|
||||
Reference in New Issue
Block a user