|
| 1 | +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 2 | +import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router"; |
| 3 | +import { render, screen, waitFor, within } from "@testing-library/react"; |
| 4 | +import userEvent from "@testing-library/user-event"; |
| 5 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 6 | +import { ConfirmDialog } from "@/components/shared/ConfirmDialog"; |
| 7 | +import { DemoProvider } from "@/context/DemoContext"; |
| 8 | +import { MetadataProvider } from "@/context/MetadataContext"; |
| 9 | +import { saveStore } from "@/lib/config"; |
| 10 | +import { routeTree } from "@/routeTree.gen"; |
| 11 | + |
| 12 | +const { httpFetch } = vi.hoisted(() => ({ httpFetch: vi.fn() })); |
| 13 | +vi.mock("@/lib/http", () => ({ httpFetch })); |
| 14 | + |
| 15 | +const WORKSPACE_ID = "ws-alpha"; |
| 16 | +const CONFLICT_DETAIL = |
| 17 | + "Cannot delete workspace 'ws-alpha': active session(s) remain. Delete all sessions first."; |
| 18 | +const INSTANCE = { |
| 19 | + id: "inst-1", |
| 20 | + name: "Local", |
| 21 | + baseUrl: "http://localhost:8000", |
| 22 | + token: "secret-token", |
| 23 | +}; |
| 24 | + |
| 25 | +function json(body: unknown, status = 200) { |
| 26 | + return new Response(JSON.stringify(body), { |
| 27 | + status, |
| 28 | + headers: { "Content-Type": "application/json" }, |
| 29 | + }); |
| 30 | +} |
| 31 | + |
| 32 | +function requestOf(input: Request | string, init?: RequestInit) { |
| 33 | + return typeof input === "string" ? new Request(input, init) : input; |
| 34 | +} |
| 35 | + |
| 36 | +function mockHoncho(options: { onDelete?: (req: Request) => Promise<Response> | Response } = {}) { |
| 37 | + httpFetch.mockImplementation(async (input: Request | string, init?: RequestInit) => { |
| 38 | + const req = requestOf(input, init); |
| 39 | + const url = req.url; |
| 40 | + if (req.method === "DELETE") { |
| 41 | + return options.onDelete ? options.onDelete(req) : json({}, 202); |
| 42 | + } |
| 43 | + if (url.includes("/queue/status")) { |
| 44 | + return json({ |
| 45 | + in_progress_work_units: 0, |
| 46 | + pending_work_units: 0, |
| 47 | + completed_work_units: 0, |
| 48 | + total_work_units: 0, |
| 49 | + }); |
| 50 | + } |
| 51 | + if (url.includes("/v3/workspaces") && !url.includes("list")) { |
| 52 | + return json({ |
| 53 | + id: WORKSPACE_ID, |
| 54 | + metadata: {}, |
| 55 | + created_at: "2026-01-01T00:00:00Z", |
| 56 | + }); |
| 57 | + } |
| 58 | + return json({ |
| 59 | + items: [{ id: WORKSPACE_ID, created_at: "2026-01-01T00:00:00Z" }], |
| 60 | + total: 1, |
| 61 | + page: 1, |
| 62 | + size: 20, |
| 63 | + pages: 1, |
| 64 | + }); |
| 65 | + }); |
| 66 | +} |
| 67 | + |
| 68 | +function renderWorkspace() { |
| 69 | + saveStore({ instances: [INSTANCE], activeId: INSTANCE.id }); |
| 70 | + const router = createRouter({ |
| 71 | + routeTree, |
| 72 | + history: createMemoryHistory({ initialEntries: [`/workspaces/${WORKSPACE_ID}`] }), |
| 73 | + }); |
| 74 | + const qc = new QueryClient({ |
| 75 | + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, |
| 76 | + }); |
| 77 | + return { |
| 78 | + router, |
| 79 | + ...render( |
| 80 | + <QueryClientProvider client={qc}> |
| 81 | + <DemoProvider> |
| 82 | + <MetadataProvider> |
| 83 | + {/* biome-ignore lint/suspicious/noExplicitAny: test router type */} |
| 84 | + <RouterProvider router={router as any} /> |
| 85 | + </MetadataProvider> |
| 86 | + </DemoProvider> |
| 87 | + </QueryClientProvider>, |
| 88 | + ), |
| 89 | + }; |
| 90 | +} |
| 91 | + |
| 92 | +async function confirmDelete() { |
| 93 | + const user = userEvent.setup(); |
| 94 | + await user.click(await screen.findByRole("button", { name: "Delete" })); |
| 95 | + const dialog = await screen.findByRole("dialog"); |
| 96 | + await user.click(within(dialog).getByRole("button", { name: "Delete workspace" })); |
| 97 | + return { user, dialog }; |
| 98 | +} |
| 99 | + |
| 100 | +describe("workspace delete errors", () => { |
| 101 | + afterEach(() => { |
| 102 | + httpFetch.mockReset(); |
| 103 | + localStorage.clear(); |
| 104 | + }); |
| 105 | + |
| 106 | + it("shows the 409 detail after confirming deletion", async () => { |
| 107 | + mockHoncho({ |
| 108 | + onDelete: () => json({ detail: CONFLICT_DETAIL }, 409), |
| 109 | + }); |
| 110 | + renderWorkspace(); |
| 111 | + await confirmDelete(); |
| 112 | + expect((await screen.findByRole("alert")).textContent).toBe(CONFLICT_DETAIL); |
| 113 | + }); |
| 114 | + |
| 115 | + it("keeps the confirmation dialog open after a 409", async () => { |
| 116 | + mockHoncho({ |
| 117 | + onDelete: () => json({ detail: CONFLICT_DETAIL }, 409), |
| 118 | + }); |
| 119 | + renderWorkspace(); |
| 120 | + await confirmDelete(); |
| 121 | + await screen.findByRole("alert"); |
| 122 | + expect(screen.getByRole("dialog")).toHaveAccessibleName("Delete workspace"); |
| 123 | + }); |
| 124 | + |
| 125 | + it("leaves the confirm button usable after a failed deletion", async () => { |
| 126 | + mockHoncho({ |
| 127 | + onDelete: () => json({ detail: CONFLICT_DETAIL }, 409), |
| 128 | + }); |
| 129 | + renderWorkspace(); |
| 130 | + const { dialog } = await confirmDelete(); |
| 131 | + await screen.findByRole("alert"); |
| 132 | + expect(within(dialog).getByRole("button", { name: "Delete workspace" })).toBeEnabled(); |
| 133 | + }); |
| 134 | + |
| 135 | + it("retries deletion from the open dialog after a 409", async () => { |
| 136 | + let deletes = 0; |
| 137 | + mockHoncho({ |
| 138 | + onDelete: () => { |
| 139 | + deletes += 1; |
| 140 | + return json({ detail: CONFLICT_DETAIL }, 409); |
| 141 | + }, |
| 142 | + }); |
| 143 | + renderWorkspace(); |
| 144 | + const { user, dialog } = await confirmDelete(); |
| 145 | + await screen.findByRole("alert"); |
| 146 | + await user.click(within(dialog).getByRole("button", { name: "Delete workspace" })); |
| 147 | + await waitFor(() => { |
| 148 | + expect(deletes).toBe(2); |
| 149 | + }); |
| 150 | + }); |
| 151 | + |
| 152 | + it("does not keep a stale error after cancel and reopen", async () => { |
| 153 | + mockHoncho({ |
| 154 | + onDelete: () => json({ detail: CONFLICT_DETAIL }, 409), |
| 155 | + }); |
| 156 | + renderWorkspace(); |
| 157 | + const { user, dialog } = await confirmDelete(); |
| 158 | + await screen.findByRole("alert"); |
| 159 | + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); |
| 160 | + await user.click(await screen.findByRole("button", { name: "Delete" })); |
| 161 | + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); |
| 162 | + }); |
| 163 | + |
| 164 | + it("shows a fallback message when deletion fails without an API detail", async () => { |
| 165 | + mockHoncho({ |
| 166 | + onDelete: () => Promise.reject(new Error("Network request failed")), |
| 167 | + }); |
| 168 | + renderWorkspace(); |
| 169 | + await confirmDelete(); |
| 170 | + expect((await screen.findByRole("alert")).textContent).toBe("Network request failed"); |
| 171 | + }); |
| 172 | + |
| 173 | + it("navigates to the workspace list after a successful deletion", async () => { |
| 174 | + mockHoncho(); |
| 175 | + const { router } = renderWorkspace(); |
| 176 | + await confirmDelete(); |
| 177 | + await waitFor(() => { |
| 178 | + expect(router.state.location.pathname).toBe("/workspaces"); |
| 179 | + }); |
| 180 | + }); |
| 181 | + |
| 182 | + it("does not render an alert when ConfirmDialog has no error", () => { |
| 183 | + render( |
| 184 | + <ConfirmDialog |
| 185 | + open |
| 186 | + title="Delete webhook" |
| 187 | + description="This endpoint will stop receiving events immediately." |
| 188 | + onConfirm={() => {}} |
| 189 | + onCancel={() => {}} |
| 190 | + />, |
| 191 | + ); |
| 192 | + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); |
| 193 | + }); |
| 194 | + |
| 195 | + it("does not put the instance token in the delete dialog", async () => { |
| 196 | + mockHoncho({ |
| 197 | + onDelete: () => json({ detail: CONFLICT_DETAIL }, 409), |
| 198 | + }); |
| 199 | + renderWorkspace(); |
| 200 | + await confirmDelete(); |
| 201 | + await screen.findByRole("alert"); |
| 202 | + expect(screen.getByRole("dialog").textContent).not.toContain(INSTANCE.token); |
| 203 | + }); |
| 204 | +}); |
0 commit comments