| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/desktop/internal/workspacestate" |
| 11 | "reasonix/internal/session" |
| 12 | ) |
| 13 | |
| 14 | func rewriteHistoricalRegistry(t *testing.T, app *App, change func(*workspacestate.State)) { |
| 15 | t.Helper() |
| 16 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 17 | if err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | change(&state) |
| 21 | body, err := json.Marshal(state) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | if err := os.WriteFile(app.workspaceRegistry().Path(), body, 0600); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func TestHistoricalDiscoveryRepairsAdoptionAndKeepsOneLifecycle(t *testing.T) { |
| 31 | for _, damage := range []string{"mapping", "aliases", "target", "deleted"} { |
| 32 | t.Run(damage, func(t *testing.T) { |
| 33 | app, selector, _ := historicalArchiveFixture(t) |
| 34 | result, err := app.ImportHistoricalSession(selector.Source.SourceKey) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | if damage == "mapping" || damage == "aliases" { |
| 39 | appendMigrationTestMessage(t, app.desktopSessionService(""), result.Session, "continued new conversation") |
| 40 | } |
| 41 | title, pinned := "My retained title", true |
| 42 | if err := app.workspaceRegistry().UpdatePresentation(t.Context(), []string{result.Session.SessionID}, &title, &pinned); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | before, err := app.desktopSessionService("").Query().Snapshot(t.Context(), result.Session) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if damage == "deleted" { |
| 50 | if err := app.ArchiveCanonicalSession(result.Session); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | // Simulate a previous build that retained its source after purge. |
| 54 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if err := app.workspaceRegistry().BeginPurge(t.Context(), result.Session.SessionID, state.Generation); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | } |
| 62 | app.stopHistoricalImports() |
| 63 | app.closeSessionServices() |
| 64 | if damage == "target" { |
| 65 | if err := os.Rename(filepath.Join(app.desktopSessions.root, result.Session.SessionID), filepath.Join(t.TempDir(), "removed-target")); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | } else { |
| 69 | rewriteHistoricalRegistry(t, app, func(state *workspacestate.State) { |
| 70 | mapping := state.SourceMappings[selector.Source.SourceKey] |
| 71 | state.SourceMappings = map[string]workspacestate.SourceMapping{} |
| 72 | if damage == "aliases" { |
| 73 | mapping.SourceKey = "previous-key" |
| 74 | state.SourceMappings[mapping.SourceKey] = mapping |
| 75 | mapping.SourceKey, mapping.SessionID = "retired-key", "retired-copy" |
| 76 | state.SourceMappings[mapping.SourceKey] = mapping |
| 77 | retired := state.SessionStates[result.Session.SessionID] |
| 78 | retired.Lifecycle = workspacestate.Deleted |
| 79 | state.SessionStates[mapping.SessionID] = retired |
| 80 | } |
| 81 | for id, op := range state.PendingOperations { |
| 82 | if op.Kind == "import" { |
| 83 | delete(state.PendingOperations, id) |
| 84 | } |
| 85 | } |
| 86 | }) |
| 87 | } |
| 88 | app = newHistoricalLifecycleApp(t) |
| 89 | if _, err := app.ListHistoricalSessions(); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | mapping, found, err := state.ResolveSource(selector.Source.SourceKey) |
| 97 | if err != nil || !found || mapping.SessionID != result.Session.SessionID { |
| 98 | t.Fatalf("adoption not repaired: %+v %v %v", mapping, found, err) |
| 99 | } |
| 100 | if p := state.Presentation[result.Session.SessionID]; p.Title != title || !p.Pinned { |
| 101 | t.Fatalf("presentation overwritten: %+v", p) |
| 102 | } |
| 103 | page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50}) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | if damage == "deleted" { |
| 108 | if len(page.Items) != 0 { |
| 109 | t.Fatalf("deleted source resurrected: %+v", page.Items) |
| 110 | } |
| 111 | return |
| 112 | } |
| 113 | if len(page.Items) != 1 || page.Items[0].Session == nil || *page.Items[0].Session != result.Session { |
| 114 | t.Fatalf("expected only b: %+v", page.Items) |
| 115 | } |
| 116 | after, err := app.desktopSessionService("").Query().Snapshot(t.Context(), result.Session) |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | if (damage == "mapping" || damage == "aliases") && !reflect.DeepEqual(before, after) { |
| 121 | t.Fatal("receipt recovery overwrote continued target content") |
| 122 | } |
| 123 | if err := app.ArchiveCanonicalSession(result.Session); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | if err := app.PurgeCanonicalSession(result.Session); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | app.stopHistoricalImports() |
| 130 | app.closeSessionServices() |
| 131 | app = newHistoricalLifecycleApp(t) |
| 132 | if _, err := app.ListHistoricalSessions(); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | page, err = app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50}) |
| 136 | if err != nil || len(page.Items) != 0 { |
| 137 | t.Fatalf("purged source returned: %+v %v", page.Items, err) |
| 138 | } |
| 139 | if _, err := os.Stat(selector.Source.Path); !os.IsNotExist(err) { |
| 140 | t.Fatalf("exclusive source was not purged: %v", err) |
| 141 | } |
| 142 | }) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func TestHistoricalDiscoveryHidesUnrecoverableSource(t *testing.T) { |
| 147 | app, selector, _ := historicalArchiveFixture(t) |
| 148 | if err := os.WriteFile(filepath.Join(selector.Source.Path, "manifest.json"), []byte("broken manifest"), 0600); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | if _, err := app.ListHistoricalSessions(); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50}) |
| 155 | if err != nil || len(page.Items) != 0 { |
| 156 | t.Fatalf("unrecoverable source shown: %+v %v", page.Items, err) |
| 157 | } |
| 158 | if _, err := os.Stat(selector.Source.Path); err != nil { |
| 159 | t.Fatal("hiding destroyed the source", err) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestCanonicalSidebarOmitsMissingMember(t *testing.T) { |
| 164 | isolateDesktopUserDirs(t) |
| 165 | app := newHistoricalLifecycleApp(t) |
| 166 | workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "") |
| 167 | if err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspace, "missing", ""); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50}) |
| 174 | if err != nil || len(page.Items) != 0 { |
| 175 | t.Fatalf("missing member shown: %+v %v", page.Items, err) |
| 176 | } |
| 177 | if _, err := app.desktopSessionService("").Query().Stat(t.Context(), session.SessionRef{HostID: localDesktopHostID, SessionID: "missing"}); err == nil { |
| 178 | t.Fatal("created an empty replacement") |
| 179 | } |
| 180 | } |
| 181 |