| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/desktop/internal/workspacestate" |
| 9 | "reasonix/internal/session" |
| 10 | ) |
| 11 | |
| 12 | func TestPurgeCommandUnrelatedSessionMustNotBlockPurge(t *testing.T) { |
| 13 | a, victim := lifecycleFixture(t) |
| 14 | other := addLifecycleFixtureSession(t, a, "review-other") |
| 15 | if err := a.ArchiveCanonicalSession(victim); err != nil { |
| 16 | t.Fatal(err) |
| 17 | } |
| 18 | req := lifecycleRequest(t, a, victim, "review-unrelated", "purge") |
| 19 | if err := a.workspaceRegistry().ArchiveSession(t.Context(), other.SessionID); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | result, err := a.ApplySessionLifecycle(req) |
| 23 | if err != nil || !result.Committed { |
| 24 | t.Fatalf("unrelated session blocked purge: result=%+v err=%v", result, err) |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | func TestPurgeCommandValidPreparedAfterStartupWriterReleased(t *testing.T) { |
| 29 | a, ref := lifecycleFixture(t) |
| 30 | store := a.workspaceRegistry() |
| 31 | if err := a.ArchiveCanonicalSession(ref); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | state, err := store.Load(t.Context()) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | legacy := workspacestate.Operation{ID: "purge-" + ref.SessionID, Kind: "purge", Phase: "prepared", Lifecycle: workspacestate.Deleted, SessionIDs: []string{ref.SessionID}, ExpectedGeneration: state.SessionStates[ref.SessionID].Generation} |
| 39 | rewriteLifecycleRegistry(t, store, func(s *workspacestate.State) { s.PendingOperations[legacy.ID] = legacy }) |
| 40 | fs := session.NewFilesystemPersistence(a.desktopSessions.root) |
| 41 | release, err := fs.AcquireMaintenance(ref.SessionID) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | replayErr := a.recoverDesktopSessionOperations(t.Context()) |
| 46 | release() |
| 47 | if replayErr == nil { |
| 48 | t.Fatal("expected startup writer conflict") |
| 49 | } |
| 50 | page, err := a.ListTrashEntries("", "", 50) |
| 51 | if err != nil || len(page.Items) != 1 || !page.Items[0].CanPurge { |
| 52 | t.Fatalf("page=%+v err=%v", page, err) |
| 53 | } |
| 54 | req := lifecycleRequest(t, a, ref, "resume-prepared", "purge") |
| 55 | for range 2 { |
| 56 | result, err := a.ApplySessionLifecycle(req) |
| 57 | if err != nil || !result.Committed { |
| 58 | t.Errorf("fresh delete after writer released: result=%+v err=%v", result, err) |
| 59 | } |
| 60 | } |
| 61 | page, err = a.ListTrashEntries("", "", 50) |
| 62 | if err != nil || len(page.Items) != 0 { |
| 63 | t.Fatalf("completed purge still in trash: %+v %v", page, err) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestPurgeCommandSnapshotConflictIsTerminal(t *testing.T) { |
| 68 | a, ref := lifecycleFixture(t) |
| 69 | if err := a.ArchiveCanonicalSession(ref); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | req := lifecycleRequest(t, a, ref, "old-confirmation", "purge") |
| 73 | store := a.workspaceRegistry() |
| 74 | if err := store.RestoreSession(t.Context(), ref.SessionID); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if err := store.ArchiveSession(t.Context(), ref.SessionID); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | first, err := a.ApplySessionLifecycle(req) |
| 81 | if err != nil || len(first.Items) != 1 || first.Items[0].ErrorCode != "state_conflict" || first.Items[0].Retryable { |
| 82 | t.Fatalf("result=%+v err=%v", first, err) |
| 83 | } |
| 84 | second, err := a.ApplySessionLifecycle(req) |
| 85 | b1, _ := json.Marshal(first) |
| 86 | b2, _ := json.Marshal(second) |
| 87 | if err != nil || string(b1) != string(b2) { |
| 88 | t.Fatalf("terminal result changed: %s %s %v", b1, b2, err) |
| 89 | } |
| 90 | if history, err := a.desktopSessionService("").Query().History(t.Context(), ref); err != nil || len(history) == 0 { |
| 91 | t.Fatalf("lost history: %v", err) |
| 92 | } |
| 93 | for _, change := range []func(*SessionLifecycleRequest){ |
| 94 | func(r *SessionLifecycleRequest) { r.ExpectedGeneration++ }, |
| 95 | func(r *SessionLifecycleRequest) { r.Action = "restore" }, |
| 96 | func(r *SessionLifecycleRequest) { |
| 97 | r.Targets = []SessionLifecycleTarget{{Ref: &session.SessionRef{HostID: localDesktopHostID, SessionID: "other"}}} |
| 98 | }, |
| 99 | } { |
| 100 | changed := req |
| 101 | change(&changed) |
| 102 | if _, err := a.ApplySessionLifecycle(changed); !errors.Is(err, workspacestate.ErrMutationConflict) { |
| 103 | t.Fatalf("changed request accepted: %v", err) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestPurgeCommandFirstDeliverySeparatesChangedTargets(t *testing.T) { |
| 109 | a, unchanged := lifecycleFixture(t) |
| 110 | changed := addLifecycleFixtureSession(t, a, "changed-target") |
| 111 | for _, ref := range []session.SessionRef{unchanged, changed} { |
| 112 | if err := a.ArchiveCanonicalSession(ref); err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | } |
| 116 | req := lifecycleRequest(t, a, unchanged, "mixed-first-delivery", "purge") |
| 117 | req.Targets = append(req.Targets, SessionLifecycleTarget{Ref: &changed}) |
| 118 | if err := a.workspaceRegistry().RestoreSession(t.Context(), changed.SessionID); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | result, err := a.ApplySessionLifecycle(req) |
| 122 | if err != nil || len(result.Items) != 2 || !result.Items[0].Committed || result.Items[1].ErrorCode != "state_conflict" || result.Items[1].Retryable { |
| 123 | t.Fatalf("mixed admission result=%+v err=%v", result, err) |
| 124 | } |
| 125 | if history, err := a.desktopSessionService("").Query().History(t.Context(), changed); err != nil || len(history) == 0 { |
| 126 | t.Fatalf("changed target lost body: %v", err) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestPurgeCommandRestoreWhileWaitingForRuntimeLock(t *testing.T) { |
| 131 | a, ref := lifecycleFixture(t) |
| 132 | if err := a.ArchiveCanonicalSession(ref); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | req := lifecycleRequest(t, a, ref, "restore-before-runtime-lock", "purge") |
| 136 | a.runtimeMutationBeforeLockHook = func(operation string) { |
| 137 | if operation == "purge lifecycle command" { |
| 138 | if err := a.workspaceRegistry().RestoreSession(t.Context(), ref.SessionID); err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | result, err := a.ApplySessionLifecycle(req) |
| 144 | if err != nil || len(result.Items) != 1 || result.Items[0].ErrorCode != "state_conflict" || result.Items[0].Retryable { |
| 145 | t.Fatalf("late conflict=%+v err=%v", result, err) |
| 146 | } |
| 147 | if history, err := a.desktopSessionService("").Query().History(t.Context(), ref); err != nil || len(history) == 0 { |
| 148 | t.Fatalf("restore lost body: %v", err) |
| 149 | } |
| 150 | } |
| 151 |