diff --git a/desktop/src/features/settings/ui/acceptedDeletionRefresh.ts b/desktop/src/features/settings/ui/acceptedDeletionRefresh.ts index f7573547b2..1ded51885c 100644 --- a/desktop/src/features/settings/ui/acceptedDeletionRefresh.ts +++ b/desktop/src/features/settings/ui/acceptedDeletionRefresh.ts @@ -27,7 +27,7 @@ type RefreshContext = { export async function refreshAcceptedCommunityDeletions( context: RefreshContext, ): Promise { - if (!context.isCurrent()) return; + if (!context.isCurrent() || context.identityMismatch) return; let auth = await invoke("get_builderlab_auth"); if (!context.isCurrent()) return; context.setAuth(auth); @@ -40,39 +40,47 @@ export async function refreshAcceptedCommunityDeletions( if (auth.canDeleteBuzzCommunities !== true) return; let restored = false; - for (const [communityId, envelope] of [...context.accepted.entries()]) { - if (!listed.some((community) => community.id === communityId)) continue; - const stillEligible = () => - context.isCurrent() && - context.ownerPubkey() === envelope.bound_owner_pubkey && - envelope.backend_origin === BUILDERLAB_BACKEND_ORIGIN && - context.accepted.get(communityId) === envelope && - !context.identityMismatch; - if (!stillEligible()) return; - auth = await invoke("get_builderlab_auth"); - if (!stillEligible()) return; - context.setAuth(auth); - if (!auth) { - context.clearAccount(); - return; - } - if (auth.canDeleteBuzzCommunities !== true) return; - const request = publicDeletionRequest(envelope); - if (!stillEligible()) return; - const response = await invoke( - "delete_builderlab_community", - { - communityId: request.community_id, - host: request.host, - requestId: request.request_id, - acknowledgementVersion: request.acknowledgement_version, - }, - ); - if (!stillEligible()) return; - if (deletionResponseDisposition(response, envelope, "check") === "abort") { - context.accepted.delete(communityId); - restored = true; + try { + for (const [communityId, envelope] of [...context.accepted.entries()]) { + if (!listed.some((community) => community.id === communityId)) continue; + const stillEligible = () => + context.isCurrent() && + context.ownerPubkey() === envelope.bound_owner_pubkey && + envelope.backend_origin === BUILDERLAB_BACKEND_ORIGIN && + context.accepted.get(communityId) === envelope; + if (!stillEligible()) return; + auth = await invoke("get_builderlab_auth"); + if (!stillEligible()) return; + context.setAuth(auth); + if (!auth) { + context.clearAccount(); + return; + } + if (auth.canDeleteBuzzCommunities !== true) return; + const request = publicDeletionRequest(envelope); + const response = await invoke( + "delete_builderlab_community", + { + communityId: request.community_id, + host: request.host, + requestId: request.request_id, + acknowledgementVersion: request.acknowledgement_version, + }, + ); + if (!stillEligible()) return; + const disposition = deletionResponseDisposition( + response, + envelope, + "check", + ); + if (disposition === "abort") { + context.accepted.delete(communityId); + restored = true; + } else if (disposition !== "accept") { + throw new Error("Couldn't check deletion status."); + } } + } finally { + if (restored && context.isCurrent()) context.restoreListed(listed); } - if (restored && context.isCurrent()) context.restoreListed(listed); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ef97936fae..3da04a45ad 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -265,6 +265,7 @@ type E2eConfig = { code: string; message?: string; } | null>; + builderlabDeletionRejectSequence?: boolean[]; builderlabDeletionStatusSequence?: string[]; builderlabDeletionHttpStatusSequence?: number[]; builderlabDeletionBodyStatus?: number; @@ -12664,6 +12665,16 @@ export function maybeInstallE2eTauriMocks() { heldBuilderlabDeletionReleases.push(resolve); }); } + const rejectSequence = + activeConfig?.mock?.builderlabDeletionRejectSequence; + if ( + rejectSequence?.length && + (rejectSequence.length > 1 + ? rejectSequence.shift() + : rejectSequence[0]) + ) { + throw new Error("Mock deletion transport failure"); + } const sequence = activeConfig?.mock?.builderlabDeletionErrorSequence; const deletionError = sequence?.length ? sequence.length > 1 diff --git a/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts index 251ec38eb2..9d9ae6f156 100644 --- a/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts @@ -94,6 +94,7 @@ async function openDeletionFixture( mismatch?: boolean; errorCode?: string; errorSequence?: Array<{ code: string; message?: string } | null>; + rejectSequence?: boolean[]; statusSequence?: string[]; capabilitySequence?: boolean[]; quota?: { used: number; limit: number; canCreate: boolean }; @@ -124,6 +125,7 @@ async function openDeletionFixture( ? { code: options.errorCode, message: "mock deletion error" } : undefined, builderlabDeletionErrorSequence: options.errorSequence, + builderlabDeletionRejectSequence: options.rejectSequence, builderlabDeletionStatusSequence: options.statusSequence, builderlabDeletionHttpStatusSequence: options.httpStatusSequence, builderlabDeletionBodyStatus: options.bodyStatus, @@ -156,6 +158,23 @@ async function startArchivedDeletion(page: Page) { .click(); } +async function startSecondArchivedDeletion(page: Page) { + await page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }) + .getByRole("button", { name: "Delete", exact: true }) + .click(); + await page + .getByLabel( + `Type the exact host to continue: ${SECOND_ARCHIVED.normalized_host}`, + ) + .fill(SECOND_ARCHIVED.normalized_host); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); +} + async function storedDeletionRequestId(page: Page) { return page.evaluate(() => { const raw = window.localStorage.getItem( @@ -1036,20 +1055,7 @@ test("Refresh restores only the aborted row while another accepted deletion rema await expect( page.getByText("Deletion started", { exact: true }), ).toBeVisible(); - await page - .getByTestId("hosted-community-row") - .filter({ hasText: "Second archived team" }) - .getByRole("button", { name: "Delete", exact: true }) - .click(); - await page - .getByLabel( - `Type the exact host to continue: ${SECOND_ARCHIVED.normalized_host}`, - ) - .fill(SECOND_ARCHIVED.normalized_host); - await page.getByRole("button", { name: "Continue" }).click(); - await page - .getByRole("button", { name: "Delete community permanently" }) - .click(); + await startSecondArchivedDeletion(page); await expect.poll(() => deletionPayloads(page)).toHaveLength(2); const acceptedCalls = await deletionPayloads(page); await expect( @@ -1082,15 +1088,99 @@ test("Refresh restores only the aborted row while another accepted deletion rema ]); }); +test("Refresh restores an earlier abort when a later replay rejects", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + communities: [...DELETION_COMMUNITIES, SECOND_ARCHIVED], + quota: { used: 3, limit: 5, canCreate: true }, + statusSequence: ["submitted", "submitted", "aborted"], + rejectSequence: [false, false, false, true], + }); + await startArchivedDeletion(page); + await startSecondArchivedDeletion(page); + await expect.poll(() => deletionPayloads(page)).toHaveLength(2); + const acceptedCalls = await deletionPayloads(page); + + await page.getByRole("button", { name: "Refresh" }).click(); + await expect( + page.getByText("Mock deletion transport failure", { exact: true }), + ).toBeVisible(); + const firstRow = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .filter({ hasNotText: "Second archived team" }); + await expect(firstRow).toBeVisible(); + await expect( + firstRow.getByRole("button", { name: "Delete", exact: true }), + ).toBeEnabled(); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }), + ).toHaveCount(0); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + await expect + .poll(() => deletionPayloads(page)) + .toEqual([ + acceptedCalls?.[0], + acceptedCalls?.[1], + acceptedCalls?.[0], + acceptedCalls?.[1], + ]); +}); + +test("Refresh restores an earlier abort when a later capability check fails", async ({ + page, +}) => { + await openDeletionFixture(page, { + capability: true, + capabilitySequence: [true, true, true, false], + communities: [...DELETION_COMMUNITIES, SECOND_ARCHIVED], + quota: { used: 3, limit: 5, canCreate: true }, + statusSequence: ["submitted", "submitted", "aborted"], + }); + await startArchivedDeletion(page); + await startSecondArchivedDeletion(page); + await expect.poll(() => deletionPayloads(page)).toHaveLength(2); + const acceptedCalls = await deletionPayloads(page); + + await page.getByRole("button", { name: "Refresh" }).click(); + await expect(page.getByRole("button", { name: "Refresh" })).toBeEnabled(); + const firstRow = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .filter({ hasNotText: "Second archived team" }); + await expect(firstRow).toBeVisible(); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Second archived team" }), + ).toHaveCount(0); + await expect( + page.getByText("Deletion started", { exact: true }), + ).toBeVisible(); + expect(await deletionPayloads(page)).toEqual([ + acceptedCalls?.[0], + acceptedCalls?.[1], + acceptedCalls?.[0], + ]); +}); + for (const scenario of [ { name: "non-aborted stage", statusSequence: ["submitted", "approved"] }, { name: "uncertain 503", errorSequence: [null, { code: "acceptance_unknown" }], + expectsCheckError: true, }, { name: "error-only 409", errorSequence: [null, { code: "deletion_aborted" }], + expectsCheckError: true, }, ]) { test(`Refresh keeps an accepted row hidden after ${scenario.name}`, async ({ @@ -1123,6 +1213,14 @@ for (const scenario of [ exact: true, }), ).toHaveCount(0); + const checkError = page.getByText("Couldn't check deletion status.", { + exact: true, + }); + if (scenario.expectsCheckError) { + await expect(checkError).toBeVisible(); + } else { + await expect(checkError).toHaveCount(0); + } }); } diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 2500a5fdd4..29a24e609a 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -196,6 +196,7 @@ type MockBridgeOptions = { code: string; message?: string; } | null>; + builderlabDeletionRejectSequence?: boolean[]; builderlabDeletionStatusSequence?: string[]; builderlabDeletionHttpStatusSequence?: number[]; builderlabDeletionBodyStatus?: number;