返回 DeepSeek-Reasonix
organization_test.go
根目录 / desktop / internal / workspacestate / organization_test.go
1 package workspacestate
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "reflect"
10 "testing"
11 )
12
13 func TestOrganizationLegacyMoveUpdatesAuthoritativeOrder(t *testing.T) {
14 store := NewStore(filepath.Join(t.TempDir(), "registry.json"))
15 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: GlobalWorkspaceID}); err != nil {
16 t.Fatal(err)
17 }
18 for _, id := range []string{"a", "b"} {
19 if err := store.AttachSession(t.Context(), "", GlobalWorkspaceID, id, ""); err != nil {
20 t.Fatal(err)
21 }
22 }
23 _, _, err := store.UpdateOrganization(t.Context(), GlobalWorkspaceID, nil, func(o *Organization) error {
24 o.Order = []string{SessionKey("a"), SessionKey("b")}
25 return nil
26 })
27 if err != nil {
28 t.Fatal(err)
29 }
30 if err := store.MoveSession(t.Context(), GlobalWorkspaceID, "b", "a"); err != nil {
31 t.Fatal(err)
32 }
33 state, err := store.Load(t.Context())
34 if err != nil {
35 t.Fatal(err)
36 }
37 o := state.Workspaces[GlobalWorkspaceID].Organization
38 if !o.ManualOrderEnabled || !reflect.DeepEqual(o.Order, []string{SessionKey("b"), SessionKey("a")}) {
39 t.Fatalf("organization did not follow move: %+v", o)
40 }
41 }
42
43 func TestOrganizationV2UpgradeBacksUpOriginalAndPreservesUnknownFields(t *testing.T) {
44 path := filepath.Join(t.TempDir(), "registry.json")
45 original := []byte(`{"version":2,"generation":7,"workspaceIds":["global"],"workspaces":{"global":{"id":"global","title":"Global","sessionIds":[],"futureWorkspace":{"enabled":true},"organization":{"revision":3,"manualOrderEnabled":false,"order":[],"groups":[{"id":"group","title":"Work","members":[],"futureGroup":[1,2]}],"migrationVersion":1,"imported":{},"futureOrganization":{"keep":"yes"}}}},"sessionStates":{},"futureRoot":{"value":42}}`)
46 if err := os.WriteFile(path, original, 0600); err != nil {
47 t.Fatal(err)
48 }
49 store := NewStore(path)
50 if _, err := store.Load(t.Context()); err != nil {
51 t.Fatal(err)
52 }
53 before, err := os.ReadFile(path)
54 if err != nil || !bytes.Equal(before, original) {
55 t.Fatalf("read-only load changed v2 source: body=%s err=%v", before, err)
56 }
57 revision := uint64(3)
58 if _, applied, err := store.UpdateOrganization(t.Context(), GlobalWorkspaceID, &revision, func(o *Organization) error {
59 o.Groups[0].Title = "Renamed"
60 return nil
61 }); err != nil || !applied {
62 t.Fatalf("upgrade organization: applied=%v err=%v", applied, err)
63 }
64 backup, err := os.ReadFile(path + ".v2.bak")
65 if err != nil || !bytes.Equal(backup, original) {
66 t.Fatalf("v2 backup differs from original: body=%s err=%v", backup, err)
67 }
68 state, err := NewStore(path).Load(t.Context())
69 if err != nil {
70 t.Fatal(err)
71 }
72 if state.Version != 3 || state.Workspaces[GlobalWorkspaceID].Organization.Revision != 4 {
73 t.Fatalf("upgraded state: %#v", state)
74 }
75 body, err := os.ReadFile(path)
76 if err != nil {
77 t.Fatal(err)
78 }
79 var document map[string]any
80 if err := json.Unmarshal(body, &document); err != nil {
81 t.Fatal(err)
82 }
83 workspace := document["workspaces"].(map[string]any)[GlobalWorkspaceID].(map[string]any)
84 organization := workspace["organization"].(map[string]any)
85 group := organization["groups"].([]any)[0].(map[string]any)
86 for name, pair := range map[string][2]any{
87 "root": {document["futureRoot"], map[string]any{"value": float64(42)}},
88 "workspace": {workspace["futureWorkspace"], map[string]any{"enabled": true}},
89 "organization": {organization["futureOrganization"], map[string]any{"keep": "yes"}},
90 "group": {group["futureGroup"], []any{float64(1), float64(2)}},
91 } {
92 if !reflect.DeepEqual(pair[0], pair[1]) {
93 t.Errorf("unknown %s field = %#v, want %#v", name, pair[0], pair[1])
94 }
95 }
96 if err := store.RenameWorkspace(t.Context(), GlobalWorkspaceID, "Changed again"); err != nil {
97 t.Fatal(err)
98 }
99 backup, err = os.ReadFile(path + ".v2.bak")
100 if err != nil || !bytes.Equal(backup, original) {
101 t.Fatal("later writes replaced the original v2 backup")
102 }
103 }
104
105 func TestOrganizationCASRejectsStaleWriterWithoutOverwritingWinner(t *testing.T) {
106 store := organizationTestStore(t)
107 revision := uint64(0)
108 winner, applied, err := store.UpdateOrganization(t.Context(), GlobalWorkspaceID, &revision, func(o *Organization) error {
109 o.Groups = []OrganizationGroup{{ID: "winner", Title: "Other window", Members: []string{}}}
110 return nil
111 })
112 if err != nil || !applied {
113 t.Fatalf("first writer: applied=%v err=%v", applied, err)
114 }
115 before, err := os.ReadFile(store.Path())
116 if err != nil {
117 t.Fatal(err)
118 }
119 invoked := false
120 latest, applied, err := NewStore(store.Path()).UpdateOrganization(t.Context(), GlobalWorkspaceID, &revision, func(o *Organization) error {
121 invoked = true
122 o.Groups = []OrganizationGroup{{ID: "loser", Title: "Stale window"}}
123 return nil
124 })
125 if err != nil || applied || invoked {
126 t.Fatalf("stale writer: applied=%v invoked=%v err=%v", applied, invoked, err)
127 }
128 latestJSON, _ := json.Marshal(latest)
129 winnerJSON, _ := json.Marshal(winner)
130 if !bytes.Equal(latestJSON, winnerJSON) {
131 t.Fatalf("conflict snapshot=%#v, want winner=%#v", latest, winner)
132 }
133 after, err := os.ReadFile(store.Path())
134 if err != nil || !bytes.Equal(before, after) {
135 t.Fatal("conflicting organization update changed the registry")
136 }
137 }
138
139 func TestOrganizationSourceJournalCommitPreservesUngroupedPlacement(t *testing.T) {
140 store := organizationTestStore(t)
141 ctx := t.Context()
142 if err := store.AttachSession(ctx, "", GlobalWorkspaceID, "sibling", ""); err != nil {
143 t.Fatal(err)
144 }
145 sourceKey := "source\x00local\x00legacy-b"
146 _, _, err := store.UpdateOrganization(ctx, GlobalWorkspaceID, nil, func(o *Organization) error {
147 o.ManualOrderEnabled = true
148 o.Order = []string{sourceKey, SessionKey("sibling")}
149 o.Groups = []OrganizationGroup{{ID: "shared-topic-group", Title: "Shared topic", Members: []string{SessionKey("sibling")}}}
150 o.MigrationVersion = 1
151 o.Imported = map[string]bool{sourceKey: true, SessionKey("sibling"): true}
152 return nil
153 })
154 if err != nil {
155 t.Fatal(err)
156 }
157 mapping := &SourceMapping{SourceKey: "legacy-b", Path: "/legacy/b.jsonl", Format: "legacy", Fingerprint: "fingerprint", SessionID: "b", WorkspaceID: GlobalWorkspaceID}
158 if err := store.BeginOperation(ctx, Operation{ID: "adopt-b", Kind: "import", WorkspaceID: GlobalWorkspaceID, Lifecycle: Active}); err != nil {
159 t.Fatal(err)
160 }
161 if err := store.PrepareOperationContent(ctx, "adopt-b", []string{"b"}, mapping, &Presentation{TopicID: "shared-topic"}); err != nil {
162 t.Fatal(err)
163 }
164 // Reopen between prepare and commit, as migration recovery does after a crash.
165 store = NewStore(store.Path())
166 if err := store.CommitOperation(ctx, "adopt-b"); err != nil {
167 t.Fatal(err)
168 }
169 state, err := store.Load(ctx)
170 if err != nil {
171 t.Fatal(err)
172 }
173 o := state.Workspaces[GlobalWorkspaceID].Organization
174 assertStrings(t, o.Order, []string{SessionKey("b"), SessionKey("sibling")})
175 assertStrings(t, o.Groups[0].Members, []string{SessionKey("sibling")})
176 assertStrings(t, state.Workspaces[GlobalWorkspaceID].SessionIDs, []string{"b", "sibling"})
177 if o.Imported[sourceKey] || !o.Imported[SessionKey("b")] {
178 t.Fatalf("source import identity was not transferred: %#v", o.Imported)
179 }
180 if state.SourceMappings["legacy-b"].SessionID != "b" {
181 t.Fatal("organization committed without its source mapping")
182 }
183 revision := o.Revision
184 if err := store.CommitOperation(ctx, "adopt-b"); err != nil {
185 t.Fatal(err)
186 }
187 state, err = store.Load(ctx)
188 if err != nil {
189 t.Fatal(err)
190 }
191 if state.Workspaces[GlobalWorkspaceID].Organization.Revision != revision {
192 t.Fatal("replaying a committed adoption changed organization again")
193 }
194 }
195
196 func TestOrganizationForkAttachmentInheritsGroupAndFollowsParent(t *testing.T) {
197 store := organizationTestStore(t)
198 ctx := t.Context()
199 for _, id := range []string{"parent", "sibling"} {
200 if err := store.AttachSession(ctx, "", GlobalWorkspaceID, id, ""); err != nil {
201 t.Fatal(err)
202 }
203 }
204 _, _, err := store.UpdateOrganization(ctx, GlobalWorkspaceID, nil, func(o *Organization) error {
205 o.ManualOrderEnabled = true
206 o.Order = []string{SessionKey("parent"), SessionKey("sibling")}
207 o.Groups = []OrganizationGroup{{ID: "feature", Title: "Feature", Members: []string{SessionKey("parent")}}}
208 return nil
209 })
210 if err != nil {
211 t.Fatal(err)
212 }
213 state, err := store.Load(ctx)
214 if err != nil {
215 t.Fatal(err)
216 }
217 parentGeneration := state.SessionStates["parent"].Generation
218 if err := store.BeginCreate(ctx, PendingCreate{OperationID: "fork", WorkspaceID: GlobalWorkspaceID, SessionID: "child"}); err != nil {
219 t.Fatal(err)
220 }
221 if err := store.AttachSessionFromSourceIfUnchanged(ctx, "fork", GlobalWorkspaceID, "child", "sibling", "parent", parentGeneration); err != nil {
222 t.Fatal(err)
223 }
224 state, err = NewStore(store.Path()).Load(ctx)
225 if err != nil {
226 t.Fatal(err)
227 }
228 workspace := state.Workspaces[GlobalWorkspaceID]
229 assertStrings(t, workspace.Organization.Order, []string{SessionKey("parent"), SessionKey("child"), SessionKey("sibling")})
230 assertStrings(t, workspace.Organization.Groups[0].Members, []string{SessionKey("parent"), SessionKey("child")})
231 assertStrings(t, workspace.SessionIDs, []string{"parent", "child", "sibling"})
232 revision := workspace.Organization.Revision
233 if err := store.AttachSessionFromSourceIfUnchanged(ctx, "fork", GlobalWorkspaceID, "child", "sibling", "parent", parentGeneration); err != nil {
234 t.Fatal(err)
235 }
236 state, err = store.Load(ctx)
237 if err != nil {
238 t.Fatal(err)
239 }
240 if state.Workspaces[GlobalWorkspaceID].Organization.Revision != revision {
241 t.Fatal("fork retry changed organization or duplicated its child")
242 }
243 }
244
245 func TestOrganizationFailedMutationLeavesOrderAndRevisionUnchanged(t *testing.T) {
246 store := organizationTestStore(t)
247 ctx := t.Context()
248 if _, _, err := store.UpdateOrganization(ctx, GlobalWorkspaceID, nil, func(o *Organization) error {
249 o.Order = []string{"source\x00local\x00before"}
250 return nil
251 }); err != nil {
252 t.Fatal(err)
253 }
254 before, err := os.ReadFile(store.Path())
255 if err != nil {
256 t.Fatal(err)
257 }
258 failure := errors.New("failed validation")
259 if _, applied, err := store.UpdateOrganization(ctx, GlobalWorkspaceID, nil, func(o *Organization) error {
260 o.Order = []string{"source\x00local\x00after"}
261 return failure
262 }); !errors.Is(err, failure) || applied {
263 t.Fatalf("failed mutation: applied=%v err=%v", applied, err)
264 }
265 after, err := os.ReadFile(store.Path())
266 if err != nil || !bytes.Equal(before, after) {
267 t.Fatal("failed callback partially published organization")
268 }
269 }
270
271 func TestOrganizationAdoptingOneHeadPreservesOtherHeadAtSamePath(t *testing.T) {
272 store := organizationTestStore(t)
273 ctx := t.Context()
274 first := "source\x00local\x00path:head-a"
275 second := "source\x00local\x00path:head-b"
276 if _, _, err := store.UpdateOrganization(ctx, GlobalWorkspaceID, nil, func(o *Organization) error {
277 o.ManualOrderEnabled = true
278 o.Order = []string{first, second}
279 o.Groups = []OrganizationGroup{{ID: "heads", Members: []string{first, second}}}
280 o.Imported = map[string]bool{first: true, second: true}
281 return nil
282 }); err != nil {
283 t.Fatal(err)
284 }
285 if err := store.RecordSource(ctx, SourceMapping{
286 SourceKey: "path:head-a", Path: "/legacy/shared.jsonl", HeadID: "head-a",
287 SessionID: "a", WorkspaceID: GlobalWorkspaceID, Fingerprint: "same-file", Format: "legacy",
288 }, Presentation{}); err != nil {
289 t.Fatal(err)
290 }
291 state, err := store.Load(ctx)
292 if err != nil {
293 t.Fatal(err)
294 }
295 o := state.Workspaces[GlobalWorkspaceID].Organization
296 assertStrings(t, o.Order, []string{SessionKey("a"), second})
297 assertStrings(t, o.Groups[0].Members, []string{SessionKey("a"), second})
298 if !o.Imported[second] || !o.Imported[SessionKey("a")] || o.Imported[first] {
299 t.Fatalf("head adoption import identities: %#v", o.Imported)
300 }
301 }
302
303 func TestOrganizationAdoptionPreservesExistingCanonicalUserSettings(t *testing.T) {
304 store := organizationTestStore(t)
305 ctx := t.Context()
306 source := "source\x00local\x00legacy"
307 canonical := SessionKey("a")
308 if _, _, err := store.UpdateOrganization(ctx, GlobalWorkspaceID, nil, func(o *Organization) error {
309 o.ManualOrderEnabled = true
310 o.Order = []string{source, SessionKey("other"), canonical}
311 o.Groups = []OrganizationGroup{{ID: "old-topic", Members: []string{source}}}
312 o.Imported = map[string]bool{source: true, canonical: true}
313 return nil
314 }); err != nil {
315 t.Fatal(err)
316 }
317 if err := store.RecordSource(ctx, SourceMapping{
318 SourceKey: "legacy", Path: "/legacy/a.jsonl", SessionID: "a",
319 WorkspaceID: GlobalWorkspaceID, Fingerprint: "fingerprint", Format: "legacy",
320 }, Presentation{}); err != nil {
321 t.Fatal(err)
322 }
323 state, err := store.Load(ctx)
324 if err != nil {
325 t.Fatal(err)
326 }
327 o := state.Workspaces[GlobalWorkspaceID].Organization
328 assertStrings(t, o.Order, []string{SessionKey("other"), canonical})
329 assertStrings(t, o.Groups[0].Members, []string{})
330 if !o.Imported[canonical] || o.Imported[source] {
331 t.Fatalf("canonical settings import identities: %#v", o.Imported)
332 }
333 }
334
335 func TestOrganizationForkPreservesActivitySortWithoutEnablingManualOrder(t *testing.T) {
336 store := organizationTestStore(t)
337 ctx := t.Context()
338 if err := store.AttachSession(ctx, "", GlobalWorkspaceID, "parent", ""); err != nil {
339 t.Fatal(err)
340 }
341 if _, _, err := store.UpdateOrganization(ctx, GlobalWorkspaceID, nil, func(o *Organization) error {
342 o.ManualOrderEnabled = false
343 o.Order = []string{SessionKey("parent")}
344 o.Groups = []OrganizationGroup{{ID: "feature", Title: "Feature", Members: []string{SessionKey("parent")}}}
345 return nil
346 }); err != nil {
347 t.Fatal(err)
348 }
349 state, err := store.Load(ctx)
350 if err != nil {
351 t.Fatal(err)
352 }
353 if err := store.BeginCreate(ctx, PendingCreate{OperationID: "fork-activity", WorkspaceID: GlobalWorkspaceID, SessionID: "child"}); err != nil {
354 t.Fatal(err)
355 }
356 if err := store.AttachSessionFromSourceIfUnchanged(ctx, "fork-activity", GlobalWorkspaceID, "child", "", "parent", state.SessionStates["parent"].Generation); err != nil {
357 t.Fatal(err)
358 }
359 state, err = NewStore(store.Path()).Load(ctx)
360 if err != nil {
361 t.Fatal(err)
362 }
363 o := state.Workspaces[GlobalWorkspaceID].Organization
364 if o.ManualOrderEnabled {
365 t.Fatal("forking implicitly changed activity sorting to manual order")
366 }
367 assertStrings(t, o.Groups[0].Members, []string{SessionKey("parent"), SessionKey("child")})
368 }
369
370 func organizationTestStore(t *testing.T) *Store {
371 t.Helper()
372 store := NewStore(filepath.Join(t.TempDir(), "registry.json"))
373 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: GlobalWorkspaceID, Visible: true}); err != nil {
374 t.Fatal(err)
375 }
376 return store
377 }
378
378 lines GO