ax_rd
7 hours ago 9587af9c909e7a6e799b663c4d5be4b50f68382e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
package index
 
import (
    "database/sql"
    "os"
    "path/filepath"
    "testing"
 
    "github.com/aisim/kb-cli/internal/graph"
)
 
// newV1Store 手工构建一个 v1 schema 的老库(模拟迁移前状态)
func newV1Store(t *testing.T) (*Store, string) {
    t.Helper()
    dbPath := filepath.Join(t.TempDir(), "kb-v1.db")
    db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
    if err != nil {
        t.Fatal(err)
    }
    schema := `
    CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
    CREATE TABLE nodes (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        path TEXT NOT NULL UNIQUE,
        title TEXT, section TEXT, tags TEXT, entities TEXT, wikilinks TEXT,
        content_fts TEXT,
        created_at TEXT DEFAULT (datetime('now')),
        updated_at TEXT DEFAULT (datetime('now')));
    CREATE TABLE edges (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        from_node INTEGER NOT NULL, to_node INTEGER NOT NULL,
        relation TEXT NOT NULL, label TEXT,
        UNIQUE(from_node, to_node, relation, label));
    `
    if _, err := db.Exec(schema); err != nil {
        t.Fatal(err)
    }
    if _, err := db.Exec(`INSERT INTO nodes (path, title, tags, entities, wikilinks, content_fts)
        VALUES ('FAQ/001-测试.md', '测试', '["a"]', '[]', '[]', '内容')`); err != nil {
        t.Fatal(err)
    }
    return &Store{db: db, dbPath: dbPath}, dbPath
}
 
func TestMigrateV1ToV2(t *testing.T) {
    store, dbPath := newV1Store(t)
    defer store.Close()
 
    // 迁移前备份文件必须存在
    if err := store.migrate(); err != nil {
        t.Fatalf("migrate: %v", err)
    }
    if _, err := os.Stat(dbPath + ".bak"); err != nil {
        t.Fatalf("备份文件不存在: %v", err)
    }
 
    // v2 列必须存在
    cols := store.nodeColumns(t)
    for _, want := range []string{"aliases", "status", "size", "mtime", "content_hash"} {
        if !cols[want] {
            t.Errorf("nodes 缺列 %s", want)
        }
    }
    if !store.edgeHasProvenance(t) {
        t.Error("edges 缺 provenance 列")
    }
    if !store.tableExists(t, "unresolved_links") {
        t.Error("缺 unresolved_links 表")
    }
 
    // 数据保留
    var title string
    if err := store.db.QueryRow(`SELECT title FROM nodes WHERE path='FAQ/001-测试.md'`).Scan(&title); err != nil {
        t.Fatal(err)
    }
    if title != "测试" {
        t.Errorf("迁移丢数据: %s", title)
    }
}
 
func TestMigrateIdempotent(t *testing.T) {
    store, _ := newV1Store(t)
    defer store.Close()
    if err := store.migrate(); err != nil {
        t.Fatalf("第一次: %v", err)
    }
    if err := store.migrate(); err != nil {
        t.Fatalf("第二次(幂等): %v", err)
    }
}
 
func TestNewDBIsV2(t *testing.T) {
    dbPath := filepath.Join(t.TempDir(), "kb-v2.db")
    store, err := Open(dbPath)
    if err != nil {
        t.Fatal(err)
    }
    defer store.Close()
    if !store.edgeHasProvenance(t) {
        t.Error("新建库应为 v2 schema")
    }
}
 
// TestMigrateVersionNotWrittenOnFailure 验证版本写入时序:
// 若 FTS 重建中途失败,schema_versions 中不得出现 version='2'
// (否则后续 Open 读到 version=2 直接早退,半迁移状态无法重试)。
// 手法:把 v1 库的 nodes.content_fts 列改名,FTS5 external-content
// 建表引用不存在的列必然真实失败(CreateFTS 第 1 步即报错)。
func TestMigrateVersionNotWrittenOnFailure(t *testing.T) {
    store, _ := newV1Store(t)
    defer store.Close()
 
    // 破坏 content 表:content_fts 列改名,使 FTS external-content 建表失败
    if _, err := store.db.Exec(`ALTER TABLE nodes RENAME COLUMN content_fts TO content_x`); err != nil {
        t.Fatal(err)
    }
 
    if err := store.migrate(); err == nil {
        t.Fatal("migrate 应返回 FTS 建表失败错误, 实际成功")
    }
 
    // 失败后版本号必须未写入(行不存在)
    var v string
    err := store.db.QueryRow(`SELECT value FROM schema_versions WHERE key='version'`).Scan(&v)
    if err == nil {
        t.Fatalf("迁移失败后版本号不应写入, 实际写入了: %s", v)
    }
    if err != sql.ErrNoRows {
        t.Fatalf("查询版本行出错: %v", err)
    }
}
 
// TestBackupWALConsistent 验证备份一致性:WAL 模式未 checkpoint 的
// 新增数据必须包含在 .bak 里(VACUUM INTO 合并 WAL;旧的
// ReadFile 直读主文件做不到)。
func TestBackupWALConsistent(t *testing.T) {
    store, dbPath := newV1Store(t)
    defer store.Close()
 
    // 通过活动连接插入一条数据,停留在未 checkpoint 的 WAL 中
    if _, err := store.db.Exec(`INSERT INTO nodes (path, title, content_fts)
        VALUES ('WAL/002-新节点.md', 'walnode', 'wal数据')`); err != nil {
        t.Fatal(err)
    }
 
    if err := store.backupDB(); err != nil {
        t.Fatalf("backupDB: %v", err)
    }
    // .bak 必须是合法 SQLite 库且包含 WAL 中未 checkpoint 的数据
    db, err := sql.Open("sqlite3", dbPath+".bak")
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()
    var n int
    if err := db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE path='WAL/002-新节点.md'`).Scan(&n); err != nil {
        t.Fatal(err)
    }
    if n != 1 {
        t.Fatalf("备份缺少 WAL 未 checkpoint 数据 (count=%d)", n)
    }
}
 
// TestFTSTriggers FTS 增删改触发器回归测试:
// 新建库 → 插节点 → FTS 可查 → UPDATE 改内容 → 新词可查/旧词不可查 → DELETE → 不可查。
// 注意:unicode61 分词下 CJK 连续串是整串 token,故用英文词验证触发器行为。
func TestFTSTriggers(t *testing.T) {
    store, err := Open(filepath.Join(t.TempDir(), "kb-fts.db"))
    if err != nil {
        t.Fatal(err)
    }
    defer store.Close()
 
    // INSERT 触发器:新插节点立即可被 FTS 查到
    id, err := store.InsertNode(&graph.Node{
        Path:    "TRG/001.md",
        Title:   "trigger node",
        Content: "alpha omega",
    })
    if err != nil {
        t.Fatal(err)
    }
    if got := store.ftsMatchCount(t, "alpha"); got != 1 {
        t.Fatalf("INSERT 后 FTS 应查到 1 条 alpha, 实际 %d", got)
    }
 
    // UPDATE 触发器:改内容后新词可查、旧词不可查
    if _, err := store.db.Exec(`UPDATE nodes SET content_fts=? WHERE id=?`, "beta delta", id); err != nil {
        t.Fatal(err)
    }
    if got := store.ftsMatchCount(t, "beta"); got != 1 {
        t.Fatalf("UPDATE 后 FTS 应查到 1 条 beta, 实际 %d", got)
    }
    if got := store.ftsMatchCount(t, "alpha"); got != 0 {
        t.Fatalf("UPDATE 后旧词 alpha 应不可查, 实际 %d 条", got)
    }
 
    // DELETE 触发器:删节点后 FTS 不可查
    if _, err := store.db.Exec(`DELETE FROM nodes WHERE id=?`, id); err != nil {
        t.Fatal(err)
    }
    if got := store.ftsMatchCount(t, "beta"); got != 0 {
        t.Fatalf("DELETE 后 FTS 不应查到 beta, 实际 %d 条", got)
    }
}
 
// ftsMatchCount 统计 FTS 虚拟表中匹配 term 的文档数(测试辅助)
func (s *Store) ftsMatchCount(t *testing.T, term string) int {
    t.Helper()
    var n int
    if err := s.db.QueryRow(`SELECT COUNT(*) FROM nodes_fts WHERE nodes_fts MATCH ?`, term).Scan(&n); err != nil {
        t.Fatalf("FTS MATCH %q: %v", term, err)
    }
    return n
}
 
// 测试辅助:列检查
func (s *Store) nodeColumns(t *testing.T) map[string]bool {
    t.Helper()
    rows, err := s.db.Query(`SELECT name FROM pragma_table_info('nodes')`)
    if err != nil {
        t.Fatal(err)
    }
    defer rows.Close()
    m := map[string]bool{}
    for rows.Next() {
        var name string
        rows.Scan(&name)
        m[name] = true
    }
    return m
}
 
func (s *Store) edgeHasProvenance(t *testing.T) bool {
    t.Helper()
    var n int
    s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('edges') WHERE name='provenance'`).Scan(&n)
    return n > 0
}
 
func (s *Store) tableExists(t *testing.T, name string) bool {
    t.Helper()
    var n int
    s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n)
    return n > 0
}