Files
Leo 361f1f1f9d 新增全新安裝的「可刪除預設庫」(leo 2026-08-08 拍板)
全新使用者第一次連上知識庫時,自動在 Documents 建一個
「Arcrun 範例庫(可刪除)」,裡面放三份 .md 示範內容,
不必自己選資料夾就能立刻走完「丟檔→知識卡→搜得到」。

四條紅線都有測試釘住(default_library_test.go/
default_library_e2e_test.go,含真的建 binary 跑 --dry-run 驗證):
1. 可刪——刪掉就真的消失,marker 檔擋住重種
2. 只有第一次——已有帳號的機器不會冒出來
3. 不污染——只在自己的資料夾裡寫檔
4. 不留幽靈資料——資料夾被刪後,config 殘留引用會被清掉(GetState 自我修復)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:22:03 +08:00

221 lines
7.7 KiB
Go
Raw Permalink 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.
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// fakeDaemonConfigServer 假的知識庫登入端點,回傳固定的 daemonConfigResp
// 讓測試能真的走過 App.Connect() 而不必打真的雲端。
//
// 🔴 用 TLS server 不是隨便選的:normalizePortalURL()connect.go)不管使用者貼的是
// http:// 還是 https://,一律吐回 "https://" + host(現實世界的網址全是 https,這樣
// 才不必逼使用者記得打 https)——所以假伺服器也一定要是 TLS,用一般 httptest.NewServer
// 會被硬轉成 https:// 打過去、連不上。同時把 http.DefaultTransport 換成信任這顆測試憑證
// 的版本(fetchConfigByLogin 的 http.Client{} 沒指定 Transport ⇒ 用的正是這個全域預設值),
// 測試結束就還原,不影響其他測試或正式行為。
func fakeDaemonConfigServer(t *testing.T, cypherURL string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/portal/daemon/config", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(daemonConfigResp{
Success: true,
Config: daemonConfig{
CypherURL: cypherURL,
Namespace: "abc123",
Library: "lib1",
Email: "evan@example.com",
InstanceName: "evan-的知識庫",
},
})
})
srv := httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
origTransport := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = origTransport })
return srv
}
// TestDefaultLibrarySeededOnFirstConnect 釘住紅線①③:全新安裝、第一次連上知識庫,
// 應該自動出現一個可刪除的預設庫——裡面有真的檔案(丟檔→知識卡→搜得到那條路走得通),
// 而且只在這個資料夾自己底下寫東西,不污染 Documents 的其他地方。
func TestDefaultLibrarySeededOnFirstConnect(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
srv := fakeDaemonConfigServer(t, "https://instance-a.example.workers.dev")
app := &App{}
if err := app.Connect(srv.URL, "evan@example.com", "pw"); err != nil {
t.Fatalf("Connect 失敗:%v", err)
}
cfg, err := loadCfg()
if err != nil {
t.Fatalf("讀不回設定:%v", err)
}
if len(cfg.Accounts) != 1 {
t.Fatalf("應該只有一個帳號,實際 %d 個", len(cfg.Accounts))
}
wantPath := defaultLibraryPath()
found := false
for _, f := range cfg.Accounts[0].WatchFolders {
if f == wantPath {
found = true
}
}
if !found {
t.Fatalf("預設庫 %s 沒有被加進 watch_folders%v", wantPath, cfg.Accounts[0].WatchFolders)
}
// 資料夾真的存在、裡面有真的檔案(不是空殼)
entries, err := os.ReadDir(wantPath)
if err != nil {
t.Fatalf("預設庫資料夾沒有被建出來:%v", err)
}
if len(entries) == 0 {
t.Fatal("預設庫資料夾是空的,等於沒有東西可以被整理成知識卡")
}
for _, e := range entries {
if filepath.Ext(e.Name()) != ".md" {
t.Fatalf("預設庫檔案 %s 不是 collector 讀得懂的格式(見 scan.go allowedExt", e.Name())
}
b, err := os.ReadFile(filepath.Join(wantPath, e.Name()))
if err != nil || len(b) == 0 {
t.Fatalf("%s 讀不到內容或是空檔——不准放空檔/佔位符", e.Name())
}
}
// 不污染:Documents 底下只多出這一個資料夾,沒有散落在別處的殘留檔案
docsEntries, err := os.ReadDir(filepath.Join(home, "Documents"))
if err != nil {
t.Fatalf("讀不到 Documents%v", err)
}
if len(docsEntries) != 1 || docsEntries[0].Name() != defaultLibraryDirName {
names := []string{}
for _, e := range docsEntries {
names = append(names, e.Name())
}
t.Fatalf("Documents 底下應該只有預設庫這一個資料夾,實際:%v", names)
}
// marker 檔存在,代表「已經種過一次」
if _, err := os.Stat(defaultLibraryMarkerPath()); err != nil {
t.Fatalf("marker 檔沒寫出來:%v", err)
}
}
// TestDefaultLibraryDeletedDoesNotComeBack 釘住紅線①④:使用者把預設庫整個刪掉之後,
// ① config 裡的殘留引用要被清乾淨(不留幽靈資料)
// ② 就算之後又跑一次「種子」邏輯,也絕對不會把資料夾或引用生回來。
func TestDefaultLibraryDeletedDoesNotComeBack(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
srv := fakeDaemonConfigServer(t, "https://instance-b.example.workers.dev")
app := &App{}
if err := app.Connect(srv.URL, "evan@example.com", "pw"); err != nil {
t.Fatalf("Connect 失敗:%v", err)
}
path := defaultLibraryPath()
if _, err := os.Stat(path); err != nil {
t.Fatalf("前置條件失敗:預設庫應該已經存在:%v", err)
}
// 使用者從 Finder/檔案總管把整個資料夾刪掉
if err := os.RemoveAll(path); err != nil {
t.Fatalf("刪除失敗:%v", err)
}
// App 下一次讀狀態(等同 GetState 的自我修復邏輯)應該把殘留引用清掉
cfg, err := loadCfg()
if err != nil {
t.Fatalf("讀不回設定:%v", err)
}
if !pruneMissingDefaultLibrary(cfg) {
t.Fatal("應該偵測到預設庫已被刪除並回報 changed=true")
}
if err := saveCfg(cfg); err != nil {
t.Fatalf("存檔失敗:%v", err)
}
cfg2, _ := loadCfg()
for _, f := range cfg2.Accounts[0].WatchFolders {
if f == path {
t.Fatal("刪掉的預設庫路徑還留在 watch_folders 裡——幽靈資料")
}
}
// 再跑一次種子邏輯(模擬使用者重開程式、或又連了一次同一個帳號)——
// marker 檔還在,絕對不能把資料夾或引用生回來。
seedDefaultLibraryIfFirstEver(cfg2, 0)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("預設庫刪掉後又長回來了:%v", err)
}
for _, f := range cfg2.Accounts[0].WatchFolders {
if f == path {
t.Fatal("種子邏輯把已刪除的預設庫路徑又加回 watch_folders 了")
}
}
}
// TestDefaultLibraryNotSeededWhenAccountsAlreadyExist 釘住紅線②:
// 「只要有掛用戶自己的資料夾,就不再出現」——這台機器如果已經連過帳號,
// 之後不管再連幾個新帳號,都不應該冒出這個預設庫。
func TestDefaultLibraryNotSeededWhenAccountsAlreadyExist(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
// 模擬「這台機器已經有一個帳號,且已經掛了使用者自己的資料夾」
preexisting := &directConfig{
Accounts: []accountCfg{{
InstanceName: "既有帳號",
CypherURL: "https://instance-existing.example.workers.dev",
Namespace: "existing",
APIKey: "existing",
WatchFolders: []string{filepath.Join(home, "我的筆記")},
}},
Extractor: "workers-ai",
Manifest: filepath.Join(home, ".arcrun-rag", "manifest.json"),
}
if err := saveCfg(preexisting); err != nil {
t.Fatalf("前置存檔失敗:%v", err)
}
srv := fakeDaemonConfigServer(t, "https://instance-c.example.workers.dev")
app := &App{}
if err := app.Connect(srv.URL, "second@example.com", "pw"); err != nil {
t.Fatalf("Connect 失敗:%v", err)
}
if _, err := os.Stat(defaultLibraryPath()); !os.IsNotExist(err) {
t.Fatalf("已有帳號的機器不該冒出預設庫,但資料夾存在:err=%v", err)
}
if _, err := os.Stat(defaultLibraryMarkerPath()); !os.IsNotExist(err) {
t.Fatal("已有帳號的機器不該寫出 marker 檔")
}
cfg, _ := loadCfg()
if len(cfg.Accounts) != 2 {
t.Fatalf("應該有兩個帳號(既有 + 新連的),實際 %d", len(cfg.Accounts))
}
for _, acc := range cfg.Accounts {
for _, f := range acc.WatchFolders {
if f == defaultLibraryPath() {
t.Fatal("預設庫路徑不該出現在任何帳號的 watch_folders 裡")
}
}
}
}