Skip to content

Commit b9a1786

Browse files
committed
test(ui): add tests for admin namespace page components
Add 52 tests covering EditNamespaceDrawer (form pre-filling, validation, submit payload, 409/generic errors, state reset) and DeleteNamespaceDialog (cascade warning, mutation calls, callbacks, error handling).
1 parent e3f2ebd commit b9a1786

2 files changed

Lines changed: 673 additions & 0 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import type { ReactNode } from "react";
2+
import { describe, it, expect, vi, beforeEach } from "vitest";
3+
import { render, screen, waitFor } from "@testing-library/react";
4+
import userEvent from "@testing-library/user-event";
5+
import DeleteNamespaceDialog from "../DeleteNamespaceDialog";
6+
import { useDeleteNamespace } from "../../../../hooks/useAdminNamespaceMutations";
7+
8+
vi.mock("../../../../hooks/useAdminNamespaceMutations", () => ({
9+
useDeleteNamespace: vi.fn(),
10+
}));
11+
12+
// ConfirmDialog manages open/close state and calls onConfirm on button click.
13+
// We flatten it to a plain div so we can exercise the component's logic without
14+
// the real dialog's animations, portals, or BaseDialog internals.
15+
vi.mock("../../../../components/common/ConfirmDialog", () => ({
16+
default: ({
17+
open,
18+
onClose,
19+
onConfirm,
20+
title,
21+
description,
22+
confirmLabel = "Confirm",
23+
}: {
24+
open: boolean;
25+
onClose: () => void;
26+
onConfirm: () => Promise<void> | void;
27+
title: string;
28+
description: ReactNode;
29+
confirmLabel?: string;
30+
}) => {
31+
if (!open) return null;
32+
return (
33+
<div role="dialog" aria-label={title}>
34+
<h2>{title}</h2>
35+
<div>{description}</div>
36+
<button onClick={onClose}>Cancel</button>
37+
<button onClick={() => void onConfirm()}>{confirmLabel}</button>
38+
</div>
39+
);
40+
},
41+
}));
42+
43+
const mockMutateAsync = vi.fn();
44+
45+
const mockNamespace = {
46+
tenant_id: "tenant-xyz",
47+
name: "test-namespace",
48+
};
49+
50+
beforeEach(() => {
51+
vi.clearAllMocks();
52+
vi.mocked(useDeleteNamespace).mockReturnValue({
53+
mutateAsync: mockMutateAsync,
54+
} as never);
55+
});
56+
57+
function renderDialog(
58+
overrides: Partial<{
59+
open: boolean;
60+
onClose: () => void;
61+
namespace: typeof mockNamespace | null;
62+
onDeleted: () => void;
63+
}> = {},
64+
) {
65+
const defaults = {
66+
open: true,
67+
onClose: vi.fn(),
68+
namespace: mockNamespace,
69+
onDeleted: vi.fn(),
70+
};
71+
const props = { ...defaults, ...overrides };
72+
return {
73+
onClose: props.onClose,
74+
onDeleted: props.onDeleted,
75+
...render(<DeleteNamespaceDialog {...props} />),
76+
};
77+
}
78+
79+
describe("DeleteNamespaceDialog", () => {
80+
describe("rendering — closed", () => {
81+
it("renders nothing when open is false", () => {
82+
renderDialog({ open: false });
83+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
84+
});
85+
});
86+
87+
describe("rendering — open", () => {
88+
it("renders the dialog when open is true", () => {
89+
renderDialog();
90+
expect(screen.getByRole("dialog")).toBeInTheDocument();
91+
});
92+
93+
it("renders the 'Delete Namespace' title", () => {
94+
renderDialog();
95+
expect(screen.getByText("Delete Namespace")).toBeInTheDocument();
96+
});
97+
98+
it("renders the namespace name in the description", () => {
99+
renderDialog();
100+
expect(screen.getByText("test-namespace")).toBeInTheDocument();
101+
});
102+
103+
it("renders the cascade warning about devices, sessions, public keys, and API keys", () => {
104+
renderDialog();
105+
expect(
106+
screen.getByText(/devices.*sessions.*public keys.*api keys/i),
107+
).toBeInTheDocument();
108+
});
109+
110+
it("renders the 'Delete' confirm button", () => {
111+
renderDialog();
112+
expect(
113+
screen.getByRole("button", { name: /^delete$/i }),
114+
).toBeInTheDocument();
115+
});
116+
117+
it("renders the Cancel button", () => {
118+
renderDialog();
119+
expect(
120+
screen.getByRole("button", { name: /cancel/i }),
121+
).toBeInTheDocument();
122+
});
123+
});
124+
125+
describe("confirm — success", () => {
126+
it("calls mutateAsync with the correct tenant_id", async () => {
127+
mockMutateAsync.mockResolvedValue(undefined);
128+
renderDialog();
129+
130+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
131+
132+
await waitFor(() => {
133+
expect(mockMutateAsync).toHaveBeenCalledWith({
134+
path: { tenant: "tenant-xyz" },
135+
});
136+
});
137+
});
138+
139+
it("calls onDeleted callback after successful deletion", async () => {
140+
mockMutateAsync.mockResolvedValue(undefined);
141+
const { onDeleted } = renderDialog();
142+
143+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
144+
145+
await waitFor(() => expect(onDeleted).toHaveBeenCalledTimes(1));
146+
});
147+
148+
it("calls onClose after successful deletion", async () => {
149+
mockMutateAsync.mockResolvedValue(undefined);
150+
const { onClose } = renderDialog();
151+
152+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
153+
154+
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
155+
});
156+
157+
it("calls onClose before onDeleted", async () => {
158+
mockMutateAsync.mockResolvedValue(undefined);
159+
const callOrder: string[] = [];
160+
const onClose = vi.fn(() => callOrder.push("onClose"));
161+
const onDeleted = vi.fn(() => callOrder.push("onDeleted"));
162+
render(
163+
<DeleteNamespaceDialog
164+
open={true}
165+
onClose={onClose}
166+
namespace={mockNamespace}
167+
onDeleted={onDeleted}
168+
/>,
169+
);
170+
171+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
172+
173+
await waitFor(() => expect(onDeleted).toHaveBeenCalledTimes(1));
174+
expect(callOrder).toEqual(["onClose", "onDeleted"]);
175+
});
176+
});
177+
178+
describe("confirm — error handling", () => {
179+
it("shows generic error message on failure", async () => {
180+
mockMutateAsync.mockRejectedValue(new Error("server error"));
181+
renderDialog();
182+
183+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
184+
185+
await waitFor(() => {
186+
expect(
187+
screen.getByText(/failed to delete namespace/i),
188+
).toBeInTheDocument();
189+
});
190+
});
191+
192+
it("shows error for SDK errors", async () => {
193+
mockMutateAsync.mockRejectedValue({ status: 500 });
194+
renderDialog();
195+
196+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
197+
198+
await waitFor(() => {
199+
expect(
200+
screen.getByText(/failed to delete namespace/i),
201+
).toBeInTheDocument();
202+
});
203+
});
204+
205+
it("does not call onDeleted when deletion fails", async () => {
206+
mockMutateAsync.mockRejectedValue(new Error("server error"));
207+
const { onDeleted } = renderDialog();
208+
209+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
210+
211+
await waitFor(() => screen.getByText(/failed to delete namespace/i));
212+
expect(onDeleted).not.toHaveBeenCalled();
213+
});
214+
215+
it("does not call onClose when deletion fails", async () => {
216+
mockMutateAsync.mockRejectedValue(new Error("server error"));
217+
const { onClose } = renderDialog();
218+
219+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
220+
221+
await waitFor(() => screen.getByText(/failed to delete namespace/i));
222+
expect(onClose).not.toHaveBeenCalled();
223+
});
224+
});
225+
226+
describe("cancel", () => {
227+
it("calls onClose when Cancel is clicked", async () => {
228+
const { onClose } = renderDialog();
229+
await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
230+
expect(onClose).toHaveBeenCalledTimes(1);
231+
});
232+
233+
it("does not call mutateAsync when Cancel is clicked", async () => {
234+
renderDialog();
235+
await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
236+
expect(mockMutateAsync).not.toHaveBeenCalled();
237+
});
238+
239+
it("does not call onDeleted when Cancel is clicked", async () => {
240+
const { onDeleted } = renderDialog();
241+
await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
242+
expect(onDeleted).not.toHaveBeenCalled();
243+
});
244+
});
245+
246+
describe("null namespace", () => {
247+
it("renders nothing meaningful in the description when namespace is null", () => {
248+
renderDialog({ namespace: null });
249+
expect(screen.queryByText("test-namespace")).not.toBeInTheDocument();
250+
});
251+
252+
it("does not call mutateAsync when confirmed with null namespace", async () => {
253+
renderDialog({ namespace: null });
254+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
255+
await waitFor(() => expect(mockMutateAsync).not.toHaveBeenCalled());
256+
});
257+
});
258+
259+
describe("optional onDeleted callback", () => {
260+
it("does not throw when onDeleted is not provided and deletion succeeds", async () => {
261+
mockMutateAsync.mockResolvedValue(undefined);
262+
const { onClose } = renderDialog({ onDeleted: undefined });
263+
264+
await userEvent.click(screen.getByRole("button", { name: /^delete$/i }));
265+
266+
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
267+
});
268+
});
269+
});

0 commit comments

Comments
 (0)