返回 DeepSeek-Reasonix
topic_state_store_test.go
根目录 / desktop / topic_state_store_test.go
1 package main
2
3 import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "testing"
10
11 "reasonix/internal/config"
12 "reasonix/internal/topicstate"
13 )
14
15 func seedLegacyTopicBridge(t *testing.T, workspaceRoot string) {
16 t.Helper()
17 for _, path := range legacyTopicPaths(workspaceRoot) {
18 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
19 t.Fatal(err)
20 }
21 if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
22 t.Fatal(err)
23 }
24 }
25 }
26
27 func TestFreshTopicStateUsesSQLiteWithoutLegacyFiles(t *testing.T) {
28 isolateDesktopUserDirs(t)
29 workspaceRoot := t.TempDir()
30 if err := setTopicTitle(workspaceRoot, "topic-fresh", "Fresh"); err != nil {
31 t.Fatal(err)
32 }
33 if got := loadTopicTitle(workspaceRoot, "topic-fresh"); got != "Fresh" {
34 t.Fatalf("title = %q", got)
35 }
36 if _, err := os.Stat(config.DesktopTopicStatePath(workspaceRoot)); err != nil {
37 t.Fatalf("SQLite topic state missing: %v", err)
38 }
39 for _, path := range legacyTopicPaths(workspaceRoot) {
40 if _, err := os.Stat(path); !os.IsNotExist(err) {
41 t.Fatalf("fresh workspace created legacy file %s: %v", filepath.Base(path), err)
42 }
43 }
44 }
45
46 func TestLegacyTopicStateMigratesAndContinuesMirroring(t *testing.T) {
47 isolateDesktopUserDirs(t)
48 workspaceRoot := t.TempDir()
49 paths := legacyTopicPaths(workspaceRoot)
50 for _, path := range paths {
51 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
52 t.Fatal(err)
53 }
54 }
55 if err := os.WriteFile(paths[0], []byte(`{"topic-old":"Old title"}`), 0o600); err != nil {
56 t.Fatal(err)
57 }
58 if err := os.WriteFile(paths[1], []byte(`{"topic-old":"manual"}`), 0o600); err != nil {
59 t.Fatal(err)
60 }
61 if err := os.WriteFile(paths[2], []byte(`{"topic-old":1234}`), 0o600); err != nil {
62 t.Fatal(err)
63 }
64 if err := os.WriteFile(paths[3], []byte(`{"topic-old":{"stage":1,"basisHash":"old","future":{"kept":true}}}`), 0o600); err != nil {
65 t.Fatal(err)
66 }
67
68 if got := loadTopicTitle(workspaceRoot, "topic-old"); got != "Old title" {
69 t.Fatalf("migrated title = %q", got)
70 }
71 if err := setTopicCreatedAt(workspaceRoot, "topic-old", 5678); err != nil {
72 t.Fatal(err)
73 }
74 if err := recordTopicAutoTitleMeta(workspaceRoot, "topic-old", autoTopicTitleProposal{Stage: 2, UserTurns: 3, BasisHash: "new"}); err != nil {
75 t.Fatal(err)
76 }
77 legacyTitles, err := loadLegacyStringMap(paths[0])
78 if err != nil {
79 t.Fatal(err)
80 }
81 if got := legacyTitles["topic-old"]; got != "Old title" {
82 t.Fatalf("legacy mirror title = %q", got)
83 }
84 var raw map[string]json.RawMessage
85 data, err := os.ReadFile(paths[3])
86 if err != nil {
87 t.Fatal(err)
88 }
89 if err := json.Unmarshal(data, &raw); err != nil {
90 t.Fatal(err)
91 }
92 if !json.Valid(raw["topic-old"]) || !containsJSONKey(raw["topic-old"], "future") {
93 t.Fatalf("unknown auto-title metadata was lost: %s", raw["topic-old"])
94 }
95 var meta topicAutoTitleMeta
96 if err := json.Unmarshal(raw["topic-old"], &meta); err != nil || meta.Stage != 2 || meta.BasisHash != "new" {
97 t.Fatalf("known auto-title metadata was not updated: %+v, %v", meta, err)
98 }
99 }
100
101 func TestLegacyWriterChangeMergesOnNextNewWrite(t *testing.T) {
102 isolateDesktopUserDirs(t)
103 workspaceRoot := t.TempDir()
104 seedLegacyTopicBridge(t, workspaceRoot)
105 if err := setTopicTitle(workspaceRoot, "topic-old", "Before downgrade"); err != nil {
106 t.Fatal(err)
107 }
108 if err := setLegacyTopicTitle(workspaceRoot, "topic-old", "Changed by old version", topicTitleSourceManual); err != nil {
109 t.Fatal(err)
110 }
111 if err := setTopicCreatedAt(workspaceRoot, "topic-old", 999); err != nil {
112 t.Fatal(err)
113 }
114 if got := loadTopicTitle(workspaceRoot, "topic-old"); got != "Changed by old version" {
115 t.Fatalf("reconciled title = %q", got)
116 }
117 }
118
119 func TestLegacyMigrationSkipsInvalidEntriesAndDeletedTopics(t *testing.T) {
120 isolateDesktopUserDirs(t)
121 workspaceRoot := t.TempDir()
122 if err := addProject(workspaceRoot, ""); err != nil {
123 t.Fatal(err)
124 }
125 if err := updateProjectsFile(func(file *desktopProjectFile) (bool, error) {
126 file.DeletedTopics = append(file.DeletedTopics, "topic-deleted")
127 return true, nil
128 }); err != nil {
129 t.Fatal(err)
130 }
131 path := topicTitlesPath(workspaceRoot)
132 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
133 t.Fatal(err)
134 }
135 if err := os.WriteFile(path, []byte(`{"topic-good":"Good","topic-invalid":7,"topic-deleted":"Do not restore"}`), 0o600); err != nil {
136 t.Fatal(err)
137 }
138 titles := loadTopicTitles(workspaceRoot)
139 if titles["topic-good"] != "Good" || titles["topic-invalid"] != "" || titles["topic-deleted"] != "" {
140 t.Fatalf("migrated titles = %#v", titles)
141 }
142 legacy, err := loadLegacyStringMap(path)
143 if err != nil {
144 t.Fatal(err)
145 }
146 if _, ok := legacy["topic-deleted"]; ok {
147 t.Fatalf("legacy mirror resurrected tombstone: %#v", legacy)
148 }
149 }
150
151 func TestFutureTopicSchemaUsesLegacyReadOnlyFallback(t *testing.T) {
152 isolateDesktopUserDirs(t)
153 workspaceRoot := t.TempDir()
154 seedLegacyTopicBridge(t, workspaceRoot)
155 if err := setLegacyTopicTitle(workspaceRoot, "topic-old", "Readable by old version", topicTitleSourceManual); err != nil {
156 t.Fatal(err)
157 }
158 path := config.DesktopTopicStatePath(workspaceRoot)
159 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
160 t.Fatal(err)
161 }
162 db, err := sql.Open("sqlite", path)
163 if err != nil {
164 t.Fatal(err)
165 }
166 if _, err := db.Exec(`CREATE TABLE schema_migrations(version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil {
167 t.Fatal(err)
168 }
169 if _, err := db.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(2, 0)`); err != nil {
170 t.Fatal(err)
171 }
172 if err := db.Close(); err != nil {
173 t.Fatal(err)
174 }
175 if got := loadTopicTitle(workspaceRoot, "topic-old"); got != "Readable by old version" {
176 t.Fatalf("legacy read fallback = %q", got)
177 }
178 err = setTopicTitle(workspaceRoot, "topic-old", "must not overwrite")
179 var future *topicstate.FutureSchemaError
180 if !errors.As(err, &future) {
181 t.Fatalf("write error = %v, want FutureSchemaError", err)
182 }
183 legacy, err := loadLegacyStringMap(topicTitlesPath(workspaceRoot))
184 if err != nil {
185 t.Fatal(err)
186 }
187 if legacy["topic-old"] != "Readable by old version" {
188 t.Fatalf("future-schema write changed legacy data: %#v", legacy)
189 }
190 }
191
192 func TestCorruptTopicDatabaseRebuildsOnlyWithLegacySource(t *testing.T) {
193 t.Run("legacy recovery", func(t *testing.T) {
194 isolateDesktopUserDirs(t)
195 workspaceRoot := t.TempDir()
196 seedLegacyTopicBridge(t, workspaceRoot)
197 if err := setLegacyTopicTitle(workspaceRoot, "topic-old", "Recovered", topicTitleSourceManual); err != nil {
198 t.Fatal(err)
199 }
200 path := config.DesktopTopicStatePath(workspaceRoot)
201 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
202 t.Fatal(err)
203 }
204 if err := os.WriteFile(path, []byte("not a sqlite database"), 0o600); err != nil {
205 t.Fatal(err)
206 }
207 if got := loadTopicTitle(workspaceRoot, "topic-old"); got != "Recovered" {
208 t.Fatalf("recovered title = %q", got)
209 }
210 if matches, _ := filepath.Glob(path + ".corrupt-*"); len(matches) != 1 {
211 t.Fatalf("corrupt backups = %#v", matches)
212 }
213 })
214
215 t.Run("no recovery source", func(t *testing.T) {
216 isolateDesktopUserDirs(t)
217 workspaceRoot := t.TempDir()
218 path := config.DesktopTopicStatePath(workspaceRoot)
219 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
220 t.Fatal(err)
221 }
222 original := []byte("not a sqlite database")
223 if err := os.WriteFile(path, original, 0o600); err != nil {
224 t.Fatal(err)
225 }
226 if err := setTopicTitle(workspaceRoot, "topic-new", "No silent reset"); err == nil {
227 t.Fatal("write unexpectedly replaced corrupt authoritative database")
228 }
229 if got, err := os.ReadFile(path); err != nil || string(got) != string(original) {
230 t.Fatalf("corrupt database changed: %q, %v", got, err)
231 }
232 if matches, _ := filepath.Glob(path + ".corrupt-*"); len(matches) != 0 {
233 t.Fatalf("database without recovery source was quarantined: %#v", matches)
234 }
235 })
236 }
237
238 func containsJSONKey(data []byte, key string) bool {
239 var fields map[string]json.RawMessage
240 return json.Unmarshal(data, &fields) == nil && fields[key] != nil
241 }
242
242 lines GO