| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/desktop/internal/workspacestate" |
| 10 | ) |
| 11 | |
| 12 | func TestTopicRemovalReadsSupportedV1Organization(t *testing.T) { |
| 13 | a, topic, _ := topicRemovalFixture(t, "global", "") |
| 14 | file, err := readTopicRemovalProjects() |
| 15 | if err != nil { |
| 16 | t.Fatal(err) |
| 17 | } |
| 18 | file.GlobalGroups = []desktopGroup{{ID: "saved", Title: "Saved", TopicIDs: []string{topic.ID}}} |
| 19 | if err := saveProjectsFile(file); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | path := filepath.Join(desktopConfigDir(), desktopProjectOrganizationFile) |
| 23 | body, err := os.ReadFile(path) |
| 24 | if err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | var organization map[string]any |
| 28 | if err := json.Unmarshal(body, &organization); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | organization["version"] = 1 |
| 32 | body, err = json.Marshal(organization) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | if err := os.WriteFile(path, body, 0600); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | view, err := a.InspectTopicRemoval(TopicRemovalTarget{TopicID: topic.ID}) |
| 40 | if err != nil || !view.Allowed || view.Disposition != "archive_placeholder" { |
| 41 | t.Fatalf("supported legacy organization must remain recoverable: %+v %v", view, err) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestTopicRemovalRecoveryRejectsReusedIDInAnotherWorkspace(t *testing.T) { |
| 46 | for _, action := range []string{"retry", "restore"} { |
| 47 | t.Run(action, func(t *testing.T) { |
| 48 | a, topic, _ := topicRemovalFixture(t, "global", "Original") |
| 49 | request := inspectedTopicRemoval(t, a, topic.ID) |
| 50 | if action == "retry" { |
| 51 | interruptTopicRemoval(t, a, "topic-removal-before-metadata", func() { _, _ = a.RemoveTopic(request) }) |
| 52 | } else if result, err := a.RemoveTopic(request); err != nil || !result.Committed { |
| 53 | t.Fatalf("remove: %+v %v", result, err) |
| 54 | } |
| 55 | root := t.TempDir() |
| 56 | if err := addProject(root, "Other owner"); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | if err := updateProjectsFile(func(file *desktopProjectFile) (bool, error) { |
| 60 | i := projectIndexByRoot(file.Projects, root) |
| 61 | file.Projects[i].Topics = append(file.Projects[i].Topics, topic.ID) |
| 62 | return true, nil |
| 63 | }); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if err := setTopicTitle(root, topic.ID, "New owner"); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if action == "retry" { |
| 70 | result, err := a.RemoveTopic(request) |
| 71 | if err != nil || result.Committed || result.ErrorCode != "state_conflict" { |
| 72 | t.Fatalf("retry changed a new owner: %+v %v", result, err) |
| 73 | } |
| 74 | } else if err := a.restoreRemovedTopic(request.OperationID, request.Target.WorkspaceID); err == nil { |
| 75 | t.Fatal("restored over a different owner") |
| 76 | } |
| 77 | if !topicIndexedInRegistry("project", root, topic.ID) || loadTopicTitle(root, topic.ID) != "New owner" { |
| 78 | t.Fatal("new owner's data changed") |
| 79 | } |
| 80 | }) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestTopicRemovalCanonicalConfirmationFencedAtArchiveAdmission(t *testing.T) { |
| 85 | for _, phase := range []string{"topic-sessions-before-archive", "before-archive-commit"} { |
| 86 | t.Run(phase, func(t *testing.T) { testTopicRemovalCanonicalConfirmation(t, phase) }) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func testTopicRemovalCanonicalConfirmation(t *testing.T, checkpoint string) { |
| 91 | a, _, _ := topicRemovalFixture(t, "global", "") |
| 92 | tab, err := a.EnsureBlankTab("global", "") |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | t.Cleanup(func() { |
| 97 | if err := a.CloseTab(tab.ID); err != nil { |
| 98 | t.Error(err) |
| 99 | } |
| 100 | }) |
| 101 | request := inspectedTopicRemoval(t, a, tab.TopicID) |
| 102 | a.lifecycleCheckpointHook = func(phase string) { |
| 103 | if phase != checkpoint { |
| 104 | return |
| 105 | } |
| 106 | title := "Edited after confirmation" |
| 107 | if err := a.workspaceRegistry().UpdatePresentation(t.Context(), []string{tab.SessionID}, &title, nil); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | } |
| 111 | result, err := a.RemoveTopic(request) |
| 112 | a.lifecycleCheckpointHook = nil |
| 113 | if err != nil || result.Committed || result.ErrorCode != "state_conflict" { |
| 114 | t.Fatalf("archived changed confirmation: %+v %v", result, err) |
| 115 | } |
| 116 | state, err := a.workspaceRegistry().Load(t.Context()) |
| 117 | if err != nil || state.SessionStates[tab.SessionID].Lifecycle != workspacestate.Active { |
| 118 | t.Fatalf("changed session not active: %v", err) |
| 119 | } |
| 120 | // The archive child is durable by this point. Generic startup replay must |
| 121 | // enforce the same confirmation fence instead of bypassing the topic RPC. |
| 122 | if err := a.recoverDesktopOperations(t.Context(), false); err == nil { |
| 123 | t.Fatal("replay accepted stale confirmation") |
| 124 | } |
| 125 | state, err = a.workspaceRegistry().Load(t.Context()) |
| 126 | if err != nil || state.SessionStates[tab.SessionID].Lifecycle != workspacestate.Active { |
| 127 | t.Fatalf("replay archived the changed session: %v", err) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestTopicRemovalInterruptedRestoreSurvivesUnrelatedEdit(t *testing.T) { |
| 132 | a, topic, _ := topicRemovalFixture(t, "global", "Restore this") |
| 133 | request := inspectedTopicRemoval(t, a, topic.ID) |
| 134 | if result, err := a.RemoveTopic(request); err != nil || !result.Committed { |
| 135 | t.Fatalf("remove: %+v %v", result, err) |
| 136 | } |
| 137 | interruptTopicRemoval(t, a, "topic-restore-before-metadata", func() { |
| 138 | _ = a.restoreRemovedTopic(request.OperationID, request.Target.WorkspaceID) |
| 139 | }) |
| 140 | other, err := a.CreateTopic("global", "", "Unrelated new topic") |
| 141 | if err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | if err := a.restoreRemovedTopic(request.OperationID, request.Target.WorkspaceID); err != nil { |
| 145 | t.Fatalf("unrelated edit permanently blocks interrupted restore: %v", err) |
| 146 | } |
| 147 | if loadTopicTitle("", topic.ID) != topic.Title || loadTopicTitle("", other.ID) != other.Title { |
| 148 | t.Fatal("restore changed unrelated data") |
| 149 | } |
| 150 | } |
| 151 |