| 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 |