diff --git a/components/settings/panel/Delete.vue b/components/settings/panel/Delete.vue index 679a246d..e8edb929 100644 --- a/components/settings/panel/Delete.vue +++ b/components/settings/panel/Delete.vue @@ -96,7 +96,15 @@ async function submitDelete() { isDeleting.value = true; try { - await workspacesClient.deleteWorkspace(workspace.id); + const { osmRequestSucceeded: osmCleanupSucceeded } = await workspacesClient.deleteWorkspace(workspace.id); + + if (!osmCleanupSucceeded) { + toast.info( + 'The workspace has been deleted, but the data may persist for some more time.', + { clearOnUrlChange: false } + ); + } + await navigateTo('/dashboard'); } catch (error) { diff --git a/services/workspaces.ts b/services/workspaces.ts index c2032c2f..e6d03d0f 100644 --- a/services/workspaces.ts +++ b/services/workspaces.ts @@ -79,6 +79,10 @@ export class WorkspacesClientError extends Error { } } +export interface DeleteWorkspaceResult { + osmRequestSucceeded: boolean; +} + export class WorkspacesClient extends BaseHttpClient implements ICancelableClient { #tdeiClient: TdeiClient; #osmClient: OsmApiClient; @@ -241,11 +245,15 @@ export class WorkspacesClient extends BaseHttpClient implements ICancelableClien ); } - async deleteWorkspace(id: WorkspaceId): Promise { - await Promise.all([ + async deleteWorkspace(id: WorkspaceId): Promise { + const [workspaceDelete, osmDelete] = await Promise.allSettled([ this._delete(`workspaces/${id}`), this.#osmClient.deleteWorkspace(id), ]); + if (workspaceDelete.status === 'rejected') { + throw workspaceDelete.reason; + } + return { osmRequestSucceeded: osmDelete.status === 'fulfilled' }; } async getLongFormQuestSettings(id: WorkspaceId): Promise { diff --git a/test/e2e/settings.spec.ts b/test/e2e/settings.spec.ts index 46abcd4f..d283fdf1 100644 --- a/test/e2e/settings.spec.ts +++ b/test/e2e/settings.spec.ts @@ -104,6 +104,9 @@ function successToast(page: Page) { function errorToast(page: Page) { return page.locator('.Toastify__toast--error'); } +function infoToast(page: Page) { + return page.locator('.Toastify__toast--info'); +} test.describe('workspace settings', () => { // @test e2e: the workspace can be renamed with the "Workspace Title" field, then clicking "Rename"; @@ -433,6 +436,34 @@ test.describe('workspace settings', () => { await expect(page).toHaveURL(/\/dashboard$/); }); + test('shows an info toast when deletion succeeds but OSM cleanup fails', async ({ page }) => { + await seedAuthenticatedSession(page); + await stubSettings(page); + + await page.route('**/workspaces/1', async (route) => { + if (route.request().method() === 'DELETE') { + return route.fulfill({ status: 204, body: '' }); + } + return route.fallback(); + }); + await page.route('**/osm/**', (route) => { + return route.fulfill({ status: 403, body: 'Forbidden' }); + }); + + await page.goto('/workspace/1/settings'); + + const del = page.locator('div.card', { hasText: 'Delete Workspace' }); + await del.getByRole('button', { name: /^I understand/ }).click(); + await del.getByRole('textbox').fill('delete'); + await del.getByRole('button', { name: 'Delete this workspace', exact: true }).click(); + + await expect(page).toHaveURL(/\/dashboard$/); + await expect(infoToast(page)).toContainText( + 'The workspace has been deleted, but the data may persist for some more time.' + ); + await expect(errorToast(page)).toHaveCount(0); + }); + // @test e2e: validate that all the API calls used on this page match the Swagger spec and that // success and error states are handled properly with toasts (snapshot these). test('all API calls conform to the OpenAPI spec', async ({ page }) => { diff --git a/test/unit/services/workspaces.test.ts b/test/unit/services/workspaces.test.ts index bfeade8a..cff1cccf 100644 --- a/test/unit/services/workspaces.test.ts +++ b/test/unit/services/workspaces.test.ts @@ -174,6 +174,49 @@ describe('WorkspacesClient.createBlankWorkspace', () => { }); }); +describe('WorkspacesClient.deleteWorkspace', () => { + it('reports failed OSM cleanup after the workspace record is deleted', async () => { + const deleteOsmWorkspace = vi.fn().mockRejectedValue(new Error('Forbidden')); + const client = new WorkspacesClient( + TEST_API_BASE, + TEST_API_BASE, + tdeiClient, + { deleteWorkspace: deleteOsmWorkspace } as unknown as OsmApiClient + ); + + server.use( + http.delete(`${TEST_API_BASE}workspaces/1`, () => { + return new HttpResponse(null, { status: 204 }); + }) + ); + + await expect(client.deleteWorkspace(1)).resolves.toEqual({ + osmRequestSucceeded: false + }); + expect(deleteOsmWorkspace).toHaveBeenCalledWith(1); + }); + + it('still rejects when the workspace record cannot be deleted', async () => { + const deleteOsmWorkspace = vi.fn().mockResolvedValue(undefined); + const client = new WorkspacesClient( + TEST_API_BASE, + TEST_API_BASE, + tdeiClient, + { deleteWorkspace: deleteOsmWorkspace } as unknown as OsmApiClient + ); + + server.use( + http.delete(`${TEST_API_BASE}workspaces/1`, () => { + return new HttpResponse(null, { status: 500, statusText: 'Server Error' }); + }) + ); + + await expect(client.deleteWorkspace(1)).rejects.toBeInstanceOf( + WorkspacesClientError + ); + }); +}); + describe('WorkspacesClient.checkWorkspaceTitleAvailability', () => { const LEGACY_API_BASE = 'http://legacy-api.test/'; const NEW_API_BASE = 'http://new-api.test/';