返回 DeepSeek-Reasonix
session_purge_sidebar_test.go
根目录 / desktop / session_purge_sidebar_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "testing"
8 "time"
9
10 "reasonix/desktop/internal/workspacestate"
11 "reasonix/internal/session"
12 )
13
14 func TestEmptyTrashDoesNotResurrectManualTopics(t *testing.T) {
15 for _, scope := range []string{"global", "project"} {
16 t.Run(scope, func(t *testing.T) { checkEmptyTrashDoesNotResurrectManualTopics(t, scope) })
17 }
18 }
19
20 func TestPurgeSharedTopicKeepsSurvivingSession(t *testing.T) {
21 a := newSavedTabReconcileTestApp(t)
22 t.Cleanup(a.closeSessionServices)
23 root := t.TempDir()
24 topic, err := a.CreateTopic("project", root, "Shared history")
25 if err != nil {
26 t.Fatal(err)
27 }
28 var refs []session.SessionRef
29 for _, id := range []string{"purge-shared-old", "purge-shared-live"} {
30 ref, _ := createLegacyCleanupSession(t, a, root, id, true)
31 if err := a.workspaceRegistry().EnsureSessionTopic(t.Context(), id, topic.ID, "Shared history"); err != nil {
32 t.Fatal(err)
33 }
34 refs = append(refs, ref)
35 }
36 for index, ref := range refs {
37 if err := a.ArchiveCanonicalSession(ref); err != nil {
38 t.Fatal(err)
39 }
40 if err := a.PurgeCanonicalSession(ref); err != nil {
41 t.Fatal(err)
42 }
43 page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 50})
44 if err != nil {
45 t.Fatal(err)
46 }
47 if index == 0 {
48 if len(page.Items) != 1 || page.Items[0].Session == nil || *page.Items[0].Session != refs[1] {
49 t.Fatalf("shared live history hidden: %+v", page)
50 }
51 } else if len(page.Items) != 0 {
52 t.Fatalf("last purged session left a placeholder: %+v", page)
53 }
54 }
55 if _, err := a.ActivateTopic("project", root, topic.ID, ""); err == nil {
56 t.Fatal("purged non-manual topic revived")
57 }
58 }
59
60 func TestPurgedTopicEvidencePreservesSurvivingOwners(t *testing.T) {
61 state := workspacestate.State{
62 SessionStates: map[string]workspacestate.SessionState{},
63 Presentation: map[string]workspacestate.Presentation{},
64 PendingOperations: map[string]workspacestate.Operation{},
65 PendingCreates: map[string]workspacestate.PendingCreate{},
66 }
67 // An older import operation can retain exact topic ownership even if its
68 // previous purge writer omitted that evidence from the purge operation.
69 state.SessionStates["old-import"] = workspacestate.SessionState{Lifecycle: workspacestate.Deleted}
70 state.PendingOperations["import"] = workspacestate.Operation{Kind: "import", Phase: "committed", SessionIDs: []string{"old-import"}, Presentation: &workspacestate.Presentation{TopicID: "imported-topic"}}
71 if !purgedCanonicalTopicIDs(state)["imported-topic"] {
72 t.Fatal("lost previous import tombstone evidence")
73 }
74 state.PendingCreates["new"] = workspacestate.PendingCreate{Presentation: &workspacestate.Presentation{TopicID: "imported-topic"}}
75 if purgedCanonicalTopicIDs(state)["imported-topic"] {
76 t.Fatal("pending owner was hidden")
77 }
78 delete(state.PendingCreates, "new")
79 state.SessionStates["restored"] = workspacestate.SessionState{Lifecycle: workspacestate.Active}
80 state.Presentation["restored"] = workspacestate.Presentation{TopicID: "imported-topic"}
81 if purgedCanonicalTopicIDs(state)["imported-topic"] {
82 t.Fatal("active owner was hidden")
83 }
84 }
85
86 func checkEmptyTrashDoesNotResurrectManualTopics(t *testing.T, scope string) {
87 a := newManualSessionTestApp(t)
88 root := ""
89 if scope == "project" {
90 root = t.TempDir()
91 }
92 var refs []session.SessionRef
93 var topics []string
94 for i := range 4 {
95 view, err := a.BeginManualSessionCreation(ManualSessionCreationRequest{OperationID: fmt.Sprintf("purge-sidebar-%d", i), Scope: scope, WorkspaceRoot: root})
96 if err != nil {
97 t.Fatal(err)
98 }
99 refs = append(refs, view.Ref)
100 topics = append(topics, view.TopicID)
101 }
102 a.manualCreationTasks.Wait()
103 for _, ref := range refs {
104 if err := a.ArchiveCanonicalSession(ref); err != nil {
105 t.Fatal(err)
106 }
107 }
108 assertEmpty := func(app *App, stage string) {
109 t.Helper()
110 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 50})
111 if err != nil || len(page.Items) != 0 {
112 t.Fatalf("%s sidebar resurrected topics: %+v %v", stage, page.Items, err)
113 }
114 }
115 assertEmpty(a, "archived")
116 trash, err := a.ListTrashEntries("", "", 50)
117 if err != nil || len(trash.Items) != 4 {
118 t.Fatalf("trash: %+v %v", trash, err)
119 }
120 request := SessionLifecycleRequest{OperationID: "empty-sidebar-trash", Action: "purge", ExpectedGeneration: trash.Generation}
121 for _, row := range trash.Items {
122 request.Targets = append(request.Targets, SessionLifecycleTarget{Ref: row.Ref, WorkspaceID: row.WorkspaceID})
123 }
124 result, err := a.ApplySessionLifecycle(request)
125 if err != nil || !result.Committed {
126 t.Fatalf("empty trash: %+v %v", result, err)
127 }
128 assertEmpty(a, "purged")
129 for range 2 {
130 for _, topic := range topics {
131 if _, err := a.ActivateTopic(scope, root, topic, ""); err == nil {
132 t.Fatal("stale deleted topic click created a replacement session")
133 }
134 }
135 }
136 assertEmpty(a, "after stale clicks")
137 waitForInitialCatalogReconcile(t, a)
138 t.Cleanup(func() { a.stopSessionCatalog(time.Second) })
139 assertEmpty(a, "catalog ready")
140 state, err := a.workspaceRegistry().Load(t.Context())
141 if err != nil {
142 t.Fatal(err)
143 }
144 if len(state.SessionStates) != 4 {
145 t.Fatalf("stale click added sessions: %+v", state.SessionStates)
146 }
147 for _, ref := range refs {
148 op := state.PendingOperations["purge-"+ref.SessionID]
149 if op.Presentation == nil || op.Presentation.TopicID == "" {
150 t.Fatal("purge lost topic ownership")
151 }
152 // Simulate a previous writer that erased topic ownership on purge.
153 op.Presentation, op.WorkspaceID = nil, ""
154 state.PendingOperations[op.ID] = op
155 }
156 body, err := json.Marshal(state)
157 if err != nil {
158 t.Fatal(err)
159 }
160 if err := os.WriteFile(a.workspaceRegistry().Path(), body, 0600); err != nil {
161 t.Fatal(err)
162 }
163 reopened := NewApp()
164 reopened.ctx = t.Context()
165 reopened.desktopSessions.root = a.desktopSessions.root
166 installNoopRuntimeEvents(reopened)
167 t.Cleanup(func() {
168 reopened.closeSessionServices()
169 _ = reopened.sessionUI.Close()
170 _ = reopened.desktopDrafts.Close()
171 })
172 assertEmpty(reopened, "restart with previous-writer tombstones")
173 if _, err := reopened.ActivateTopic(scope, root, topics[0], ""); err == nil {
174 t.Fatal("previous-writer stale topic revived after restart")
175 }
176 placeholder, err := reopened.CreateTopic(scope, root, "Untouched empty topic")
177 if err != nil {
178 t.Fatal(err)
179 }
180 page, err := reopened.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 50})
181 if err != nil || len(page.Items) != 1 || page.Items[0].TopicID != placeholder.ID {
182 t.Fatalf("unrelated placeholder lost: %+v %v", page, err)
183 }
184 }
185
185 lines GO