| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/desktop/internal/workspacestate" |
| 11 | "reasonix/internal/sessioncatalog" |
| 12 | ) |
| 13 | |
| 14 | func TestLegacyTopicArchiveVisibility(t *testing.T) { |
| 15 | for _, scope := range []string{"global", "project"} { |
| 16 | for _, metadataOnly := range []bool{false, true} { |
| 17 | for _, topicRoute := range []bool{true, false} { |
| 18 | name := scope + "/materialized" |
| 19 | if metadataOnly { |
| 20 | name = scope + "/lazy" |
| 21 | } |
| 22 | if topicRoute { |
| 23 | name += "/topic" |
| 24 | } else { |
| 25 | name += "/session" |
| 26 | } |
| 27 | t.Run(name, func(t *testing.T) { |
| 28 | a, topic, root := topicRemovalFixture(t, scope, "Historical conversation") |
| 29 | openCatalog := func(owner *App, filename string) { |
| 30 | t.Helper() |
| 31 | catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), filename), MetadataOnly: metadataOnly, DisableRepair: true}) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | owner.sessionCatalog.Store(catalog) |
| 36 | // Register after this generation's TempDir cleanup and bind |
| 37 | // the owner now, so Windows sees closed SQLite handles first. |
| 38 | t.Cleanup(func() { |
| 39 | owner.desktopSessions.readSnapshots.close() |
| 40 | if !owner.stopSessionCatalog(sessionCatalogTestDeadline) { |
| 41 | t.Error("session catalog did not stop before directory cleanup") |
| 42 | } |
| 43 | }) |
| 44 | } |
| 45 | if err := a.SetTopicPinned(topic.ID, true); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | canonicalRoot := pinDesktopSessionRoot(t, a) |
| 49 | sourceRoot := root |
| 50 | if scope == "global" { |
| 51 | sourceRoot = globalWorkspaceRoot() |
| 52 | } |
| 53 | dir := desktopSessionDir(sourceRoot) |
| 54 | if err := os.MkdirAll(dir, 0700); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | sourcePath := writeTopicSessionWithPrompt(t, dir, "archive-visibility.jsonl", topic.ID, topic.Title, root, "preserve this history", time.Now()) |
| 58 | original, err := os.ReadFile(sourcePath) |
| 59 | if err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | openCatalog(a, "catalog.sqlite") |
| 63 | reconcileSessionCatalogForTest(t, a, dir, scope, root) |
| 64 | assertVisible := func(want int) { |
| 65 | t.Helper() |
| 66 | page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 50}) |
| 67 | if err != nil || len(page.Items) != want { |
| 68 | t.Fatalf("sidebar = %+v, %v; want %d", page.Items, err, want) |
| 69 | } |
| 70 | } |
| 71 | assertVisible(1) |
| 72 | if topicRoute { |
| 73 | result, err := a.RemoveTopic(inspectedTopicRemoval(t, a, topic.ID)) |
| 74 | if err != nil || !result.Committed { |
| 75 | t.Fatalf("archive topic = %+v, %v", result, err) |
| 76 | } |
| 77 | } else { |
| 78 | result, err := a.ArchiveSessionTarget(SessionSelector{SessionPath: sourcePath}) |
| 79 | if err != nil || !result.Committed { |
| 80 | t.Fatalf("archive session = %+v, %v", result, err) |
| 81 | } |
| 82 | } |
| 83 | assertVisible(0) |
| 84 | trash, err := a.ListTrashEntries("", "", 50) |
| 85 | if err != nil || len(trash.Items) != 1 || trash.Items[0].Ref == nil { |
| 86 | t.Fatalf("trash = %+v, %v", trash, err) |
| 87 | } |
| 88 | if err := a.RestoreCanonicalSession(*trash.Items[0].Ref); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | assertVisible(1) |
| 92 | if err := a.ArchiveCanonicalSession(*trash.Items[0].Ref); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | assertVisible(0) |
| 96 | if err := a.PurgeCanonicalSession(*trash.Items[0].Ref); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | assertVisible(0) |
| 100 | retained, err := os.ReadFile(sourcePath) |
| 101 | if err != nil || !bytes.Equal(retained, original) { |
| 102 | t.Fatal("archive/purge changed the retained legacy source") |
| 103 | } |
| 104 | // A stale metadata-only row would take the native-confirmation |
| 105 | // route and fail exactly like the reported second archive attempt. |
| 106 | stale := inspectedTopicRemoval(t, a, topic.ID) |
| 107 | stale.OperationID += "-stale" |
| 108 | failed, err := a.RemoveTopic(stale) |
| 109 | if err != nil || failed.Committed || failed.ErrorMessage != "session not found" { |
| 110 | t.Fatalf("stale archive = %+v, %v", failed, err) |
| 111 | } |
| 112 | assertVisible(0) |
| 113 | // The retained source and metadata must not recreate a conversation |
| 114 | // after a fresh process loads the registry without a warm catalog. |
| 115 | a.closeSessionServices() |
| 116 | a.desktopSessions.readSnapshots.close() |
| 117 | if !a.stopSessionCatalog(sessionCatalogTestDeadline) { |
| 118 | t.Fatal("session catalog did not stop before the fresh process") |
| 119 | } |
| 120 | a = NewApp() |
| 121 | a.ctx = t.Context() |
| 122 | a.desktopSessions.root = canonicalRoot |
| 123 | installNoopRuntimeEvents(a) |
| 124 | t.Cleanup(a.closeSessionServices) |
| 125 | assertVisible(0) |
| 126 | state, err := a.workspaceRegistry().LoadProjection(t.Context()) |
| 127 | if err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | pins, err := a.historicalPinnedShellsFromProjection(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root}, state, workspacestate.NewWorkspaceIndex(state), desktopProject{Root: root, Topics: []string{topic.ID}, PinnedTopics: []string{topic.ID}}) |
| 131 | if err != nil || len(pins) != 0 { |
| 132 | t.Fatalf("purged topic returned in pinned shell: %+v, %v", pins, err) |
| 133 | } |
| 134 | // Independent legacy content can share the consumed topic ID. |
| 135 | // Its real source row must remain visible after rediscovery. |
| 136 | sibling := writeTopicSessionWithPrompt(t, dir, "independent-sibling.jsonl", topic.ID, topic.Title, root, "independent history", time.Now()) |
| 137 | openCatalog(a, "rebuilt-catalog.sqlite") |
| 138 | reconcileSessionCatalogForTest(t, a, dir, scope, root) |
| 139 | page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 1}) |
| 140 | if err != nil || len(page.Items) != 1 || !sameDesktopPath(page.Items[0].SessionPath, sibling) || page.NextCursor != "" { |
| 141 | t.Fatalf("topic adoption hid independent history: %+v, %v", page, err) |
| 142 | } |
| 143 | if _, err := a.CreateTopic(scope, root, "Unrelated empty topic"); err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | assertVisible(2) |
| 147 | }) |
| 148 | } |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 |