返回 DeepSeek-Reasonix
store_v2_test.go
根目录 / internal / memory / store_v2_test.go
1 package memory
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "sync"
8 "testing"
9 )
10
11 func TestStoreV2StableIDSurvivesRenameAndRevisionIncrements(t *testing.T) {
12 store := Store{Dir: t.TempDir()}
13 first, err := store.SaveWithOptions(Memory{Name: "alpha", Description: "first", Body: "v1"}, SaveOptions{})
14 if err != nil {
15 t.Fatal(err)
16 }
17 if first.Memory.ID == "" || first.Memory.Revision != 1 {
18 t.Fatalf("first save = %+v", first.Memory)
19 }
20
21 second, err := store.SaveWithOptions(Memory{
22 ID: first.Memory.ID, Name: "beta", Description: "renamed", Body: "v2",
23 }, SaveOptions{ExpectedRevision: 1, RequireExpectedRevision: true})
24 if err != nil {
25 t.Fatal(err)
26 }
27 if second.Memory.ID != first.Memory.ID || second.Memory.Revision != 2 || second.Memory.Name != "beta" {
28 t.Fatalf("renamed save = %+v, first = %+v", second.Memory, first.Memory)
29 }
30 if _, err := os.Stat(filepath.Join(store.Dir, "alpha.md")); !os.IsNotExist(err) {
31 t.Fatalf("old slug path still active: %v", err)
32 }
33 if _, err := os.Stat(filepath.Join(store.Dir, "beta.md")); err != nil {
34 t.Fatalf("renamed path missing: %v", err)
35 }
36 }
37
38 func TestStoreV2IndexIsDerivedFromFacts(t *testing.T) {
39 store := Store{Dir: t.TempDir()}
40 if _, err := store.Save(Memory{Name: "source-of-truth", Title: "Source of truth", Description: "active fact", Body: "body"}); err != nil {
41 t.Fatal(err)
42 }
43 if err := os.WriteFile(filepath.Join(store.Dir, indexFile), []byte("# stale\n\n- [Ghost](ghost.md) — stale\n"), 0o644); err != nil {
44 t.Fatal(err)
45 }
46
47 index := store.Index()
48 if !strings.Contains(index, "[Source of truth](source-of-truth.md)") || strings.Contains(index, "Ghost") {
49 t.Fatalf("derived index used stale MEMORY.md:\n%s", index)
50 }
51 }
52
53 func TestStoreV2ExpectedRevisionRejectsLostUpdate(t *testing.T) {
54 store := Store{Dir: t.TempDir()}
55 first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "one", Body: "v1"}, SaveOptions{})
56 if err != nil {
57 t.Fatal(err)
58 }
59 if _, err := store.SaveWithOptions(Memory{ID: first.Memory.ID, Name: "fact", Description: "two", Body: "v2"}, SaveOptions{}); err != nil {
60 t.Fatal(err)
61 }
62 _, err = store.SaveWithOptions(Memory{ID: first.Memory.ID, Name: "fact", Description: "stale", Body: "lost"}, SaveOptions{
63 ExpectedRevision: 1, RequireExpectedRevision: true,
64 })
65 if err == nil || !strings.Contains(err.Error(), "revision conflict") {
66 t.Fatalf("stale save error = %v, want revision conflict", err)
67 }
68 got, ok := store.Read(first.Memory.ID)
69 if !ok || got.Body != "v2" || got.Revision != 2 {
70 t.Fatalf("stale update changed active memory: %+v, ok=%v", got, ok)
71 }
72 }
73
74 func TestStoreV2RestoreRevisionCreatesAuditedRevision(t *testing.T) {
75 store := Store{Dir: t.TempDir()}
76 first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "one", Body: "v1"}, SaveOptions{})
77 if err != nil {
78 t.Fatal(err)
79 }
80 if _, err := store.SaveWithOptions(Memory{ID: first.Memory.ID, Name: "fact", Description: "two", Body: "v2"}, SaveOptions{}); err != nil {
81 t.Fatal(err)
82 }
83 restored, err := store.Restore(first.Memory.ID, 1)
84 if err != nil {
85 t.Fatal(err)
86 }
87 if restored.Memory.ID != first.Memory.ID || restored.Memory.Revision != 3 || restored.Memory.Body != "v1" {
88 t.Fatalf("restored memory = %+v", restored.Memory)
89 }
90 if revisions := store.Revisions(first.Memory.ID); len(revisions) < 2 || revisions[0].Revision != 2 || revisions[1].Revision != 1 {
91 t.Fatalf("revision history = %+v, want revisions 2 and 1 newest first", revisions)
92 }
93 }
94
95 func TestStoreV2LegacyMemoryGetsDeterministicIdentity(t *testing.T) {
96 dir := t.TempDir()
97 legacy := "---\nname: legacy-fact\ndescription: old file\nmetadata:\n type: project\n scope: project\n---\n\nlegacy body\n"
98 if err := os.WriteFile(filepath.Join(dir, "legacy-fact.md"), []byte(legacy), 0o644); err != nil {
99 t.Fatal(err)
100 }
101 store := Store{Dir: dir}
102 first := store.List()
103 second := store.List()
104 if len(first) != 1 || len(second) != 1 || first[0].ID == "" || first[0].ID != second[0].ID || first[0].Revision != 1 {
105 t.Fatalf("legacy identities = first %+v second %+v", first, second)
106 }
107 if !strings.HasPrefix(first[0].ID, "legacy-") {
108 t.Fatalf("legacy id = %q, want recognizable deterministic id", first[0].ID)
109 }
110 }
111
112 func TestStoreV2MigrationPersistsLegacyIdentity(t *testing.T) {
113 dir := t.TempDir()
114 path := filepath.Join(dir, "legacy-fact.md")
115 legacy := "---\nname: legacy-fact\ndescription: old file\nmetadata:\n type: project\n scope: project\n---\n\nlegacy body\n"
116 if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil {
117 t.Fatal(err)
118 }
119 store := Store{Dir: dir}
120 report, err := store.MigrateV2()
121 if err != nil {
122 t.Fatal(err)
123 }
124 if report.Migrated != 1 {
125 t.Fatalf("migration report = %+v", report)
126 }
127 frontmatter, _ := splitFrontmatter(mustReadString(t, path))
128 if !strings.HasPrefix(frontmatter["id"], "legacy-") || frontmatter["revision"] != "1" || frontmatter["created_at"] == "" || frontmatter["updated_at"] == "" {
129 t.Fatalf("migrated frontmatter = %+v", frontmatter)
130 }
131 again, err := store.MigrateV2()
132 if err != nil || again.Migrated != 0 {
133 t.Fatalf("idempotent migration = %+v, %v", again, err)
134 }
135 }
136
137 func TestStoreV2LegacyIdentityIncludesScope(t *testing.T) {
138 root := t.TempDir()
139 store := Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")}
140 for _, fixture := range []struct {
141 dir string
142 scope FactScope
143 }{
144 {dir: store.Dir, scope: FactScopeProject},
145 {dir: store.GlobalDir, scope: FactScopeGlobal},
146 } {
147 if err := os.MkdirAll(fixture.dir, 0o755); err != nil {
148 t.Fatal(err)
149 }
150 legacy := "---\nname: shared-name\ndescription: old file\nmetadata:\n type: project\n scope: " + string(fixture.scope) + "\n---\n\nlegacy body\n"
151 if err := os.WriteFile(filepath.Join(fixture.dir, "shared-name.md"), []byte(legacy), 0o644); err != nil {
152 t.Fatal(err)
153 }
154 }
155
156 project, _, ok := store.findActiveInDir(store.Dir, "shared-name")
157 if !ok {
158 t.Fatal("project legacy memory not found")
159 }
160 global, _, ok := store.findActiveInDir(store.GlobalDir, "shared-name")
161 if !ok {
162 t.Fatal("global legacy memory not found")
163 }
164 if project.ID == global.ID {
165 t.Fatalf("legacy IDs collided across scopes: %q", project.ID)
166 }
167 }
168
169 func TestStoreV2ArchiveByIDOnlyArchivesMatchingIdentity(t *testing.T) {
170 root := t.TempDir()
171 store := Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")}
172 for _, fixture := range []struct {
173 dir string
174 id string
175 scope FactScope
176 }{
177 {dir: store.Dir, id: "mem-project", scope: FactScopeProject},
178 {dir: store.GlobalDir, id: "mem-global", scope: FactScopeGlobal},
179 } {
180 if err := os.MkdirAll(fixture.dir, 0o755); err != nil {
181 t.Fatal(err)
182 }
183 memory := Memory{ID: fixture.id, Revision: 1, Name: "shared-name", Description: string(fixture.scope), Scope: fixture.scope, Body: "body"}
184 if err := os.WriteFile(filepath.Join(fixture.dir, "shared-name.md"), []byte(render(memory, memory.Name)), 0o644); err != nil {
185 t.Fatal(err)
186 }
187 }
188
189 if _, err := store.Archive("mem-project"); err != nil {
190 t.Fatal(err)
191 }
192 if _, err := os.Stat(filepath.Join(store.Dir, "shared-name.md")); !os.IsNotExist(err) {
193 t.Fatalf("project memory remained active: %v", err)
194 }
195 if _, err := os.Stat(filepath.Join(store.GlobalDir, "shared-name.md")); err != nil {
196 t.Fatalf("global memory with a different ID was archived: %v", err)
197 }
198 }
199
200 func TestStoreV2ScopeQualifiedReferencesRoundTrip(t *testing.T) {
201 root := t.TempDir()
202 store := Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")}
203 global, err := store.SaveWithOptions(Memory{
204 Name: "global/shared.md", Description: "global", Body: "global body",
205 }, SaveOptions{})
206 if err != nil {
207 t.Fatal(err)
208 }
209 project, err := store.SaveWithOptions(Memory{
210 Name: "project/shared.md", Description: "project", Body: "project body",
211 }, SaveOptions{})
212 if err != nil {
213 t.Fatal(err)
214 }
215 if global.Memory.Name != "shared" || global.Memory.Scope != FactScopeGlobal {
216 t.Fatalf("global memory = %+v", global.Memory)
217 }
218 if project.Memory.Name != "shared" || project.Memory.Scope != FactScopeProject {
219 t.Fatalf("project memory = %+v", project.Memory)
220 }
221 if got, ok := store.Read("global/shared.md"); !ok || got.ID != global.Memory.ID || got.Body != "global body" {
222 t.Fatalf("global qualified read = %+v, ok=%v", got, ok)
223 }
224 if got, ok := store.Read("project/shared.md"); !ok || got.ID != project.Memory.ID || got.Body != "project body" {
225 t.Fatalf("project qualified read = %+v, ok=%v", got, ok)
226 }
227 if got, ok := store.Read("shared.md"); !ok || got.ID != project.Memory.ID {
228 t.Fatalf("legacy unqualified read = %+v, ok=%v; want project-preferred", got, ok)
229 }
230
231 updated, err := store.SaveWithOptions(Memory{
232 Name: "global/shared.md", Description: "updated global", Body: "global v2",
233 }, SaveOptions{ExpectedRevision: 1, RequireExpectedRevision: true})
234 if err != nil {
235 t.Fatal(err)
236 }
237 if updated.Memory.Name != "shared" || updated.Memory.Scope != FactScopeGlobal || updated.Memory.Revision != 2 {
238 t.Fatalf("qualified update = %+v", updated.Memory)
239 }
240 if _, err := os.Stat(filepath.Join(store.GlobalDir, "shared.md")); err != nil {
241 t.Fatalf("qualified update renamed or moved global fact: %v", err)
242 }
243 if got, ok := store.Read("project/shared.md"); !ok || got.ID != project.Memory.ID {
244 t.Fatalf("qualified update disturbed project fact: %+v, ok=%v", got, ok)
245 }
246 if revisions := store.Revisions("global/shared.md"); len(revisions) != 1 || revisions[0].Revision != 1 {
247 t.Fatalf("qualified revisions = %+v", revisions)
248 }
249
250 if _, err := store.Archive("project/shared.md"); err != nil {
251 t.Fatal(err)
252 }
253 if _, ok := store.Read("project/shared.md"); ok {
254 t.Fatal("qualified archive left project fact active")
255 }
256 if got, ok := store.Read("global/shared.md"); !ok || got.ID != global.Memory.ID || got.Revision != 2 {
257 t.Fatalf("qualified archive disturbed global fact: %+v, ok=%v", got, ok)
258 }
259 }
260
261 func TestStoreV2RejectsConflictingQualifiedReferenceScope(t *testing.T) {
262 root := t.TempDir()
263 store := Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")}
264 _, err := store.SaveWithOptions(Memory{
265 Name: "global/fact.md", Scope: FactScopeProject, Description: "conflict", Body: "body",
266 }, SaveOptions{})
267 if err == nil || !strings.Contains(err.Error(), "conflicts") {
268 t.Fatalf("conflicting qualified reference error = %v", err)
269 }
270 if len(store.ListAll()) != 0 {
271 t.Fatalf("conflicting reference wrote a fact: %+v", store.ListAll())
272 }
273 }
274
275 func TestStoreV2RestoreArchivedPreservesIdentityAndCreatesRevision(t *testing.T) {
276 store := Store{Dir: t.TempDir()}
277 first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "first", Body: "v1"}, SaveOptions{})
278 if err != nil {
279 t.Fatal(err)
280 }
281 archivePath, err := store.Archive(first.Memory.ID)
282 if err != nil {
283 t.Fatal(err)
284 }
285
286 restored, err := store.RestoreArchived(archivePath)
287 if err != nil {
288 t.Fatal(err)
289 }
290 if restored.Memory.ID != first.Memory.ID || restored.Memory.Revision != 2 || restored.Memory.Body != "v1" {
291 t.Fatalf("restored memory = %+v, first = %+v", restored.Memory, first.Memory)
292 }
293 if _, err := os.Stat(archivePath); !os.IsNotExist(err) {
294 t.Fatalf("restored archive should leave the archive list: %v", err)
295 }
296 revisions := store.Revisions(first.Memory.ID)
297 if len(revisions) != 1 || revisions[0].Revision != 1 || revisions[0].Body != "v1" {
298 t.Fatalf("revision history = %+v, want archived revision 1", revisions)
299 }
300 }
301
302 func TestStoreV2RestoreArchivedRejectsActiveCollisions(t *testing.T) {
303 store := Store{Dir: t.TempDir()}
304 first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "first", Body: "archived"}, SaveOptions{})
305 if err != nil {
306 t.Fatal(err)
307 }
308 archivePath, err := store.Archive(first.Memory.ID)
309 if err != nil {
310 t.Fatal(err)
311 }
312 if _, err := store.SaveWithOptions(Memory{Name: "fact", Description: "replacement", Body: "active"}, SaveOptions{}); err != nil {
313 t.Fatal(err)
314 }
315
316 if _, err := store.RestoreArchived(archivePath); err == nil || !strings.Contains(err.Error(), "already active") {
317 t.Fatalf("restore collision error = %v, want already active", err)
318 }
319 active, ok := store.Read("fact")
320 if !ok || active.Body != "active" {
321 t.Fatalf("restore collision overwrote active fact: %+v, ok=%v", active, ok)
322 }
323 if _, err := os.Stat(archivePath); err != nil {
324 t.Fatalf("failed restore should preserve archive: %v", err)
325 }
326 }
327
328 func TestStoreV2RestoreArchivedIsConcurrencySafe(t *testing.T) {
329 store := Store{Dir: t.TempDir()}
330 first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "first", Body: "v1"}, SaveOptions{})
331 if err != nil {
332 t.Fatal(err)
333 }
334 archivePath, err := store.Archive(first.Memory.ID)
335 if err != nil {
336 t.Fatal(err)
337 }
338
339 var wg sync.WaitGroup
340 errs := make(chan error, 2)
341 for range 2 {
342 wg.Add(1)
343 go func() {
344 defer wg.Done()
345 _, restoreErr := store.RestoreArchived(archivePath)
346 errs <- restoreErr
347 }()
348 }
349 wg.Wait()
350 close(errs)
351
352 var successes int
353 for restoreErr := range errs {
354 if restoreErr == nil {
355 successes++
356 }
357 }
358 if successes != 1 {
359 t.Fatalf("successful restores = %d, want exactly one", successes)
360 }
361 active, ok := store.Read(first.Memory.ID)
362 if !ok || active.Revision != 2 {
363 t.Fatalf("active memory = %+v, ok=%v", active, ok)
364 }
365 }
366
367 func TestStoreV2RestoreArchivedRejectsPathsOutsideStore(t *testing.T) {
368 store := Store{Dir: t.TempDir()}
369 outside := filepath.Join(t.TempDir(), "fact.md")
370 if err := os.WriteFile(outside, []byte("not an archive"), 0o644); err != nil {
371 t.Fatal(err)
372 }
373 if _, err := store.RestoreArchived(outside); err == nil || !strings.Contains(err.Error(), "not found") {
374 t.Fatalf("outside restore error = %v, want archive not found", err)
375 }
376 }
377
378 func TestStoreV2RestoreArchivedRejectsSymlinkEntry(t *testing.T) {
379 store := Store{Dir: t.TempDir()}
380 archiveDir := filepath.Join(store.Dir, ".archive")
381 if err := os.MkdirAll(archiveDir, 0o755); err != nil {
382 t.Fatal(err)
383 }
384 outside := filepath.Join(t.TempDir(), "fact.md")
385 if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
386 t.Fatal(err)
387 }
388 archivePath := filepath.Join(archiveDir, "fact.md")
389 if err := os.Symlink(outside, archivePath); err != nil {
390 t.Skipf("symlink unavailable: %v", err)
391 }
392 if _, err := store.RestoreArchived(archivePath); err == nil || !strings.Contains(err.Error(), "not found") {
393 t.Fatalf("symlink restore error = %v, want archive not found", err)
394 }
395 if _, err := os.Stat(outside); err != nil {
396 t.Fatalf("outside target changed: %v", err)
397 }
398 }
399
400 func TestStoreV2RestoreArchivedRejectsSymlinkDirectory(t *testing.T) {
401 store := Store{Dir: t.TempDir()}
402 outsideDir := t.TempDir()
403 outside := filepath.Join(outsideDir, "fact.md")
404 if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
405 t.Fatal(err)
406 }
407 archiveDir := filepath.Join(store.Dir, ".archive")
408 if err := os.Symlink(outsideDir, archiveDir); err != nil {
409 t.Skipf("symlink unavailable: %v", err)
410 }
411 archivePath := filepath.Join(archiveDir, "fact.md")
412 if _, err := store.RestoreArchived(archivePath); err == nil || !strings.Contains(err.Error(), "not found") {
413 t.Fatalf("symlinked directory restore error = %v, want archive not found", err)
414 }
415 if _, err := os.Stat(outside); err != nil {
416 t.Fatalf("outside target changed: %v", err)
417 }
418 }
419
419 lines GO