返回 DeepSeek-Reasonix
topic_removal_test.go
根目录 / desktop / topic_removal_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10
11 "reasonix/desktop/internal/workspacestate"
12 "reasonix/internal/topicstate"
13 )
14
15 func topicRemovalFixture(t *testing.T, scope, title string) (*App, TopicMeta, string) {
16 t.Helper()
17 isolateDesktopUserDirs(t)
18 a := NewApp()
19 a.ctx = t.Context()
20 installNoopRuntimeEvents(a)
21 t.Cleanup(a.closeSessionServices)
22 root := ""
23 if scope == "project" {
24 root = t.TempDir()
25 }
26 topic, err := a.CreateTopic(scope, root, title)
27 if err != nil {
28 t.Fatal(err)
29 }
30 return a, topic, root
31 }
32
33 func inspectedTopicRemoval(t *testing.T, a *App, topicID string) TopicRemovalRequest {
34 t.Helper()
35 view, err := a.InspectTopicRemoval(TopicRemovalTarget{TopicID: topicID})
36 if err != nil || !view.Allowed {
37 t.Fatalf("inspect: %+v %v", view, err)
38 }
39 return TopicRemovalRequest{OperationID: "remove-" + topicID, Target: view.Target, ExpectedToken: view.Token}
40 }
41
42 func TestTopicRemovalClassificationAndRestore(t *testing.T) {
43 for _, scope := range []string{"global", "project"} {
44 for _, kind := range []string{"default", "named", "manual-default", "pinned", "grouped", "unknown-source"} {
45 t.Run(scope+"/"+kind, func(t *testing.T) {
46 title := ""
47 if kind == "named" {
48 title = "My plan"
49 }
50 if kind == "manual-default" {
51 title = defaultTopicTitle
52 }
53 a, topic, root := topicRemovalFixture(t, scope, title)
54 if kind == "pinned" {
55 if err := a.SetTopicPinned(topic.ID, true); err != nil {
56 t.Fatal(err)
57 }
58 }
59 if kind == "grouped" {
60 if err := updateProjectsFile(func(file *desktopProjectFile) (bool, error) {
61 group := desktopGroup{ID: "group", Title: "Plans", TopicIDs: []string{topic.ID}}
62 if scope == "global" {
63 file.GlobalGroups = []desktopGroup{group}
64 } else {
65 file.Projects[projectIndexByRoot(file.Projects, root)].Groups = []desktopGroup{group}
66 }
67 return true, nil
68 }); err != nil {
69 t.Fatal(err)
70 }
71 }
72 if kind == "unknown-source" {
73 if err := desktopTopicState.withExclusiveScope(root, func(ctx context.Context, store *topicstate.Store) error {
74 _, err := store.Update(ctx, topic.ID, func(r *topicstate.Record) { r.TitleSource = "" })
75 return err
76 }); err != nil {
77 t.Fatal(err)
78 }
79 }
80 req := inspectedTopicRemoval(t, a, topic.ID)
81 out, err := a.RemoveTopic(req)
82 if err != nil || !out.Committed {
83 t.Fatalf("remove: %+v %v", out, err)
84 }
85 if topicIndexedInRegistry(scope, root, topic.ID) || loadTopicTitle(root, topic.ID) != "" {
86 t.Fatal("removed topic still present")
87 }
88 page, err := a.ListTrashEntries("", "", 50)
89 if err != nil {
90 t.Fatal(err)
91 }
92 if kind == "default" {
93 if len(page.Items) != 0 || out.Disposition != "discard_placeholder" {
94 t.Fatalf("default polluted trash: %+v %+v", out, page)
95 }
96 return
97 }
98 if len(page.Items) != 1 || !page.Items[0].CanRestore || page.Items[0].CanPreview {
99 t.Fatalf("metadata trash: %+v", page)
100 }
101 row := page.Items[0]
102 result, err := a.ApplySessionLifecycle(SessionLifecycleRequest{OperationID: "restore", Action: "restore", ExpectedGeneration: page.Generation, Targets: []SessionLifecycleTarget{{WorkspaceID: row.WorkspaceID, RecoveryEntryID: row.RecoveryEntryID}}})
103 if err != nil || !result.Committed {
104 t.Fatalf("restore: %+v %v", result, err)
105 }
106 if !topicIndexedInRegistry(scope, root, topic.ID) || loadTopicTitle(root, topic.ID) != topic.Title {
107 t.Fatal("restore lost metadata")
108 }
109 waitForInitialCatalogReconcile(t, a)
110 t.Cleanup(func() { a.stopSessionCatalog(time.Second) })
111 visible, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 50})
112 if err != nil || len(visible.Items) != 1 || visible.Items[0].TopicID != topic.ID {
113 t.Fatalf("restored placeholder missing from completed catalog projection: %+v %v", visible, err)
114 }
115 if kind == "pinned" || kind == "grouped" {
116 state, _ := a.workspaceRegistry().Load(t.Context())
117 file, _ := readTopicRemovalProjects()
118 item, err := topicRemovalCandidate(state, file, req.Target)
119 if err != nil || (kind == "pinned" && !item.Topic.Pinned) || (kind == "grouped" && item.Topic.GroupID != "group") {
120 t.Fatalf("organization not restored: %+v %v", item, err)
121 }
122 }
123 // A delayed duplicate removal cannot remove the restored topic again.
124 if out, err := a.RemoveTopic(req); err != nil || !out.Committed || !topicIndexedInRegistry(scope, root, topic.ID) {
125 t.Fatalf("duplicate removal replayed: %+v %v", out, err)
126 }
127 })
128 }
129 }
130 }
131
132 func TestTopicRemovalRejectsChangedConfirmation(t *testing.T) {
133 a, topic, _ := topicRemovalFixture(t, "global", "")
134 req := inspectedTopicRemoval(t, a, topic.ID)
135 if err := a.RenameTopic(topic.ID, "Keep this"); err != nil {
136 t.Fatal(err)
137 }
138 out, err := a.RemoveTopic(req)
139 if err != nil || out.Committed || out.ErrorCode != "state_conflict" {
140 t.Fatalf("stale confirmation: %+v %v", out, err)
141 }
142 if loadTopicTitle("", topic.ID) != "Keep this" {
143 t.Fatal("removed changed topic")
144 }
145 }
146
147 func interruptTopicRemoval(t *testing.T, a *App, phase string, work func()) {
148 t.Helper()
149 a.lifecycleCheckpointHook = func(at string) {
150 if at == phase {
151 panic("simulated process exit")
152 }
153 }
154 defer func() {
155 a.lifecycleCheckpointHook = nil
156 if recover() != "simulated process exit" {
157 t.Fatal("checkpoint was not reached")
158 }
159 }()
160 work()
161 }
162
163 func TestTopicRemovalRecoversInterruptedWrites(t *testing.T) {
164 for _, phase := range []string{"topic-removal-before-index", "topic-removal-before-metadata", "topic-removal-before-commit"} {
165 t.Run(phase, func(t *testing.T) {
166 a, topic, _ := topicRemovalFixture(t, "global", "Keep metadata")
167 req := inspectedTopicRemoval(t, a, topic.ID)
168 interruptTopicRemoval(t, a, phase, func() { _, _ = a.RemoveTopic(req) })
169 restarted := NewApp()
170 restarted.ctx = t.Context()
171 installNoopRuntimeEvents(restarted)
172 t.Cleanup(restarted.closeSessionServices)
173 state, err := restarted.workspaceRegistry().Load(t.Context())
174 if err != nil {
175 t.Fatal(err)
176 }
177 if err := restarted.reconcileTopicRemovals(state); err != nil {
178 t.Fatal(err)
179 }
180 page, err := restarted.ListTrashEntries("", "", 50)
181 if err != nil || len(page.Items) != 1 || !page.Items[0].CanRestore || topicIndexedInRegistry("global", "", topic.ID) {
182 t.Fatalf("recovery: %+v %v", page, err)
183 }
184 })
185 }
186 }
187
188 func TestTopicRemovalRecoveryDoesNotEraseNewerMetadata(t *testing.T) {
189 a, topic, _ := topicRemovalFixture(t, "global", "Keep metadata")
190 req := inspectedTopicRemoval(t, a, topic.ID)
191 interruptTopicRemoval(t, a, "topic-removal-before-index", func() { _, _ = a.RemoveTopic(req) })
192 if err := a.RenameTopic(topic.ID, "Newer title"); err != nil {
193 t.Fatal(err)
194 }
195 state, _ := a.workspaceRegistry().Load(t.Context())
196 if err := a.reconcileTopicRemovals(state); err == nil {
197 t.Fatal("unfenced replay")
198 }
199 if loadTopicTitle("", topic.ID) != "Newer title" {
200 t.Fatal("new title erased")
201 }
202 }
203
204 func TestTopicRemovalRestoreInterruption(t *testing.T) {
205 for _, phase := range []string{"topic-restore-before-metadata", "topic-restore-before-index", "topic-restore-before-commit"} {
206 t.Run(phase, func(t *testing.T) {
207 a, topic, _ := topicRemovalFixture(t, "global", "Restore me")
208 req := inspectedTopicRemoval(t, a, topic.ID)
209 if out, err := a.RemoveTopic(req); err != nil || !out.Committed {
210 t.Fatalf("remove: %+v %v", out, err)
211 }
212 interruptTopicRemoval(t, a, phase, func() { _ = a.restoreRemovedTopic(req.OperationID, req.Target.WorkspaceID) })
213 if err := a.restoreRemovedTopic(req.OperationID, req.Target.WorkspaceID); err != nil {
214 t.Fatal(err)
215 }
216 if loadTopicTitle("", topic.ID) != topic.Title || !topicIndexedInRegistry("global", "", topic.ID) {
217 t.Fatal("restore did not finish")
218 }
219 })
220 }
221 }
222
223 func TestTopicRemovalPurgeDoesNotTouchRestoredOrRecreatedTopic(t *testing.T) {
224 a, topic, _ := topicRemovalFixture(t, "global", "Archive me")
225 req := inspectedTopicRemoval(t, a, topic.ID)
226 if out, err := a.RemoveTopic(req); err != nil || !out.Committed {
227 t.Fatalf("remove: %+v %v", out, err)
228 }
229 if err := createTopicState("", topic.ID, "New occupant", topicTitleSourceManual, 123); err != nil {
230 t.Fatal(err)
231 }
232 if err := a.restoreRemovedTopic(req.OperationID, req.Target.WorkspaceID); !errors.Is(err, workspacestate.ErrMutationConflict) {
233 t.Fatalf("must not overwrite: %v", err)
234 }
235 if err := a.purgeRemovedTopic(req.OperationID, req.Target.WorkspaceID); err != nil {
236 t.Fatal(err)
237 }
238 if loadTopicTitle("", topic.ID) != "New occupant" {
239 t.Fatal("purge touched new occupant")
240 }
241 }
242
243 func TestTopicRemovalCorruptOrganizationIsNotEmpty(t *testing.T) {
244 a, topic, _ := topicRemovalFixture(t, "global", "")
245 if err := os.WriteFile(filepath.Join(desktopConfigDir(), desktopProjectOrganizationFile), []byte("broken"), 0600); err != nil {
246 t.Fatal(err)
247 }
248 if _, err := a.InspectTopicRemoval(TopicRemovalTarget{TopicID: topic.ID}); err == nil {
249 t.Fatal("corruption treated as no organization")
250 }
251 }
252
253 func TestTopicRemovalCanonicalBlankRetainsArchiveSemantics(t *testing.T) {
254 a, _, _ := topicRemovalFixture(t, "global", "")
255 tab, err := a.EnsureBlankTab("global", "")
256 if err != nil {
257 t.Fatal(err)
258 }
259 req := inspectedTopicRemoval(t, a, tab.TopicID)
260 out, err := a.RemoveTopic(req)
261 if err != nil || !out.Committed || out.Disposition != "archive_sessions" {
262 t.Fatalf("canonical removal: %+v %v", out, err)
263 }
264 page, err := a.ListTrashEntries("", "", 50)
265 if err != nil || len(page.Items) != 1 || page.Items[0].Ref == nil || page.Items[0].Ref.SessionID != tab.SessionID || !page.Items[0].CanRestore {
266 t.Fatalf("canonical blank must be restorable: %+v %v", page, err)
267 }
268 if out, err := a.RemoveTopic(req); err != nil || !out.Committed {
269 t.Fatalf("duplicate: %+v %v", out, err)
270 }
271 }
272
273 func TestTopicRemovalWriteFailureReturnsIncompleteAndRetriesSameIntent(t *testing.T) {
274 for _, phase := range []string{"topic-removal-before-index", "topic-removal-before-commit"} {
275 t.Run(phase, func(t *testing.T) {
276 a, topic, _ := topicRemovalFixture(t, "global", "Preserve on failure")
277 req := inspectedTopicRemoval(t, a, topic.ID)
278 path := filepath.Join(desktopConfigDir(), desktopProjectsFile)
279 if phase == "topic-removal-before-commit" {
280 path = a.workspaceRegistry().Path()
281 }
282 a.lifecycleCheckpointHook = func(at string) {
283 if at != phase {
284 return
285 }
286 if err := os.Rename(path, path+".saved"); err != nil {
287 t.Fatal(err)
288 }
289 if err := os.Mkdir(path, 0700); err != nil {
290 t.Fatal(err)
291 }
292 }
293 out, err := a.RemoveTopic(req)
294 a.lifecycleCheckpointHook = nil
295 if err != nil || out.Committed || out.ErrorCode != "operation_failed" {
296 t.Fatalf("write failure reported success: %+v %v", out, err)
297 }
298 if err := os.Remove(path); err != nil {
299 t.Fatal(err)
300 }
301 if err := os.Rename(path+".saved", path); err != nil {
302 t.Fatal(err)
303 }
304 out, err = a.RemoveTopic(req)
305 if err != nil || !out.Committed {
306 t.Fatalf("same intent retry: %+v %v", out, err)
307 }
308 page, err := a.ListTrashEntries("", "", 50)
309 if err != nil || len(page.Items) != 1 {
310 t.Fatalf("retry duplicated/lost archive: %+v %v", page, err)
311 }
312 })
313 }
314 }
315
316 func TestTopicRemovalWorkspaceUnavailableAndRestoreGroupMissing(t *testing.T) {
317 a, topic, root := topicRemovalFixture(t, "project", "Project plan")
318 if err := updateProjectsFile(func(file *desktopProjectFile) (bool, error) {
319 file.Projects[projectIndexByRoot(file.Projects, root)].Groups = []desktopGroup{{ID: "old-group", Title: "Old", TopicIDs: []string{topic.ID}}}
320 return true, nil
321 }); err != nil {
322 t.Fatal(err)
323 }
324 req := inspectedTopicRemoval(t, a, topic.ID)
325 if out, err := a.RemoveTopic(req); err != nil || !out.Committed {
326 t.Fatalf("remove: %+v %v", out, err)
327 }
328 if err := os.Rename(root, root+"-offline"); err != nil {
329 t.Fatal(err)
330 }
331 if err := a.restoreRemovedTopic(req.OperationID, req.Target.WorkspaceID); err == nil {
332 t.Fatal("restored into missing workspace")
333 }
334 if err := os.Rename(root+"-offline", root); err != nil {
335 t.Fatal(err)
336 }
337 if err := updateProjectsFile(func(file *desktopProjectFile) (bool, error) {
338 file.Projects[projectIndexByRoot(file.Projects, root)].Groups = nil
339 return true, nil
340 }); err != nil {
341 t.Fatal(err)
342 }
343 if err := a.restoreRemovedTopic(req.OperationID, req.Target.WorkspaceID); err != nil {
344 t.Fatal(err)
345 }
346 file := loadProjectsFile()
347 project := file.Projects[projectIndexByRoot(file.Projects, root)]
348 if len(project.Groups) != 0 || !containsDesktopString(project.Topics, topic.ID) {
349 t.Fatal("missing group recreated or topic not restored")
350 }
351 }
352
353 func TestTopicRemovalLegacyContentUsesCanonicalArchive(t *testing.T) {
354 a, topic, _ := topicRemovalFixture(t, "global", "Historical content")
355 dir := desktopSessionDir(globalWorkspaceRoot())
356 if err := os.MkdirAll(dir, 0700); err != nil {
357 t.Fatal(err)
358 }
359 writeTopicSessionWithPrompt(t, dir, "removal-legacy.jsonl", topic.ID, topic.Title, globalWorkspaceRoot(), "keep this message", time.Now())
360 // A real old source can outlive its desktop-projects index entry.
361 if err := updateProjectsFile(func(file *desktopProjectFile) (bool, error) {
362 file.GlobalTopics = removeString(file.GlobalTopics, topic.ID)
363 return true, nil
364 }); err != nil {
365 t.Fatal(err)
366 }
367 req := inspectedTopicRemoval(t, a, topic.ID)
368 out, err := a.RemoveTopic(req)
369 if err != nil || !out.Committed || out.Disposition != "archive_sessions" {
370 again, inspectErr := a.InspectTopicRemoval(req.Target)
371 t.Fatalf("legacy content removal: %+v %v request=%+v inspection=%+v %v", out, err, req, again, inspectErr)
372 }
373 page, err := a.ListTrashEntries("", "", 50)
374 if err != nil || len(page.Items) != 1 || !page.Items[0].CanRestore || page.Items[0].Ref == nil {
375 t.Fatalf("legacy content not archived: %+v %v", page, err)
376 }
377 }
378
379 func TestTopicRemovalRejectsAmbiguousLegacyOwner(t *testing.T) {
380 a, topic, _ := topicRemovalFixture(t, "global", "Keep both")
381 root := t.TempDir()
382 if err := addProject(root, "Other"); err != nil {
383 t.Fatal(err)
384 }
385 if err := updateProjectsFile(func(file *desktopProjectFile) (bool, error) {
386 i := projectIndexByRoot(file.Projects, root)
387 file.Projects[i].Topics = append(file.Projects[i].Topics, topic.ID)
388 return true, nil
389 }); err != nil {
390 t.Fatal(err)
391 }
392 if _, err := a.InspectTopicRemoval(TopicRemovalTarget{TopicID: topic.ID}); err == nil {
393 t.Fatal("ambiguous inspection accepted")
394 }
395 if err := a.TrashTopic(topic.ID); err == nil {
396 t.Fatal("legacy adapter removed ambiguous topic")
397 }
398 if !topicIndexedInRegistry("global", "", topic.ID) || !topicIndexedInRegistry("project", root, topic.ID) {
399 t.Fatal("ambiguous owners changed")
400 }
401 }
402
402 lines GO