返回 DeepSeek-Reasonix
topic_adoption_test.go
根目录 / desktop / internal / workspacestate / topic_adoption_test.go
1 package workspacestate
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "reflect"
8 "testing"
9
10 previous "reasonix/desktop/internal/workspacestate/testdata/v2previous"
11 )
12
13 func TestTopicAdoptionProjectionSurvivesPurgeAndReopen(t *testing.T) {
14 state := newState()
15 state.Workspaces["w"] = Workspace{ID: "w", SessionIDs: []string{"archived"}}
16 state.Presentation["archived"] = Presentation{TopicID: "archived-topic"}
17 state.SessionStates["archived"] = SessionState{Lifecycle: Archived}
18 state.SessionStates["purged"] = SessionState{Lifecycle: Deleted}
19 mapping := SourceMapping{SourceKey: "source", Path: "retained.jsonl", Format: "legacy", Fingerprint: "fingerprint", SessionID: "purged", WorkspaceID: "w"}
20 state.SourceMappings[mapping.SourceKey] = mapping
21 state.PendingOperations["import"] = Operation{ID: "import", Kind: "import", Lifecycle: Active, Phase: "committed", WorkspaceID: "w", Mapping: &mapping, Presentation: &Presentation{TopicID: "purged-topic"}}
22 state.TopicRemovals["remove"] = TopicRemoval{ID: "remove", WorkspaceID: "w", TopicID: "removed-topic", Disposition: "archive_sessions", Phase: "committed"}
23 state.TopicRemovals["pending"] = TopicRemoval{ID: "pending", WorkspaceID: "w", TopicID: "pending-topic", Disposition: "archive_sessions", Phase: "prepared"}
24 state.TopicRemovals["placeholder"] = TopicRemoval{ID: "placeholder", WorkspaceID: "w", TopicID: "placeholder-topic", Disposition: "archive_placeholder", Phase: "restored"}
25 state.PendingOperations["uncommitted"] = Operation{ID: "uncommitted", Kind: "import", Lifecycle: Active, Phase: "content_ready", WorkspaceID: "w", Mapping: &mapping, Presentation: &Presentation{TopicID: "not-adopted"}}
26 // Historical registries can retain multiple source/head receipts for one
27 // topic, old purge receipts with no presentation, and a failed new archive.
28 // None of that should make the consumed topic into a fresh placeholder.
29 head := mapping
30 head.SourceKey, head.HeadID = "source-main", "main"
31 state.SourceMappings[head.SourceKey] = head
32 state.PendingOperations["import-main"] = Operation{ID: "import-main", Kind: "import", Lifecycle: Active, Phase: "committed", WorkspaceID: "w", Mapping: &head, Presentation: &Presentation{TopicID: "purged-topic"}}
33 sibling := mapping
34 sibling.SourceKey, sibling.SessionID = "source-sibling", "purged-sibling"
35 state.SourceMappings[sibling.SourceKey] = sibling
36 state.SessionStates[sibling.SessionID] = SessionState{Lifecycle: Deleted}
37 state.PendingOperations["import-sibling"] = Operation{ID: "import-sibling", Kind: "import", Lifecycle: Archived, Phase: "committed", WorkspaceID: "w", Mapping: &sibling, Presentation: &Presentation{TopicID: "purged-topic"}}
38 for _, id := range []string{"purged", "purged-sibling"} {
39 state.PendingOperations["purge-"+id] = Operation{ID: "purge-" + id, Kind: "purge", Lifecycle: Deleted, Phase: "committed", SessionIDs: []string{id}}
40 }
41 state.TopicRemovals["retry-purged"] = TopicRemoval{ID: "retry-purged", WorkspaceID: "w", TopicID: "purged-topic", Disposition: "archive_sessions", Phase: "prepared"}
42 want := map[string]bool{"archived-topic": true, "purged-topic": true, "removed-topic": true}
43 body, err := json.Marshal(state)
44 if err != nil {
45 t.Fatal(err)
46 }
47 path := filepath.Join(t.TempDir(), "state.json")
48 if err := os.WriteFile(path, body, 0600); err != nil {
49 t.Fatal(err)
50 }
51 for range 2 {
52 store := NewStore(path)
53 projection, err := store.LoadProjection(t.Context())
54 if err != nil {
55 t.Fatal(err)
56 }
57 if got := projection.AdoptedTopicIDs("w"); !reflect.DeepEqual(got, want) {
58 t.Fatalf("adopted topics = %v; want %v", got, want)
59 }
60 if len(projection.AdoptedTopicIDs("other")) != 0 || len(projection.PendingOperations) != 0 || len(projection.TopicRemovals) != 0 {
61 t.Fatal("projection leaked another workspace or operation payloads")
62 }
63 projection.AdoptedTopicIDs("w")["unrelated"] = true
64 if projection.AdoptedTopicIDs("w")["unrelated"] {
65 t.Fatal("caller mutated the cached adoption index")
66 }
67 full, err := store.Load(t.Context())
68 if err != nil || !reflect.DeepEqual(full.AdoptedTopicIDs("w"), want) {
69 t.Fatalf("full snapshot adoption differs: %v", err)
70 }
71 after, err := os.ReadFile(path)
72 if err != nil || string(after) != string(body) {
73 t.Fatal("projection rewrote registry bytes")
74 }
75 }
76 }
77
78 func TestPurgeRetainsTopicAdoptionWithoutImport(t *testing.T) {
79 store, _ := seedArchivedProcessState(t)
80 if err := store.EnsureSessionTopic(t.Context(), "victim", "legacy-topic", "Private title"); err != nil {
81 t.Fatal(err)
82 }
83 state, err := store.Load(t.Context())
84 if err != nil {
85 t.Fatal(err)
86 }
87 if err := store.BeginPurge(t.Context(), "victim", state.Generation); err != nil {
88 t.Fatal(err)
89 }
90 if err := store.AdvancePurge(t.Context(), "victim", "content_removed"); err != nil {
91 t.Fatal(err)
92 }
93 // Resume completion with a new store to cover an interrupted purge.
94 store = NewStore(store.Path())
95 if err := store.CompletePurge(t.Context(), "victim"); err != nil {
96 t.Fatal(err)
97 }
98 if err := store.RenameWorkspace(t.Context(), GlobalWorkspaceID, "Unrelated edit"); err != nil {
99 t.Fatal(err)
100 }
101 state, err = NewStore(store.Path()).Load(t.Context())
102 if err != nil {
103 t.Fatal(err)
104 }
105 if !state.AdoptedTopicIDs(GlobalWorkspaceID)["legacy-topic"] || len(state.Workspaces[GlobalWorkspaceID].SessionIDs) != 0 || len(state.Presentation) != 0 {
106 t.Fatal("purge lost topic adoption or retained live presentation")
107 }
108 receipt := state.PendingOperations["purge-victim"]
109 if receipt.Presentation == nil || receipt.Presentation.Title != "" || receipt.Mapping != nil {
110 t.Fatal("purge receipt retained more than topic identity")
111 }
112 // These are existing operation fields, including for older codecs. The
113 // registry schema fence remains unchanged; this checks the receipt shape.
114 body, err := json.Marshal(receipt)
115 if err != nil {
116 t.Fatal(err)
117 }
118 var old previous.Operation
119 if err := json.Unmarshal(body, &old); err != nil {
120 t.Fatal(err)
121 }
122 roundtrip, err := json.Marshal(old)
123 if err != nil || string(roundtrip) != string(body) {
124 t.Fatal("previous operation codec changed the purge receipt")
125 }
126 }
127
127 lines GO