diff --git a/ui/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx b/ui/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx index 02af40909e4..79dff417cb9 100644 --- a/ui/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx +++ b/ui/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx @@ -56,82 +56,41 @@ beforeEach(() => { }); describe("AnnouncementModal", () => { - describe("when open=false", () => { - it("renders nothing", () => { - renderModal({ open: false }); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); + it("renders nothing when open=false", () => { + renderModal({ open: false }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - describe("when open=true", () => { - it("renders the dialog element", () => { - renderModal(); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); - - it("shows the announcement title", () => { - renderModal({ - announcement: makeAnnouncement({ title: "Big Announcement" }), - }); - expect(screen.getByText("Big Announcement")).toBeInTheDocument(); - }); - - it("shows the formatted announcement date", () => { - renderModal({ - announcement: makeAnnouncement({ date: "2024-06-15T12:00:00Z" }), - }); - expect(screen.getByText("Jun 15, 2024")).toBeInTheDocument(); - }); - - it("renders the close button with correct aria-label", () => { - renderModal(); - expect( - screen.getByRole("button", { name: /close announcement/i }), - ).toBeInTheDocument(); - }); - - it("renders the 'Got it' button", () => { - renderModal(); - expect( - screen.getByRole("button", { name: /got it/i }), - ).toBeInTheDocument(); + it("shows the formatted announcement date", () => { + renderModal({ + announcement: makeAnnouncement({ date: "2024-06-15T12:00:00Z" }), }); + expect(screen.getByText("Jun 15, 2024")).toBeInTheDocument(); }); - describe("accessibility", () => { - it("dialog is labelled by the title element", () => { - renderModal({ - announcement: makeAnnouncement({ title: "My Announcement" }), - }); - const dialog = screen.getByRole("dialog"); - const labelledById = dialog.getAttribute("aria-labelledby"); - expect(labelledById).toBeTruthy(); - - const titleEl = document.getElementById(labelledById!); - expect(titleEl).not.toBeNull(); - expect(titleEl!.textContent).toBe("My Announcement"); + it("labels the dialog with the announcement title", () => { + renderModal({ + announcement: makeAnnouncement({ title: "My Announcement" }), }); + const labelledById = screen + .getByRole("dialog") + .getAttribute("aria-labelledby"); + expect(labelledById).toBeTruthy(); + + const titleEl = document.getElementById(labelledById!); + expect(titleEl).not.toBeNull(); + expect(titleEl!.textContent).toBe("My Announcement"); }); - describe("close interactions", () => { - it("calls onClose when the close button is clicked", async () => { + it.each([/close announcement/i, /got it/i])( + "calls onClose when the %s button is clicked", + async (name) => { const user = userEvent.setup(); const { onClose } = renderModal(); - await user.click( - screen.getByRole("button", { name: /close announcement/i }), - ); + await user.click(screen.getByRole("button", { name })); expect(onClose).toHaveBeenCalledOnce(); - }); - - it("calls onClose when 'Got it' is clicked", async () => { - const user = userEvent.setup(); - const { onClose } = renderModal(); - - await user.click(screen.getByRole("button", { name: /got it/i })); - - expect(onClose).toHaveBeenCalledOnce(); - }); - }); + }, + ); }); diff --git a/ui/apps/console/src/components/billing/__tests__/BillingDialog.test.tsx b/ui/apps/console/src/components/billing/__tests__/BillingDialog.test.tsx index 638bde3ec37..c9e90935b11 100644 --- a/ui/apps/console/src/components/billing/__tests__/BillingDialog.test.tsx +++ b/ui/apps/console/src/components/billing/__tests__/BillingDialog.test.tsx @@ -97,39 +97,20 @@ async function goToStep3(user: ReturnType) { describe("BillingDialog", () => { describe("Step 1 — Overview", () => { - it("renders BillingLetter on step 1", () => { - renderDialog(); - expect(screen.getByTestId("billing-letter")).toBeInTheDocument(); - }); - - it("shows a 'Next' button on step 1", () => { - renderDialog(); - expect(screen.getByRole("button", { name: /next/i })).toBeInTheDocument(); - }); - it("clicking 'Next' advances to step 2 (BillingPayment)", async () => { const user = userEvent.setup(); - renderDialog(); - await goToStep2(user); - expect(screen.getByTestId("billing-payment")).toBeInTheDocument(); - }); - - it("announces step 1 in the sr-only live region", () => { renderDialog(); expect(screen.getByRole("status")).toHaveTextContent( /step 1 of 4.*overview/i, ); - }); - }); - describe("Step 2 — Payment method", () => { - it("renders BillingPayment on step 2", async () => { - const user = userEvent.setup(); - renderDialog(); await goToStep2(user); + expect(screen.getByTestId("billing-payment")).toBeInTheDocument(); }); + }); + describe("Step 2 — Payment method", () => { it("'Next' button is disabled until onHasDefault fires", async () => { const user = userEvent.setup(); renderDialog(); @@ -159,37 +140,23 @@ describe("BillingDialog", () => { it("clicking 'Next' after onHasDefault advances to step 3 (BillingCheckout)", async () => { const user = userEvent.setup(); renderDialog(); - await goToStep3(user); - expect(screen.getByTestId("billing-checkout")).toBeInTheDocument(); - }); - it("announces step 2 in the sr-only live region", async () => { - const user = userEvent.setup(); - renderDialog(); await goToStep2(user); expect(screen.getByRole("status")).toHaveTextContent( /step 2 of 4.*payment method/i, ); - }); - }); - describe("Step 3 — Review", () => { - it("renders BillingCheckout on step 3", async () => { - const user = userEvent.setup(); - renderDialog(); - await goToStep3(user); - expect(screen.getByTestId("billing-checkout")).toBeInTheDocument(); - }); + await user.click(screen.getByTestId("trigger-has-default")); + await user.click(screen.getByRole("button", { name: /^next$/i })); - it("has a 'Confirm subscription' button on step 3", async () => { - const user = userEvent.setup(); - renderDialog(); - await goToStep3(user); - expect( - screen.getByRole("button", { name: /confirm subscription/i }), - ).toBeInTheDocument(); + expect(screen.getByTestId("billing-checkout")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent( + /step 3 of 4.*review/i, + ); }); + }); + describe("Step 3 — Review", () => { it("advances to step 4 after subscription is active", async () => { const user = userEvent.setup(); renderDialog(); @@ -310,15 +277,6 @@ describe("BillingDialog", () => { ).toBeDisabled(), ); }); - - it("announces step 3 in the sr-only live region", async () => { - const user = userEvent.setup(); - renderDialog(); - await goToStep3(user); - expect(screen.getByRole("status")).toHaveTextContent( - /step 3 of 4.*review/i, - ); - }); }); describe("Step 4 — Success", () => { @@ -332,29 +290,18 @@ describe("BillingDialog", () => { ); } - it("renders BillingSuccessful on step 4", async () => { - const user = userEvent.setup(); - renderDialog(); - await goToStep4(user); - expect(screen.getByTestId("billing-successful")).toBeInTheDocument(); - }); - it("clicking 'Done' calls onSuccess and onClose", async () => { const user = userEvent.setup(); const { onClose, onSuccess } = renderDialog(); await goToStep4(user); - await user.click(screen.getByRole("button", { name: /done/i })); - expect(onSuccess).toHaveBeenCalledOnce(); - expect(onClose).toHaveBeenCalledOnce(); - }); - - it("announces step 4 in the sr-only live region", async () => { - const user = userEvent.setup(); - renderDialog(); - await goToStep4(user); expect(screen.getByRole("status")).toHaveTextContent( /step 4 of 4.*success/i, ); + + await user.click(screen.getByRole("button", { name: /done/i })); + + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); }); }); @@ -402,33 +349,5 @@ describe("BillingDialog", () => { await user.click(screen.getByRole("button", { name: /^close$/i })); expect(onClose).toHaveBeenCalledOnce(); }); - - it("clicking the X (aria-label 'Close wizard') calls onClose on step 1", async () => { - const user = userEvent.setup(); - const { onClose } = renderDialog(); - await user.click(screen.getByRole("button", { name: /close wizard/i })); - expect(onClose).toHaveBeenCalledOnce(); - }); - - it("close buttons are present on step 2", async () => { - const user = userEvent.setup(); - renderDialog(); - await goToStep2(user); - expect( - screen.getByRole("button", { name: /close wizard/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /^close$/i }), - ).toBeInTheDocument(); - }); - - it("close buttons are present on step 3", async () => { - const user = userEvent.setup(); - renderDialog(); - await goToStep3(user); - expect( - screen.getByRole("button", { name: /close wizard/i }), - ).toBeInTheDocument(); - }); }); }); diff --git a/ui/apps/console/src/components/billing/__tests__/BillingSection.test.tsx b/ui/apps/console/src/components/billing/__tests__/BillingSection.test.tsx index 128d0cc05e8..1e3cd51a530 100644 --- a/ui/apps/console/src/components/billing/__tests__/BillingSection.test.tsx +++ b/ui/apps/console/src/components/billing/__tests__/BillingSection.test.tsx @@ -104,132 +104,36 @@ beforeEach(() => { setInactive(); }); -describe("BillingSection — Subscribe button visibility", () => { - it("shows Subscribe button when status is 'inactive'", async () => { - setInactive(); - renderSection(); - expect( - await screen.findByRole("button", { name: /subscribe/i }), - ).toBeInTheDocument(); - }); - - it("does not show portal button when status is 'inactive'", async () => { - setInactive(); - renderSection(); - await screen.findByRole("button", { name: /subscribe/i }); - expect( - screen.queryByRole("button", { name: /open portal/i }), - ).not.toBeInTheDocument(); - }); - - it("shows Subscribe button when status is 'canceled'", async () => { - setStatus("canceled"); - renderSection(); - expect( - await screen.findByRole("button", { name: /subscribe/i }), - ).toBeInTheDocument(); - }); - - it("shows Subscribe button when status is 'incomplete_expired'", async () => { - setStatus("incomplete_expired"); - renderSection(); - expect( - await screen.findByRole("button", { name: /subscribe/i }), - ).toBeInTheDocument(); - }); - - it("shows portal button when status is 'canceled'", async () => { - setStatus("canceled"); - renderSection(); - expect( - await screen.findByRole("button", { name: /open portal/i }), - ).toBeInTheDocument(); - }); - - it("shows portal button when status is 'incomplete_expired'", async () => { - setStatus("incomplete_expired"); - renderSection(); - expect( - await screen.findByRole("button", { name: /open portal/i }), - ).toBeInTheDocument(); - }); - - it("does not show Subscribe button when status is 'active'", async () => { - setStatus("active"); - renderSection(); - await screen.findByRole("button", { name: /open portal/i }); - expect( - screen.queryByRole("button", { name: /subscribe/i }), - ).not.toBeInTheDocument(); - }); - - it("shows portal button when status is 'active'", async () => { - setStatus("active"); - renderSection(); - expect( - await screen.findByRole("button", { name: /open portal/i }), - ).toBeInTheDocument(); - }); - - it("does not show Subscribe button when status is 'incomplete'", async () => { - setStatus("incomplete"); - renderSection(); - await screen.findByRole("button", { name: /open portal/i }); - expect( - screen.queryByRole("button", { name: /subscribe/i }), - ).not.toBeInTheDocument(); - }); - - it("shows portal button when status is 'incomplete'", async () => { - setStatus("incomplete"); - renderSection(); - expect( - await screen.findByRole("button", { name: /open portal/i }), - ).toBeInTheDocument(); - }); - - it("does not show Subscribe button when status is 'unpaid'; shows portal instead", async () => { - setStatus("unpaid"); - renderSection(); - await screen.findByRole("button", { name: /open portal/i }); - expect( - screen.queryByRole("button", { name: /subscribe/i }), - ).not.toBeInTheDocument(); - }); - - it("does not show Subscribe button when status is 'paused'", async () => { - setStatus("paused"); - renderSection(); - await screen.findByRole("button", { name: /open portal/i }); - expect( - screen.queryByRole("button", { name: /subscribe/i }), - ).not.toBeInTheDocument(); - }); - - it("shows portal button when status is 'paused'", async () => { - setStatus("paused"); - renderSection(); - expect( - await screen.findByRole("button", { name: /open portal/i }), - ).toBeInTheDocument(); - }); - - it("does not show Subscribe button when status is 'past_due'", async () => { - setStatus("past_due"); - renderSection(); - await screen.findByRole("button", { name: /open portal/i }); - expect( - screen.queryByRole("button", { name: /subscribe/i }), - ).not.toBeInTheDocument(); - }); - - it("shows portal button when status is 'past_due'", async () => { - setStatus("past_due"); - renderSection(); - expect( - await screen.findByRole("button", { name: /open portal/i }), - ).toBeInTheDocument(); - }); +const SUBSCRIBE = /subscribe/i; +const PORTAL = /open portal/i; + +describe("BillingSection — Subscribe / portal button visibility", () => { + it.each([ + ["inactive", true, false], + ["canceled", true, true], + ["incomplete_expired", true, true], + ["active", false, true], + ["incomplete", false, true], + ["unpaid", false, true], + ["paused", false, true], + ["past_due", false, true], + ] as const)( + "status %s offers subscribe=%s portal=%s", + async (status, subscribe, portal) => { + if (status === "inactive") setInactive(); + else setStatus(status); + renderSection(); + + await waitFor(() => { + expect(screen.queryByRole("button", { name: SUBSCRIBE }) !== null).toBe( + subscribe, + ); + expect(screen.queryByRole("button", { name: PORTAL }) !== null).toBe( + portal, + ); + }); + }, + ); }); describe("BillingSection — non-owner", () => { @@ -237,63 +141,45 @@ describe("BillingSection — non-owner", () => { seedAuthStore({ role: "administrator" }); }); - it("shows the 'Owner-only' row", async () => { - renderSection(); - expect(await screen.findByText("Owner-only")).toBeInTheDocument(); - }); - - it("does not show the Subscribe button", async () => { + it("shows the 'Owner-only' row instead of the Subscribe button", async () => { + setInactive(); renderSection(); await screen.findByText("Owner-only"); expect( - screen.queryByRole("button", { name: /subscribe/i }), + screen.queryByRole("button", { name: SUBSCRIBE }), ).not.toBeInTheDocument(); }); - it("does not show the portal button", async () => { - setStatus("active"); + it("shows neither the portal button nor the banners", async () => { + setStatus("past_due"); renderSection(); await screen.findByText("Owner-only"); expect( - screen.queryByRole("button", { name: /open portal/i }), + screen.queryByRole("button", { name: PORTAL }), ).not.toBeInTheDocument(); - }); - - it("does not show banners", async () => { - setStatus("past_due"); - renderSection(); - await screen.findByText("Owner-only"); expect(screen.queryByText(/payment overdue/i)).not.toBeInTheDocument(); }); }); describe("BillingSection — banners", () => { - it("shows 'Payment overdue' banner for 'past_due' status", async () => { - setStatus("past_due"); - renderSection(); - expect(await screen.findByText("Payment overdue")).toBeInTheDocument(); - }); - - it("shows 'Subscription incomplete' banner with billing-portal wording for 'incomplete'", async () => { - setStatus("incomplete"); - renderSection(); - expect( - await screen.findByText("Subscription incomplete"), - ).toBeInTheDocument(); - expect(screen.getByText(/open the billing portal/i)).toBeInTheDocument(); - }); - - it("shows 'Subscription expired' banner with 'Subscribe again' wording for 'incomplete_expired'", async () => { - setStatus("incomplete_expired"); - renderSection(); - expect(await screen.findByText("Subscription expired")).toBeInTheDocument(); - expect(screen.getByText(/subscribe again/i)).toBeInTheDocument(); - }); + it.each([ + ["past_due", "Payment overdue", /open the billing portal/i], + ["incomplete", "Subscription incomplete", /open the billing portal/i], + ["incomplete_expired", "Subscription expired", /subscribe again/i], + ] as const)( + "status %s shows the '%s' banner", + async (status, title, wording) => { + setStatus(status); + renderSection(); + expect(await screen.findByText(title)).toBeInTheDocument(); + expect(screen.getByText(wording)).toBeInTheDocument(); + }, + ); it("shows no banner for 'active' status", async () => { setStatus("active"); renderSection(); - await screen.findByRole("button", { name: /open portal/i }); + await screen.findByRole("button", { name: PORTAL }); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); }); @@ -303,42 +189,23 @@ describe("BillingSection — Subscribe button interaction", () => { const user = userEvent.setup(); setInactive(); renderSection(); - await user.click(await screen.findByRole("button", { name: /subscribe/i })); + await user.click(await screen.findByRole("button", { name: SUBSCRIBE })); expect(await screen.findByTestId("billing-letter")).toBeInTheDocument(); expect( screen.getByRole("dialog", { name: /subscribe to shellhub cloud/i }), ).toBeInTheDocument(); }); - - it("closing BillingDialog via the X button hides it", async () => { - const user = userEvent.setup(); - setInactive(); - renderSection(); - await user.click(await screen.findByRole("button", { name: /subscribe/i })); - await screen.findByTestId("billing-letter"); - await user.click(screen.getByRole("button", { name: /close wizard/i })); - await waitFor(() => - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), - ); - }); }); describe("BillingSection — status badge", () => { - it("shows 'Inactive' badge when there is no subscription", async () => { - setInactive(); - renderSection(); - expect(await screen.findByText("Inactive")).toBeInTheDocument(); - }); - - it("shows 'Active' badge when status is active", async () => { - setStatus("active"); - renderSection(); - expect(await screen.findByText("Active")).toBeInTheDocument(); - }); - - it("shows 'Past due' badge when status is past_due", async () => { - setStatus("past_due"); - renderSection(); - expect(await screen.findByText("Past due")).toBeInTheDocument(); + it.each([ + ["inactive", "Inactive"], + ["active", "Active"], + ["past_due", "Past due"], + ] as const)("status %s shows the '%s' badge", async (status, badge) => { + if (status === "inactive") setInactive(); + else setStatus(status); + renderSection(); + expect(await screen.findByText(badge)).toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/components/billing/__tests__/DeviceChooserDialog.test.tsx b/ui/apps/console/src/components/billing/__tests__/DeviceChooserDialog.test.tsx index 9ce7aea9153..e2018fede58 100644 --- a/ui/apps/console/src/components/billing/__tests__/DeviceChooserDialog.test.tsx +++ b/ui/apps/console/src/components/billing/__tests__/DeviceChooserDialog.test.tsx @@ -85,25 +85,6 @@ beforeEach(() => { describe("DeviceChooserDialog", () => { describe("rendering", () => { - it("renders nothing when open=false", () => { - renderDialog({ open: false }); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - - it("renders the dialog title when open", async () => { - renderDialog(); - expect( - await screen.findByText(/update account or select three devices/i), - ).toBeInTheDocument(); - }); - - it("renders the description when open", async () => { - renderDialog(); - expect( - await screen.findByText(/subscribe to shellhub cloud/i), - ).toBeInTheDocument(); - }); - it("dialog has aria-labelledby pointing to the title", async () => { renderDialog(); await screen.findByText(/update account or select three devices/i); @@ -130,20 +111,6 @@ describe("DeviceChooserDialog", () => { }); describe("tab structure", () => { - it("renders a tablist with role=tablist", async () => { - renderDialog(); - expect(await screen.findByRole("tablist")).toBeInTheDocument(); - }); - - it("renders Suggested and All tabs with role=tab", async () => { - renderDialog(); - await screen.findByRole("tablist"); - const tabs = screen.getAllByRole("tab"); - const labels = tabs.map((t) => t.textContent); - expect(labels).toContain("Suggested"); - expect(labels).toContain("All"); - }); - it("Suggested tab is selected by default when suggested devices are non-empty", async () => { renderDialog(); await screen.findByText("hostname-1"); @@ -152,21 +119,9 @@ describe("DeviceChooserDialog", () => { "true", ); }); - - it("renders a tabpanel for the active tab", async () => { - renderDialog(); - expect(await screen.findByRole("tabpanel")).toBeInTheDocument(); - }); }); describe("Suggested tab", () => { - it("shows suggested device hostnames", async () => { - renderDialog(); - expect(await screen.findByText("hostname-1")).toBeInTheDocument(); - expect(screen.getByText("hostname-2")).toBeInTheDocument(); - expect(screen.getByText("hostname-3")).toBeInTheDocument(); - }); - it("does not render checkboxes on suggested tab (non-editable)", async () => { renderDialog(); await screen.findByText("hostname-1"); @@ -180,15 +135,6 @@ describe("DeviceChooserDialog", () => { screen.getByRole("button", { name: /accept/i }), ).not.toBeDisabled(); }); - - it("renders CheckIcon (heroicons) not an inline custom SVG for the selected-row check", async () => { - renderDialog(); - await screen.findByText("hostname-1"); - const inlinePath = document.querySelector('path[d="M3 8l3.5 3.5L13 5"]'); - expect(inlinePath).toBeNull(); - const checkSpan = document.querySelector('span[aria-hidden="true"] svg'); - expect(checkSpan).not.toBeNull(); - }); }); describe("when suggested list is empty", () => { @@ -241,19 +187,6 @@ describe("DeviceChooserDialog", () => { }); }); - describe("when suggested becomes empty after a refetch", () => { - it("forces tab to All when suggested starts empty", async () => { - setupHandlers({ suggested: [] }); - renderDialog(); - await waitFor(() => - expect(screen.getByRole("tab", { name: "All" })).toHaveAttribute( - "aria-selected", - "true", - ), - ); - }); - }); - describe("tab-switch selection persistence", () => { it("clears All-tab selections when the user explicitly switches to Suggested", async () => { const user = userEvent.setup(); @@ -278,27 +211,6 @@ describe("DeviceChooserDialog", () => { await user.click(screen.getByRole("tab", { name: "All" })); } - it("switches to All tab on click", async () => { - const user = userEvent.setup(); - renderDialog(); - await screen.findByText("hostname-1"); - await switchToAll(user); - expect(screen.getByRole("tab", { name: "All" })).toHaveAttribute( - "aria-selected", - "true", - ); - }); - - it("shows device checkboxes in the All tab", async () => { - const user = userEvent.setup(); - renderDialog(); - await screen.findByText("hostname-1"); - await switchToAll(user); - await screen.findByRole("checkbox", { name: /select hostname-10/i }); - const checkboxes = screen.getAllByRole("checkbox"); - expect(checkboxes.length).toBe(ALL_DEVICES.length); - }); - it("Accept button is disabled when no devices are selected in All tab", async () => { const user = userEvent.setup(); renderDialog(); @@ -451,14 +363,6 @@ describe("DeviceChooserDialog", () => { }); describe("Cancel button", () => { - it("calls onClose when Cancel is clicked", async () => { - const user = userEvent.setup(); - const { onClose } = renderDialog(); - await screen.findByText("hostname-1"); - await user.click(screen.getByRole("button", { name: /cancel/i })); - expect(onClose).toHaveBeenCalledOnce(); - }); - it("Cancel is disabled while mutation is in flight", async () => { server.use( http.post("*/api/billing/device-choice", () => new Promise(() => {})), @@ -482,14 +386,6 @@ describe("DeviceChooserDialog", () => { expect(mockNavigate).toHaveBeenCalledWith("/settings#billing"); }); - it("calls onClose when Subscribe is clicked", async () => { - const user = userEvent.setup(); - const { onClose } = renderDialog(); - await screen.findByText("hostname-1"); - await user.click(screen.getByRole("button", { name: /subscribe/i })); - expect(onClose).toHaveBeenCalledOnce(); - }); - it("Subscribe is disabled while mutation is in flight", async () => { server.use( http.post("*/api/billing/device-choice", () => new Promise(() => {})), @@ -527,21 +423,6 @@ describe("DeviceChooserDialog", () => { await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); }); - it("shows spinner and 'Saving…' text while mutation is pending", async () => { - server.use( - http.post("*/api/billing/device-choice", () => new Promise(() => {})), - ); - const user = userEvent.setup(); - renderDialog(); - await screen.findByText("hostname-1"); - await user.click(screen.getByRole("button", { name: /accept/i })); - await waitFor(() => - expect( - screen.getByRole("button", { name: /saving/i }), - ).toBeInTheDocument(), - ); - }); - it("Accept is disabled while mutation is in flight", async () => { server.use( http.post("*/api/billing/device-choice", () => new Promise(() => {})), @@ -613,27 +494,6 @@ describe("DeviceChooserDialog", () => { ); }); - it("error alert is rendered above the footer", async () => { - server.use( - http.post("*/api/billing/device-choice", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - const user = userEvent.setup(); - renderDialog(); - await screen.findByText("hostname-1"); - await user.click(screen.getByRole("button", { name: /accept/i })); - await waitFor(() => - expect(screen.getByRole("alert")).toBeInTheDocument(), - ); - const alert = screen.getByRole("alert"); - const cancel = screen.getByRole("button", { name: /cancel/i }); - expect( - alert.compareDocumentPosition(cancel) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - }); - it("clears the error when switching tabs", async () => { server.use( http.post("*/api/billing/device-choice", () => diff --git a/ui/apps/console/src/components/billing/__tests__/DeviceChooserTrigger.test.tsx b/ui/apps/console/src/components/billing/__tests__/DeviceChooserTrigger.test.tsx index 0c9936be777..7ffef726e3e 100644 --- a/ui/apps/console/src/components/billing/__tests__/DeviceChooserTrigger.test.tsx +++ b/ui/apps/console/src/components/billing/__tests__/DeviceChooserTrigger.test.tsx @@ -211,21 +211,7 @@ describe("DeviceChooserTrigger", () => { }); describe("dismissal", () => { - it("hides the dialog after dismissal", async () => { - const user = userEvent.setup(); - renderTrigger(); - expect( - await screen.findByTestId("device-chooser-dialog"), - ).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /dismiss/i })); - await waitFor(() => - expect( - screen.queryByTestId("device-chooser-dialog"), - ).not.toBeInTheDocument(), - ); - }); - - it("does not re-open the dialog after dismissal within the same mount", async () => { + it("hides the dialog after dismissal and does not re-open it in the same mount", async () => { const user = userEvent.setup(); const { rerender } = renderTrigger(); expect( diff --git a/ui/apps/console/src/components/commandPalette/__tests__/CommandPalette.test.tsx b/ui/apps/console/src/components/commandPalette/__tests__/CommandPalette.test.tsx index 425f12675eb..55e438fa453 100644 --- a/ui/apps/console/src/components/commandPalette/__tests__/CommandPalette.test.tsx +++ b/ui/apps/console/src/components/commandPalette/__tests__/CommandPalette.test.tsx @@ -308,13 +308,6 @@ describe("CommandPalette", () => { expect(useCommandPaletteStore.getState().open).toBe(false); }); - it("omits the Terminal Sessions section when none are open", async () => { - renderPalette(); - - expect(await screen.findByText("web-01")).toBeInTheDocument(); - expect(screen.queryByText("Terminal Sessions")).not.toBeInTheDocument(); - }); - it("lists recent devices between sessions and the full device list", async () => { setDevices([device, device2]); useTerminalStore.setState({ sessions: [session], reconnectTarget: null }); @@ -383,13 +376,6 @@ describe("CommandPalette", () => { expect(useCommandPaletteStore.getState().open).toBe(false); }); - it("omits the Recent section when there are no recent devices", async () => { - renderPalette(); - - expect(await screen.findByText("web-01")).toBeInTheDocument(); - expect(screen.queryByText("Recent")).not.toBeInTheDocument(); - }); - it("shakes the clicked recent row, not its device duplicate, when offline", async () => { const user = userEvent.setup(); setDevices([device, { ...device2, online: false }]); diff --git a/ui/apps/console/src/components/common/CreateNamespace.tsx b/ui/apps/console/src/components/common/CreateNamespace.tsx index 010f8116e2b..382feb3b8b2 100644 --- a/ui/apps/console/src/components/common/CreateNamespace.tsx +++ b/ui/apps/console/src/components/common/CreateNamespace.tsx @@ -1,11 +1,8 @@ -import { useState, useEffect, FormEvent } from "react"; -import { - useCreateNamespace, - useSwitchNamespace, -} from "@/hooks/useNamespaceMutations"; +import { useState, useEffect } from "react"; +import { useSwitchNamespace } from "@/hooks/useNamespaceMutations"; +import { useNamespaceCreateForm } from "@/hooks/useNamespaceCreateForm"; import { getNamespaces } from "@/client/api"; import { isEnterpriseOrCloud } from "@/env"; -import { isSdkError } from "@/api/errors"; import { CommandLineIcon, SparklesIcon, @@ -14,10 +11,7 @@ import { import AmbientBackground from "./AmbientBackground"; import CopyButton from "@/components/common/CopyButton"; import NamespaceNameField from "@/components/common/fields/NamespaceNameField"; -import { - NAMESPACE_NAME_MIN_LENGTH, - validateNamespaceName, -} from "@/utils/validation"; +import { NAMESPACE_NAME_MIN_LENGTH } from "@/utils/validation"; import { Button, GithubIcon, @@ -30,68 +24,28 @@ import { nullOnFailure } from "@/utils/failure"; * so the requirements are visible before the request rather than after it. */ export function NamespaceCreateForm() { - const [name, setName] = useState(""); - const [validationError, setValidationError] = useState(null); - const [submitError, setSubmitError] = useState(null); - const createNs = useCreateNamespace(); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - const err = validateNamespaceName(name); - if (err) { - setValidationError(err); - return; - } - setValidationError(null); - setSubmitError(null); - try { - await createNs.mutateAsync(name); - } catch (caught) { - if (isSdkError(caught)) { - if (caught.status === 409) { - setSubmitError("A namespace with this name already exists."); - } else if (caught.status === 403) { - setSubmitError( - "You have reached the namespace limit or do not have permission.", - ); - } else if (caught.status === 400) { - setSubmitError("The namespace name is invalid."); - } else { - setSubmitError("An unexpected error occurred. Please try again."); - } - } else { - setSubmitError("An unexpected error occurred. Please try again."); - } - } - }; - - const displayError = validationError ?? submitError ?? null; + const form = useNamespaceCreateForm(); return ( -
void handleSubmit(e)} className="w-full"> + void form.submit(e)} className="w-full">
{ - setName(v); - setValidationError(null); - setSubmitError(null); - createNs.reset(); - }} - error={displayError} + value={form.name} + onChange={form.changeName} + error={form.error} />
diff --git a/ui/apps/console/src/components/common/CreateNamespaceDialog.tsx b/ui/apps/console/src/components/common/CreateNamespaceDialog.tsx index ee03684c96b..87ff23b6af1 100644 --- a/ui/apps/console/src/components/common/CreateNamespaceDialog.tsx +++ b/ui/apps/console/src/components/common/CreateNamespaceDialog.tsx @@ -1,5 +1,4 @@ -import { useState, useId, type FormEvent } from "react"; -import { isSdkError } from "@/api/errors"; +import { useId } from "react"; import { XMarkIcon, BookOpenIcon, @@ -12,45 +11,12 @@ import { } from "@shellhub/design-system/primitives"; import BaseDialog from "./BaseDialog"; import NamespaceNameField from "./fields/NamespaceNameField"; -import { - NAMESPACE_NAME_MIN_LENGTH, - validateNamespaceName, -} from "@/utils/validation"; +import { NAMESPACE_NAME_MIN_LENGTH } from "@/utils/validation"; import { isEnterpriseOrCloud } from "@/env"; -import { useCreateNamespace } from "@/hooks/useNamespaceMutations"; +import { useNamespaceCreateForm } from "@/hooks/useNamespaceCreateForm"; const FORM_ID = "create-namespace-form"; -function CloudForm({ - inputId, - name, - setName, - displayError, - resetError, - onSubmit, -}: { - inputId: string; - name: string; - setName: (v: string) => void; - displayError: string | null; - resetError: () => void; - onSubmit: (e: FormEvent) => void; -}) { - return ( -
- { - setName(v); - resetError(); - }} - error={displayError} - /> - - ); -} - interface CreateNamespaceDialogProps { open: boolean; onClose: () => void; @@ -67,50 +33,7 @@ export default function CreateNamespaceDialog({ const titleId = `create-ns-title-${autoId}`; const inputId = `create-ns-input-${autoId}`; const isPremium = isEnterpriseOrCloud(); - - const [name, setName] = useState(""); - const [validationError, setValidationError] = useState(null); - const [submitError, setSubmitError] = useState(null); - const createNs = useCreateNamespace(); - - const displayError = validationError ?? submitError ?? null; - - const resetError = () => { - setValidationError(null); - setSubmitError(null); - createNs.reset(); - }; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - const err = validateNamespaceName(name); - if (err) { - setValidationError(err); - return; - } - setValidationError(null); - setSubmitError(null); - try { - await createNs.mutateAsync(name); - onClose(); - } catch (caught) { - if (isSdkError(caught)) { - if (caught.status === 409) { - setSubmitError("A namespace with this name already exists."); - } else if (caught.status === 403) { - setSubmitError( - "You have reached the namespace limit or do not have permission.", - ); - } else if (caught.status === 400) { - setSubmitError("The namespace name is invalid."); - } else { - setSubmitError("An unexpected error occurred. Please try again."); - } - } else { - setSubmitError("An unexpected error occurred. Please try again."); - } - } - }; + const form = useNamespaceCreateForm(onClose); if (!isPremium) return null; @@ -139,14 +62,14 @@ export default function CreateNamespaceDialog({ {/* Body */}
- void handleSubmit(e)} - /> +
void form.submit(e)}> + +
{/* Footer */} @@ -168,8 +91,8 @@ export default function CreateNamespaceDialog({ diff --git a/ui/apps/console/src/components/common/__tests__/ActionDialog.test.tsx b/ui/apps/console/src/components/common/__tests__/ActionDialog.test.tsx index 55788ffb485..f8d53589568 100644 --- a/ui/apps/console/src/components/common/__tests__/ActionDialog.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/ActionDialog.test.tsx @@ -73,128 +73,61 @@ beforeEach(() => { }); describe("ActionDialog", () => { - describe("confirm flow", () => { - it("renders the correct title and confirm label for accept", () => { - renderDialog({ action: acceptAction }); - expect(screen.getByText("Accept Device")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Accept" }), - ).toBeInTheDocument(); - }); - - it("renders the correct title for reject", () => { - renderDialog({ action: rejectAction }); - expect(screen.getByText("Reject Device")).toBeInTheDocument(); - }); - - it("renders the correct title for remove", () => { - renderDialog({ action: removeAction }); - expect(screen.getByText("Remove Device")).toBeInTheDocument(); - }); - - it("uses entityType in the title", () => { - renderDialog({ action: acceptAction, entityType: "container" }); - expect(screen.getByText("Accept Container")).toBeInTheDocument(); - }); - - it("calls onSuccess then onClose on successful confirm", async () => { - const props = renderDialog(); - await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => - expect(props.onSuccess).toHaveBeenCalledWith("accept"), - ); - expect(props.onClose).toHaveBeenCalled(); - }); - - it("does not call onSuccess on cancel", async () => { - const props = renderDialog(); - await userEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(props.onClose).toHaveBeenCalled(); - expect(props.onSuccess).not.toHaveBeenCalled(); - }); + describe("title and confirm label", () => { + it.each([ + [acceptAction, "device", "Accept Device", "Accept"], + [rejectAction, "device", "Reject Device", "Reject"], + [removeAction, "device", "Remove Device", "Remove"], + [acceptAction, "container", "Accept Container", "Accept"], + ] as const)( + "renders '%s' as '%s' with a '%s' button", + (action, entityType, title, confirmLabel) => { + renderDialog({ action, entityType }); + expect(screen.getByText(title)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: confirmLabel }), + ).toBeInTheDocument(); + }, + ); }); describe("error handling — accept", () => { - it("shows accept error message for non-cloud 402", async () => { + it.each([ + [402, /license/i], + [403, /permission/i], + [409, /already exists/i], + ])("shows the %i message when accept fails", async (status, message) => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "enterprise", }); - const runAction = vi.fn().mockRejectedValue(makeSdkError(402)); + const runAction = vi.fn().mockRejectedValue(makeSdkError(status)); renderDialog({ runAction }); await userEvent.click(screen.getByRole("button", { name: "Accept" })); await waitFor(() => - expect(screen.getByRole("alert")).toBeInTheDocument(), - ); - expect(screen.getByRole("alert")).toHaveTextContent(/license/i); - }); - - it("shows permission error for 403", async () => { - const runAction = vi.fn().mockRejectedValue(makeSdkError(403)); - renderDialog({ runAction }); - await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent(/permission/i), - ); - }); - - it("shows rename error for 409", async () => { - const runAction = vi.fn().mockRejectedValue(makeSdkError(409)); - renderDialog({ runAction }); - await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent(/already exists/i), - ); - }); - - it("does not call onClose on error", async () => { - const runAction = vi.fn().mockRejectedValue(makeSdkError(500)); - const props = renderDialog({ runAction }); - await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => - expect(screen.getByRole("alert")).toBeInTheDocument(), + expect(screen.getByRole("alert")).toHaveTextContent(message), ); - expect(props.onClose).not.toHaveBeenCalled(); }); }); describe("error handling — reject/remove", () => { - it("shows generic error for reject failure", async () => { - const runAction = vi.fn().mockRejectedValue(makeSdkError(500)); - renderDialog({ action: rejectAction, runAction }); - await userEvent.click(screen.getByRole("button", { name: "Reject" })); - await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent( - /failed to reject device/i, - ), - ); - }); - - it("shows generic error for remove failure", async () => { - const runAction = vi.fn().mockRejectedValue(makeSdkError(500)); - renderDialog({ action: removeAction, runAction }); - await userEvent.click(screen.getByRole("button", { name: "Remove" })); - await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent( - /failed to remove device/i, - ), - ); - }); - - it("interpolates container in generic error", async () => { - const runAction = vi.fn().mockRejectedValue(makeSdkError(500)); - renderDialog({ - action: removeAction, - entityType: "container", - runAction, - }); - await userEvent.click(screen.getByRole("button", { name: "Remove" })); - await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent( - /failed to remove container/i, - ), - ); - }); + it.each([ + [rejectAction, "device", "Reject", /failed to reject device/i], + [removeAction, "device", "Remove", /failed to remove device/i], + [removeAction, "container", "Remove", /failed to remove container/i], + ] as const)( + "shows the generic error naming the %s and %s", + async (action, entityType, confirmLabel, message) => { + const runAction = vi.fn().mockRejectedValue(makeSdkError(500)); + renderDialog({ action, entityType, runAction }); + await userEvent.click( + screen.getByRole("button", { name: confirmLabel }), + ); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent(message), + ); + }, + ); }); describe("billing dialog — cloud 402 on accept", () => { @@ -202,21 +135,27 @@ describe("ActionDialog", () => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); }); - it("shows billing ConfirmDialog for owners", async () => { - useAuthStore.setState({ role: "owner" }); - const runAction = vi.fn().mockRejectedValue(makeSdkError(402)); - renderDialog({ runAction }); - await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => - expect(screen.getByText("Device limit reached")).toBeInTheDocument(), - ); - expect( - screen.getByRole("button", { name: "Go to billing" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Not now" }), - ).toBeInTheDocument(); - }); + it.each([ + ["device", "Device limit reached"], + ["container", "Container limit reached"], + ] as const)( + "shows the %s billing ConfirmDialog for owners", + async (entityType, title) => { + useAuthStore.setState({ role: "owner" }); + const runAction = vi.fn().mockRejectedValue(makeSdkError(402)); + renderDialog({ entityType, runAction }); + await userEvent.click(screen.getByRole("button", { name: "Accept" })); + await waitFor(() => + expect(screen.getByText(title)).toBeInTheDocument(), + ); + expect( + screen.getByRole("button", { name: "Go to billing" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Not now" }), + ).toBeInTheDocument(); + }, + ); it("shows BaseDialog with single Close button for non-owners", async () => { useAuthStore.setState({ role: "observer" }); @@ -237,23 +176,13 @@ describe("ActionDialog", () => { it("navigates to billing on owner confirm", async () => { useAuthStore.setState({ role: "owner" }); const runAction = vi.fn().mockRejectedValue(makeSdkError(402)); - const props = renderDialog({ runAction }); + renderDialog({ runAction }); await userEvent.click(screen.getByRole("button", { name: "Accept" })); await waitFor(() => screen.getByText("Device limit reached")); await userEvent.click( screen.getByRole("button", { name: "Go to billing" }), ); expect(mockNavigate).toHaveBeenCalledWith("/settings#billing"); - expect(props.onClose).toHaveBeenCalled(); - }); - - it("uses container label in billing title", async () => { - const runAction = vi.fn().mockRejectedValue(makeSdkError(402)); - renderDialog({ entityType: "container", runAction }); - await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => - expect(screen.getByText("Container limit reached")).toBeInTheDocument(), - ); }); it("does not trigger billing dialog for reject 402", async () => { diff --git a/ui/apps/console/src/components/common/__tests__/ActiveBadge.test.tsx b/ui/apps/console/src/components/common/__tests__/ActiveBadge.test.tsx index fd6a7f23585..f7db425cbde 100644 --- a/ui/apps/console/src/components/common/__tests__/ActiveBadge.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/ActiveBadge.test.tsx @@ -4,31 +4,13 @@ import { render, screen } from "@testing-library/react"; import ActiveBadge from "../ActiveBadge"; describe("ActiveBadge", () => { - describe("active=true", () => { - it("renders the text 'Active'", () => { - render(); - expect(screen.getByText("Active")).toBeInTheDocument(); - }); - - it("chip carries green colour class (text-accent-green)", () => { - render(); - const chip = screen.getByText("Active").closest("span"); - expect(chip).not.toBeNull(); - expect(chip!.className).toContain("text-accent-green"); - }); - }); - - describe("active=false", () => { - it("renders the text 'Inactive'", () => { - render(); - expect(screen.getByText("Inactive")).toBeInTheDocument(); - }); - - it("chip carries yellow colour class (text-accent-yellow)", () => { - render(); - const chip = screen.getByText("Inactive").closest("span"); - expect(chip).not.toBeNull(); - expect(chip!.className).toContain("text-accent-yellow"); - }); + it.each([ + [true, "Active", "text-accent-green"], + [false, "Inactive", "text-accent-yellow"], + ])("active=%s reads '%s' in %s", (active, label, colour) => { + render(); + const chip = screen.getByText(label).closest("span"); + expect(chip).not.toBeNull(); + expect(chip!.className).toContain(colour); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/BaseDialog.test.tsx b/ui/apps/console/src/components/common/__tests__/BaseDialog.test.tsx index 206d5cb5637..ef4f00f0ed4 100644 --- a/ui/apps/console/src/components/common/__tests__/BaseDialog.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/BaseDialog.test.tsx @@ -48,11 +48,6 @@ describe("BaseDialog", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - it("renders a element when open=true", () => { - renderDialog(true); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); - it("renders children inside the dialog", () => { renderDialog(true); expect(screen.getByText("dialog content")).toBeInTheDocument(); @@ -103,16 +98,6 @@ describe("BaseDialog", () => { expect(onClose).not.toHaveBeenCalled(); }); - - it("calls onClose when canClose returns true", () => { - const { onClose } = renderDialog(true, { - canClose: () => true, - }); - - fireEvent(screen.getByRole("dialog"), new Event("cancel")); - - expect(onClose).toHaveBeenCalledOnce(); - }); }); describe("backdrop click", () => { @@ -138,76 +123,17 @@ describe("BaseDialog", () => { }); describe("ARIA attributes", () => { - it("passes aria-labelledby to the dialog element", () => { - renderDialog(true, { ariaLabelledBy: "my-title" }); - expect(screen.getByRole("dialog")).toHaveAttribute( - "aria-labelledby", - "my-title", - ); - }); - - it("passes aria-describedby to the dialog element", () => { - renderDialog(true, { ariaDescribedBy: "my-description" }); - expect(screen.getByRole("dialog")).toHaveAttribute( - "aria-describedby", - "my-description", - ); - }); - - it("passes aria-label to the dialog element", () => { - renderDialog(true, { ariaLabel: "My accessible dialog" }); - expect(screen.getByRole("dialog")).toHaveAttribute( - "aria-label", - "My accessible dialog", - ); - }); - }); - - describe("data-custom-backdrop", () => { - it("has data-custom-backdrop attribute for native ::backdrop CSS styling", () => { - renderDialog(true); - expect(screen.getByRole("dialog")).toHaveAttribute( - "data-custom-backdrop", - ); - }); - }); - - describe("size prop", () => { - it("applies sm:max-w-sm by default (size omitted)", () => { - renderDialog(true); - expect(screen.getByRole("dialog").className).toContain("sm:max-w-sm"); - }); - - it("applies sm:max-w-sm when size='sm'", () => { - renderDialog(true, { size: "sm" }); - expect(screen.getByRole("dialog").className).toContain("sm:max-w-sm"); - }); - - it("applies sm:max-w-md when size='md'", () => { - renderDialog(true, { size: "md" }); - expect(screen.getByRole("dialog").className).toContain("sm:max-w-md"); - }); - - it("applies sm:max-w-lg when size='lg'", () => { - renderDialog(true, { size: "lg" }); - expect(screen.getByRole("dialog").className).toContain("sm:max-w-lg"); - }); - - it("applies sm:max-w-xl when size='xl'", () => { - renderDialog(true, { size: "xl" }); - expect(screen.getByRole("dialog").className).toContain("sm:max-w-xl"); - }); - - it("does not apply any max-w class when size='full'", () => { - renderDialog(true, { size: "full" }); - expect(screen.getByRole("dialog").className).not.toContain("sm:max-w"); - }); - }); + it("forwards the aria-* props to the dialog element", () => { + renderDialog(true, { + ariaLabelledBy: "my-title", + ariaDescribedBy: "my-description", + ariaLabel: "My accessible dialog", + }); - describe("className prop", () => { - it("appends extra classes to the dialog panel", () => { - renderDialog(true, { className: "sm:max-h-[85vh]" }); - expect(screen.getByRole("dialog").className).toContain("sm:max-h-[85vh]"); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-labelledby", "my-title"); + expect(dialog).toHaveAttribute("aria-describedby", "my-description"); + expect(dialog).toHaveAttribute("aria-label", "My accessible dialog"); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/Breadcrumb.test.tsx b/ui/apps/console/src/components/common/__tests__/Breadcrumb.test.tsx index 56446193990..a28eb52ac54 100644 --- a/ui/apps/console/src/components/common/__tests__/Breadcrumb.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/Breadcrumb.test.tsx @@ -8,17 +8,6 @@ function renderBreadcrumb(ui: React.ReactNode) { } describe("Breadcrumb", () => { - it("renders a navigation landmark named 'Breadcrumb'", () => { - renderBreadcrumb( - , - ); - expect( - screen.getByRole("navigation", { name: "Breadcrumb" }), - ).toBeInTheDocument(); - }); - it("renders one list item per breadcrumb entry inside an ordered list", () => { renderBreadcrumb( { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - it("renders the dialog when open=true", () => { - renderDialog(true); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); - - it("renders the title", () => { - renderDialog(true, { title: "Confirm deletion" }); + it("renders the title, the description and any children", () => { + renderDialog(true, { + title: "Confirm deletion", + description: "This cannot be undone.", + children: extra, + }); expect( screen.getByRole("heading", { name: "Confirm deletion" }), ).toBeInTheDocument(); - }); - - it("renders the description", () => { - renderDialog(true, { description: "This cannot be undone." }); expect(screen.getByText("This cannot be undone.")).toBeInTheDocument(); - }); - - it("renders children between description and buttons", () => { - renderDialog(true, { - children: extra, - }); expect(screen.getByTestId("extra-content")).toBeInTheDocument(); }); }); @@ -94,39 +83,14 @@ describe("ConfirmDialog", () => { const titleEl = document.getElementById(labelId!); expect(titleEl).toHaveTextContent("My title"); }); - - it("title element has an id that matches dialog's aria-labelledby", () => { - renderDialog(true); - const dialog = screen.getByRole("dialog"); - const labelId = dialog.getAttribute("aria-labelledby")!; - expect(screen.getByRole("heading")).toHaveAttribute("id", labelId); - }); }); describe("buttons", () => { - it("renders the confirm button with default label 'Confirm'", () => { - renderDialog(true); - expect( - screen.getByRole("button", { name: "Confirm" }), - ).toBeInTheDocument(); - }); - - it("renders the cancel button with default label 'Cancel'", () => { - renderDialog(true); - expect( - screen.getByRole("button", { name: "Cancel" }), - ).toBeInTheDocument(); - }); - - it("renders a custom confirmLabel", () => { - renderDialog(true, { confirmLabel: "Delete" }); + it("renders custom confirm and cancel labels", () => { + renderDialog(true, { confirmLabel: "Delete", cancelLabel: "Go back" }); expect( screen.getByRole("button", { name: "Delete" }), ).toBeInTheDocument(); - }); - - it("renders a custom cancelLabel", () => { - renderDialog(true, { cancelLabel: "Go back" }); expect( screen.getByRole("button", { name: "Go back" }), ).toBeInTheDocument(); @@ -162,7 +126,7 @@ describe("ConfirmDialog", () => { expect(onConfirm).toHaveBeenCalledOnce(); }); - it("shows a spinner and disables the confirm button while onConfirm is pending", async () => { + it("disables the confirm button while onConfirm is pending", async () => { const user = userEvent.setup(); let resolve!: () => void; const onConfirm = vi.fn( @@ -175,9 +139,7 @@ describe("ConfirmDialog", () => { await user.click(screen.getByRole("button", { name: "Confirm" })); - const confirmBtn = screen.getByRole("button", { name: "Confirm" }); - expect(confirmBtn).toBeDisabled(); - expect(confirmBtn.querySelector(".animate-spin")).not.toBeNull(); + expect(screen.getByRole("button", { name: "Confirm" })).toBeDisabled(); act(() => { resolve(); @@ -217,56 +179,4 @@ describe("ConfirmDialog", () => { expect(screen.getByRole("button", { name: "Confirm" })).toBeDisabled(); }); }); - - describe("variant", () => { - it("applies danger variant classes by default", () => { - renderDialog(true); - const btn = screen.getByRole("button", { name: "Confirm" }); - expect(btn.className).toContain("bg-accent-red"); - }); - - it("applies primary variant classes when variant='primary'", () => { - renderDialog(true, { variant: "primary" }); - const btn = screen.getByRole("button", { name: "Confirm" }); - expect(btn.className).toContain("bg-primary"); - expect(btn.className).not.toContain("bg-accent-red"); - }); - - it("applies success variant classes when variant='success'", () => { - renderDialog(true, { variant: "success" }); - const btn = screen.getByRole("button", { name: "Confirm" }); - expect(btn.className).toContain("bg-accent-green"); - expect(btn.className).not.toContain("bg-accent-red"); - }); - - it("applies warning variant classes when variant='warning'", () => { - renderDialog(true, { variant: "warning" }); - const btn = screen.getByRole("button", { name: "Confirm" }); - expect(btn.className).toContain("bg-accent-yellow"); - expect(btn.className).not.toContain("bg-accent-red"); - }); - - it("variant=warning loading renders Spinner with onBackground tone (border-background/30)", async () => { - const user = userEvent.setup(); - let resolve!: () => void; - const onConfirm = vi.fn( - () => - new Promise((res) => { - resolve = res; - }), - ); - renderDialog(true, { variant: "warning", onConfirm }); - - await user.click(screen.getByRole("button", { name: "Confirm" })); - - const confirmBtn = screen.getByRole("button", { name: "Confirm" }); - const spinner = confirmBtn.querySelector(".animate-spin"); - expect(spinner).not.toBeNull(); - expect(spinner!.className).toContain("border-background/30"); - - act(() => { - resolve(); - }); - }); - }); }); diff --git a/ui/apps/console/src/components/common/__tests__/CreateNamespace.test.tsx b/ui/apps/console/src/components/common/__tests__/CreateNamespace.test.tsx index 0bf93d92090..4606e708fe9 100644 --- a/ui/apps/console/src/components/common/__tests__/CreateNamespace.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/CreateNamespace.test.tsx @@ -4,7 +4,6 @@ import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockNamespace, mockUserAuth } from "@/tests/factories"; import { getConfig, defaultConfig } from "@/env"; import CreateNamespace from "../CreateNamespace"; @@ -15,101 +14,20 @@ beforeEach(() => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); server.use( http.get("*/api/namespaces", () => jsonWithTotal([])), - http.post("*/api/namespaces", () => - HttpResponse.json(mockNamespace({ name: "my-ns" })), - ), - http.get("*/api/auth/token/:tenant", () => - HttpResponse.json(mockUserAuth({ token: "jwt-token" })), - ), + http.post("*/api/namespaces", () => HttpResponse.json({}, { status: 409 })), ); }); -function renderComponent() { - return render(, { wrapper: createTestWrapper() }); -} - -describe("CreateNamespace — CloudForm", () => { - it("shows 'A namespace with this name already exists.' on 409", async () => { - server.use( - http.post("*/api/namespaces", () => - HttpResponse.json({}, { status: 409 }), - ), - ); - const user = userEvent.setup(); - renderComponent(); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); - expect( - await screen.findByText("A namespace with this name already exists."), - ).toBeInTheDocument(); - }); - - it("shows the limit/permission message on 403", async () => { - server.use( - http.post("*/api/namespaces", () => - HttpResponse.json({}, { status: 403 }), - ), - ); - const user = userEvent.setup(); - renderComponent(); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); - expect( - await screen.findByText( - "You have reached the namespace limit or do not have permission.", - ), - ).toBeInTheDocument(); - }); - - it("shows the invalid-name message on 400", async () => { - server.use( - http.post("*/api/namespaces", () => - HttpResponse.json({}, { status: 400 }), - ), - ); +describe("CreateNamespace — NamespaceCreateForm", () => { + it("surfaces a failed creation on the name field", async () => { const user = userEvent.setup(); - renderComponent(); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); - expect( - await screen.findByText("The namespace name is invalid."), - ).toBeInTheDocument(); - }); + render(, { wrapper: createTestWrapper() }); - it("shows the generic fallback message on 500", async () => { - server.use( - http.post("*/api/namespaces", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - const user = userEvent.setup(); - renderComponent(); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); await user.click(screen.getByRole("button", { name: "Create" })); - expect( - await screen.findByText( - "An unexpected error occurred. Please try again.", - ), - ).toBeInTheDocument(); - }); - it("clears the error text when the user types after a failed submission", async () => { - server.use( - http.post("*/api/namespaces", () => - HttpResponse.json({}, { status: 409 }), - ), - ); - const user = userEvent.setup(); - renderComponent(); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); expect( await screen.findByText("A namespace with this name already exists."), ).toBeInTheDocument(); - - await user.type(screen.getByPlaceholderText("my-namespace"), "x"); - expect( - screen.queryByText("A namespace with this name already exists."), - ).not.toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/CreateNamespaceDialog.test.tsx b/ui/apps/console/src/components/common/__tests__/CreateNamespaceDialog.test.tsx index f78b4f59162..1681d4128df 100644 --- a/ui/apps/console/src/components/common/__tests__/CreateNamespaceDialog.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/CreateNamespaceDialog.test.tsx @@ -49,25 +49,29 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "enterprise" }); }); - describe("when open=false", () => { - it("renders nothing", () => { - renderDialog(false); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); + it("renders nothing when open=false", () => { + renderDialog(false); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - describe("when open=true", () => { - it("renders the dialog", () => { - renderDialog(true); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); + it("labels the dialog with its heading", () => { + renderDialog(true); + const labelId = screen + .getByRole("dialog") + .getAttribute("aria-labelledby"); + expect( + screen.getByRole("heading", { name: "Create a Namespace" }), + ).toHaveAttribute("id", labelId); + }); - it("displays the heading 'Create a Namespace'", () => { - renderDialog(true); - expect( - screen.getByRole("heading", { name: "Create a Namespace" }), - ).toBeInTheDocument(); - }); + it("links to the Administration Guide in a new tab", () => { + renderDialog(true); + const link = screen.getByRole("link", { name: /administration guide/i }); + expect(link).toHaveAttribute( + "href", + "https://docs.shellhub.io/self-hosted/administration", + ); + expect(link).toHaveAttribute("target", "_blank"); }); describe("closing the dialog", () => { @@ -92,50 +96,6 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { }); }); - describe("aria attributes", () => { - it("dialog aria-labelledby points to the heading element", () => { - renderDialog(true); - const dialog = screen.getByRole("dialog"); - const labelId = dialog.getAttribute("aria-labelledby"); - expect(labelId).toBeTruthy(); - expect(document.getElementById(labelId!)).toHaveTextContent( - "Create a Namespace", - ); - }); - - it("heading id matches dialog's aria-labelledby", () => { - renderDialog(true); - const dialog = screen.getByRole("dialog"); - const labelId = dialog.getAttribute("aria-labelledby")!; - expect( - screen.getByRole("heading", { name: "Create a Namespace" }), - ).toHaveAttribute("id", labelId); - }); - }); - - describe("documentation link", () => { - it("renders a link to the Administration Guide", () => { - renderDialog(true); - const link = screen.getByRole("link", { name: /administration guide/i }); - expect(link).toHaveAttribute( - "href", - "https://docs.shellhub.io/self-hosted/administration", - ); - }); - - it("link opens in a new tab", () => { - renderDialog(true); - const link = screen.getByRole("link", { name: /administration guide/i }); - expect(link).toHaveAttribute("target", "_blank"); - }); - }); - - it("renders the name input and Create button", () => { - renderDialog(true); - expect(screen.getByPlaceholderText("my-namespace")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Create" })).toBeInTheDocument(); - }); - it("Create button is disabled when name is fewer than 3 characters", () => { renderDialog(true); expect(screen.getByRole("button", { name: "Create" })).toBeDisabled(); @@ -152,40 +112,7 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { }); }); - it("shows a validation error when name is too short on submit", async () => { - renderDialog(true); - fireEvent.change(screen.getByPlaceholderText("my-namespace"), { - target: { value: "ab" }, - }); - fireEvent.submit( - screen.getByPlaceholderText("my-namespace").closest("form")!, - ); - await waitFor(() => - expect( - screen.getByText("Name must be at least 3 characters"), - ).toBeInTheDocument(), - ); - }); - - it("shows a validation error for names with invalid characters", async () => { - const user = userEvent.setup(); - renderDialog(true); - const input = screen.getByPlaceholderText("my-namespace"); - await user.type(input, "-badname"); - await user.click(screen.getByRole("button", { name: "Create" })); - expect( - screen.getByText(/Only lowercase letters, numbers, and hyphens/i), - ).toBeInTheDocument(); - }); - - it("forces lowercase on input", async () => { - const user = userEvent.setup(); - renderDialog(true); - await user.type(screen.getByPlaceholderText("my-namespace"), "MyNS"); - expect(screen.getByPlaceholderText("my-namespace")).toHaveValue("myns"); - }); - - it("shows 'A namespace with this name already exists.' on 409 and does NOT call onClose", async () => { + it("keeps the dialog open and shows the error when creation fails", async () => { server.use( http.post("*/api/namespaces", () => HttpResponse.json({}, { status: 409 }), @@ -193,81 +120,14 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { ); const user = userEvent.setup(); const { onClose } = renderDialog(true); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); - expect( - await screen.findByText("A namespace with this name already exists."), - ).toBeInTheDocument(); - expect(onClose).not.toHaveBeenCalled(); - }); - it("shows the limit/permission message on 403", async () => { - server.use( - http.post("*/api/namespaces", () => - HttpResponse.json({}, { status: 403 }), - ), - ); - const user = userEvent.setup(); - renderDialog(true); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); - expect( - await screen.findByText( - "You have reached the namespace limit or do not have permission.", - ), - ).toBeInTheDocument(); - }); - - it("shows the invalid-name message on 400", async () => { - server.use( - http.post("*/api/namespaces", () => - HttpResponse.json({}, { status: 400 }), - ), - ); - const user = userEvent.setup(); - renderDialog(true); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); - expect( - await screen.findByText("The namespace name is invalid."), - ).toBeInTheDocument(); - }); - - it("shows the generic fallback message on 500", async () => { - server.use( - http.post("*/api/namespaces", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - const user = userEvent.setup(); - renderDialog(true); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); await user.click(screen.getByRole("button", { name: "Create" })); - expect( - await screen.findByText( - "An unexpected error occurred. Please try again.", - ), - ).toBeInTheDocument(); - }); - it("clears the error text when the user types after a failed submission", async () => { - server.use( - http.post("*/api/namespaces", () => - HttpResponse.json({}, { status: 409 }), - ), - ); - const user = userEvent.setup(); - renderDialog(true); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); expect( await screen.findByText("A namespace with this name already exists."), ).toBeInTheDocument(); - - await user.type(screen.getByPlaceholderText("my-namespace"), "x"); - expect( - screen.queryByText("A namespace with this name already exists."), - ).not.toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); }); it("calls onClose after successful creation", async () => { diff --git a/ui/apps/console/src/components/common/__tests__/DataTable.test.tsx b/ui/apps/console/src/components/common/__tests__/DataTable.test.tsx index 81a84a0c6ba..72f3f206122 100644 --- a/ui/apps/console/src/components/common/__tests__/DataTable.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/DataTable.test.tsx @@ -10,6 +10,11 @@ const COLUMNS: Column[] = [ { key: "name", header: "Name", render: (row) => row.name }, ]; +const SORTABLE_COLUMNS: Column[] = [ + { key: "id", header: "ID", sortable: true, render: (row) => row.id }, + { key: "name", header: "Name", sortable: false, render: (row) => row.name }, +]; + const ROWS: Row[] = [ { id: "1", name: "Alice" }, { id: "2", name: "Bob" }, @@ -49,104 +54,49 @@ function renderTable(options: RenderOptions = {}) { ); } +function dataRows() { + return screen.getAllByRole("row").slice(1); +} + beforeEach(() => { vi.clearAllMocks(); }); describe("DataTable", () => { - describe("column headers", () => { - it("renders a header cell for each column", () => { - renderTable(); - expect( - screen.getByRole("columnheader", { name: "ID" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("columnheader", { name: "Name" }), - ).toBeInTheDocument(); - }); - - it("renders the correct number of header cells", () => { - renderTable(); - expect(screen.getAllByRole("columnheader")).toHaveLength(COLUMNS.length); - }); - }); - - describe("data rows", () => { - it("renders a row for each data item", () => { - renderTable(); - expect(screen.getAllByRole("row")).toHaveLength(ROWS.length + 1); - }); - - it("renders cell content via column render functions", () => { - renderTable(); - expect(screen.getByText("Alice")).toBeInTheDocument(); - expect(screen.getByText("Bob")).toBeInTheDocument(); - expect(screen.getByText("1")).toBeInTheDocument(); - expect(screen.getByText("2")).toBeInTheDocument(); - }); - - it("renders custom ReactNode output from render functions", () => { - const columns: Column[] = [ - { - key: "name", - header: "Name", - render: (row) => {row.name}, - }, - ]; - renderTable({ columns }); - expect(screen.getAllByTestId("bold-name")).toHaveLength(ROWS.length); - }); + it("renders a header per column and a row per data item", () => { + renderTable(); + expect(screen.getByRole("columnheader", { name: "ID" })).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Name" }), + ).toBeInTheDocument(); + expect(screen.getAllByRole("row")).toHaveLength(ROWS.length + 1); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); }); describe("loading state", () => { - it("shows spinner with role='status' when isLoading and data is empty", () => { - renderTable({ isLoading: true, data: [] }); + it.each([ + [undefined, "Loading..."], + ["Fetching data…", "Fetching data…"], + ])("loadingMessage=%s shows '%s'", (loadingMessage, expected) => { + renderTable({ isLoading: true, data: [], loadingMessage }); expect(screen.getByRole("status")).toBeInTheDocument(); - }); - - it("shows the default loading message when loadingMessage is omitted", () => { - renderTable({ isLoading: true, data: [] }); - expect(screen.getByText("Loading...")).toBeInTheDocument(); - }); - - it("shows a custom loadingMessage when provided", () => { - renderTable({ - isLoading: true, - data: [], - loadingMessage: "Fetching data\u2026", - }); - expect(screen.getByText("Fetching data\u2026")).toBeInTheDocument(); + expect(screen.getByText(expected)).toBeInTheDocument(); }); it("does NOT show spinner when isLoading is true but data is non-empty", () => { renderTable({ isLoading: true, data: ROWS }); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); - - it("uses colSpan equal to the number of columns on the loading cell", () => { - renderTable({ isLoading: true, data: [] }); - const td = screen.getByRole("status").closest("td"); - expect(td).toHaveAttribute("colspan", String(COLUMNS.length)); - }); }); describe("empty state", () => { - it("shows 'No data available' by default when data is empty and not loading", () => { - renderTable({ data: [] }); - expect(screen.getByText("No data available")).toBeInTheDocument(); - }); - - it("shows a custom emptyMessage when provided", () => { - renderTable({ data: [], emptyMessage: "Nothing here yet." }); - expect(screen.getByText("Nothing here yet.")).toBeInTheDocument(); - }); - - it("shows a custom emptyState ReactNode when provided", () => { - renderTable({ - data: [], - emptyState: No rows found, - }); - expect(screen.getByTestId("custom-empty")).toBeInTheDocument(); + it.each([ + [undefined, "No data available"], + ["Nothing here yet.", "Nothing here yet."], + ])("emptyMessage=%s shows '%s'", (emptyMessage, expected) => { + renderTable({ data: [], emptyMessage }); + expect(screen.getByText(expected)).toBeInTheDocument(); }); it("uses emptyState over emptyMessage when both are provided", () => { @@ -161,15 +111,9 @@ describe("DataTable", () => { it("uses colSpan equal to the number of columns on the empty cell", () => { renderTable({ data: [] }); - const emptyText = screen.getByText("No data available"); - const td = emptyText.closest("td"); + const td = screen.getByText("No data available").closest("td"); expect(td).toHaveAttribute("colspan", String(COLUMNS.length)); }); - - it("does not render data rows when data is empty", () => { - renderTable({ data: [] }); - expect(screen.getAllByRole("row")).toHaveLength(2); - }); }); describe("row click", () => { @@ -180,214 +124,82 @@ describe("DataTable", () => { expect(onRowClick).toHaveBeenCalledWith(ROWS[0]); }); - it("calls onRowClick for second row with correct data", async () => { - const onRowClick = vi.fn(); - renderTable({ onRowClick }); - await userEvent.click(screen.getByText("Bob").closest("tr")!); - expect(onRowClick).toHaveBeenCalledWith(ROWS[1]); - }); - - it("adds cursor-pointer class to rows when onRowClick is provided", () => { - const onRowClick = vi.fn(); - renderTable({ onRowClick }); - const dataRows = screen.getAllByRole("row").slice(1); - dataRows.forEach((row) => { - expect(row.className).toContain("cursor-pointer"); - }); - }); - - it("sets tabIndex=0 on rows when onRowClick is provided", () => { - renderTable({ onRowClick: vi.fn() }); - const dataRows = screen.getAllByRole("row").slice(1); - dataRows.forEach((row) => { - expect(row).toHaveAttribute("tabindex", "0"); - }); - }); - }); - - describe("no row click handler", () => { - it("does not add cursor-pointer class when onRowClick is undefined", () => { - renderTable(); - const dataRows = screen.getAllByRole("row").slice(1); - dataRows.forEach((row) => { - expect(row.className).not.toContain("cursor-pointer"); - }); - }); - - it("does not set tabIndex when onRowClick is undefined", () => { - renderTable(); - const dataRows = screen.getAllByRole("row").slice(1); - dataRows.forEach((row) => { - expect(row).not.toHaveAttribute("tabindex"); - }); - }); - }); - - describe("keyboard navigation on clickable rows", () => { - it("calls onRowClick when Enter is pressed on a row", () => { - const onRowClick = vi.fn(); - renderTable({ onRowClick }); - const firstDataRow = screen.getAllByRole("row")[1]; - fireEvent.keyDown(firstDataRow, { key: "Enter" }); - expect(onRowClick).toHaveBeenCalledWith(ROWS[0]); - }); - - it("calls onRowClick when Space is pressed on a row", () => { - const onRowClick = vi.fn(); - renderTable({ onRowClick }); - const firstDataRow = screen.getAllByRole("row")[1]; - fireEvent.keyDown(firstDataRow, { key: " " }); - expect(onRowClick).toHaveBeenCalledWith(ROWS[0]); - }); + it.each([ + ["provided", vi.fn(), "0"], + ["undefined", undefined, null], + ])( + "onRowClick %s sets tabindex to %s", + (_label, onRowClick, expected) => { + renderTable({ onRowClick }); + dataRows().forEach((row) => { + if (expected === null) expect(row).not.toHaveAttribute("tabindex"); + else expect(row).toHaveAttribute("tabindex", expected); + }); + }, + ); - it("does NOT call onRowClick for other keys", () => { + it.each([ + ["Enter", true], + [" ", true], + ["Tab", false], + ] as const)("pressing %s on a row activates it: %s", (key, activates) => { const onRowClick = vi.fn(); renderTable({ onRowClick }); - const firstDataRow = screen.getAllByRole("row")[1]; - fireEvent.keyDown(firstDataRow, { key: "Tab" }); - expect(onRowClick).not.toHaveBeenCalled(); - }); - - it("does not attach a keyDown handler when onRowClick is undefined", () => { - renderTable(); - const firstDataRow = screen.getAllByRole("row")[1]; - expect(() => - fireEvent.keyDown(firstDataRow, { key: "Enter" }), - ).not.toThrow(); + fireEvent.keyDown(dataRows()[0], { key }); + if (activates) expect(onRowClick).toHaveBeenCalledWith(ROWS[0]); + else expect(onRowClick).not.toHaveBeenCalled(); }); }); - describe("focus-visible outline classes", () => { - it("applies focus-visible outline classes to clickable rows", () => { - renderTable({ onRowClick: vi.fn() }); - const dataRows = screen.getAllByRole("row").slice(1); - dataRows.forEach((row) => { - expect(row.className).toContain("focus-visible:outline"); - expect(row.className).toContain("focus-visible:outline-primary/50"); - }); - }); - - it("does not apply focus-visible outline classes when not clickable", () => { - renderTable(); - const dataRows = screen.getAllByRole("row").slice(1); - dataRows.forEach((row) => { - expect(row.className).not.toContain("focus-visible:outline"); - }); - }); - }); - - describe("rowClassName", () => { - it("applies the class returned by rowClassName to each row", () => { - renderTable({ - rowClassName: (row) => (row.id === "1" ? "row-highlight" : undefined), - }); - const dataRows = screen.getAllByRole("row").slice(1); - expect(dataRows[0].className).toContain("row-highlight"); - expect(dataRows[1].className).not.toContain("row-highlight"); - }); - - it("does not break when rowClassName returns undefined for all rows", () => { - expect(() => - renderTable({ rowClassName: () => undefined }), - ).not.toThrow(); - }); - - it("combines rowClassName output with base row classes", () => { - renderTable({ rowClassName: () => "extra-class" }); - const dataRows = screen.getAllByRole("row").slice(1); - dataRows.forEach((row) => { - expect(row.className).toContain("extra-class"); - expect(row.className).toContain("transition-colors"); - }); + it("applies the class returned by rowClassName to each row", () => { + renderTable({ + rowClassName: (row) => (row.id === "1" ? "row-highlight" : undefined), }); + expect(dataRows()[0].className).toContain("row-highlight"); + expect(dataRows()[1].className).not.toContain("row-highlight"); }); describe("pagination", () => { - it("does not render pagination when page, totalPages and onPageChange are all undefined", () => { - renderTable(); + it.each([ + ["page, totalPages and onPageChange are all undefined", {}], + ["totalPages <= 1", { page: 1, totalPages: 1, onPageChange: vi.fn() }], + ])("does not render pagination when %s", (_label, options) => { + renderTable(options); expect(screen.queryByText("Prev")).not.toBeInTheDocument(); expect(screen.queryByText("Next")).not.toBeInTheDocument(); }); - it("does not render pagination when only page is provided", () => { - renderTable({ page: 1, totalPages: 3 }); - expect(screen.queryByText("Prev")).not.toBeInTheDocument(); - }); - - it("does not render pagination when only onPageChange is provided", () => { - renderTable({ onPageChange: vi.fn() }); - expect(screen.queryByText("Prev")).not.toBeInTheDocument(); - }); - - it("does not render pagination when totalPages is missing", () => { - renderTable({ page: 1, onPageChange: vi.fn() }); - expect(screen.queryByText("Prev")).not.toBeInTheDocument(); - }); - - it("does not render pagination when totalPages <= 1", () => { - renderTable({ page: 1, totalPages: 1, onPageChange: vi.fn() }); - expect(screen.queryByText("Prev")).not.toBeInTheDocument(); - }); - - it("renders Prev and Next buttons when there are multiple pages", () => { - renderTable({ - page: 1, - totalPages: 3, - totalCount: 25, - onPageChange: vi.fn(), - }); - expect(screen.getByText("Prev")).toBeInTheDocument(); - expect(screen.getByText("Next")).toBeInTheDocument(); - }); - - it("calls onPageChange with next page number when Next is clicked", async () => { - const onPageChange = vi.fn(); - renderTable({ page: 1, totalPages: 3, totalCount: 25, onPageChange }); - await userEvent.click(screen.getByText("Next")); - expect(onPageChange).toHaveBeenCalledWith(2); - }); - - it("calls onPageChange with previous page number when Prev is clicked", async () => { + it.each([ + ["Next", 1, 2], + ["Prev", 2, 1], + ])("clicking %s from page %i calls onPageChange with %i", async ( + button, + page, + expected, + ) => { const onPageChange = vi.fn(); - renderTable({ page: 2, totalPages: 3, totalCount: 25, onPageChange }); - await userEvent.click(screen.getByText("Prev")); - expect(onPageChange).toHaveBeenCalledWith(1); - }); - - it("disables Prev button on the first page", () => { - renderTable({ - page: 1, - totalPages: 3, - totalCount: 25, - onPageChange: vi.fn(), - }); - expect(screen.getByText("Prev")).toBeDisabled(); - }); - - it("disables Next button on the last page", () => { - renderTable({ - page: 3, - totalPages: 3, - totalCount: 25, - onPageChange: vi.fn(), - }); - expect(screen.getByText("Next")).toBeDisabled(); + renderTable({ page, totalPages: 3, totalCount: 25, onPageChange }); + await userEvent.click(screen.getByText(button)); + expect(onPageChange).toHaveBeenCalledWith(expected); + }); + + it.each([ + [1, "Prev", "Next"], + [3, "Next", "Prev"], + ])("on page %i of 3, %s is disabled and %s is not", ( + page, + disabled, + enabled, + ) => { + renderTable({ page, totalPages: 3, totalCount: 25, onPageChange: vi.fn() }); + expect(screen.getByText(disabled)).toBeDisabled(); + expect(screen.getByText(enabled)).not.toBeDisabled(); }); }); describe("sorting", () => { - const sortableColumns: Column[] = [ - { key: "id", header: "ID", sortable: true, render: (row) => row.id }, - { - key: "name", - header: "Name", - sortable: false, - render: (row) => row.name, - }, - ]; - it("renders a sort button only for sortable columns", () => { - renderTable({ columns: sortableColumns, onSort: vi.fn() }); + renderTable({ columns: SORTABLE_COLUMNS, onSort: vi.fn() }); expect( screen.getByRole("button", { name: /sort by id/i }), ).toBeInTheDocument(); @@ -397,7 +209,7 @@ describe("DataTable", () => { }); it("does not render sort buttons when onSort is not provided", () => { - renderTable({ columns: sortableColumns }); + renderTable({ columns: SORTABLE_COLUMNS }); expect( screen.queryByRole("button", { name: /sort by/i }), ).not.toBeInTheDocument(); @@ -405,51 +217,37 @@ describe("DataTable", () => { it("calls onSort with the column key when sort button is clicked", async () => { const onSort = vi.fn(); - renderTable({ columns: sortableColumns, onSort }); - await userEvent.click( - screen.getByRole("button", { name: /sort by id/i }), - ); + renderTable({ columns: SORTABLE_COLUMNS, onSort }); + await userEvent.click(screen.getByRole("button", { name: /sort by id/i })); expect(onSort).toHaveBeenCalledWith("id"); }); - it("sets aria-sort='ascending' on the active sorted column when order is asc", () => { + it.each([ + ["asc", "ascending"], + ["desc", "descending"], + [undefined, "none"], + ] as const)("sortOrder=%s sets aria-sort='%s' on the sorted column", ( + sortOrder, + expected, + ) => { renderTable({ - columns: sortableColumns, + columns: SORTABLE_COLUMNS, onSort: vi.fn(), sortField: "id", - sortOrder: "asc", - }); - expect(screen.getByRole("columnheader", { name: /id/i })).toHaveAttribute( - "aria-sort", - "ascending", - ); - }); - - it("sets aria-sort='descending' on the active sorted column when order is desc", () => { - renderTable({ - columns: sortableColumns, - onSort: vi.fn(), - sortField: "id", - sortOrder: "desc", + sortOrder, }); expect(screen.getByRole("columnheader", { name: /id/i })).toHaveAttribute( "aria-sort", - "descending", + expected, ); }); it("sets aria-sort='none' on a sortable column that is not the active sort field", () => { - const threeColumns: Column[] = [ - { key: "id", header: "ID", sortable: true, render: (row) => row.id }, - { - key: "name", - header: "Name", - sortable: true, - render: (row) => row.name, - }, - ]; renderTable({ - columns: threeColumns, + columns: SORTABLE_COLUMNS.map((column) => ({ + ...column, + sortable: true, + })), onSort: vi.fn(), sortField: "name", sortOrder: "asc", @@ -458,21 +256,9 @@ describe("DataTable", () => { expect(idSortBtn.closest("th")).toHaveAttribute("aria-sort", "none"); }); - it("sets aria-sort='none' when sortField matches but sortOrder is undefined", () => { - renderTable({ - columns: sortableColumns, - onSort: vi.fn(), - sortField: "id", - }); - expect(screen.getByRole("columnheader", { name: /id/i })).toHaveAttribute( - "aria-sort", - "none", - ); - }); - it("does not set aria-sort on non-sortable columns", () => { renderTable({ - columns: sortableColumns, + columns: SORTABLE_COLUMNS, onSort: vi.fn(), sortField: "id", sortOrder: "asc", @@ -481,59 +267,17 @@ describe("DataTable", () => { screen.getByRole("columnheader", { name: "Name" }), ).not.toHaveAttribute("aria-sort"); }); - - it("sort button has aria-label matching Sort by
", () => { - renderTable({ columns: sortableColumns, onSort: vi.fn() }); - const btn = screen.getByRole("button", { name: /sort by id/i }); - expect(btn).toHaveAttribute("aria-label", "Sort by ID"); - }); }); - describe("noWrapper prop", () => { - it("renders the default card wrapper when noWrapper is omitted", () => { - const { container } = renderTable(); - const wrapper = container.firstChild as HTMLElement; - expect(wrapper.className).toContain("bg-card"); - expect(wrapper.className).toContain("border"); - expect(wrapper.className).toContain("rounded-xl"); - }); - - it("wrapper has overflow-hidden so content does not bleed outside rounded corners", () => { - const { container } = renderTable(); - const wrapper = container.firstChild as HTMLElement; - expect(wrapper.className).toContain("overflow-hidden"); - }); - - it("skips the default card wrapper when noWrapper is true", () => { - const { container } = renderTable({ noWrapper: true }); - const firstChild = container.firstChild as HTMLElement; - expect(firstChild.className).not.toContain("bg-card"); - expect(firstChild.className).not.toContain("rounded-xl"); - expect(firstChild.className).toContain("overflow-x-auto"); - }); - }); - - describe("label prop", () => { - it("sets an aria-label on the table when label is provided", () => { - renderTable({ label: "Users" }); - expect(screen.getByRole("table", { name: "Users" })).toBeInTheDocument(); - }); - - it("does not set an aria-label when label is omitted", () => { - renderTable(); + it.each([ + ["Users", "Users"], + [undefined, null], + ])("label=%s sets the table's accessible name to %s", (label, expected) => { + renderTable({ label }); + if (expected === null) { expect(screen.getByRole("table")).not.toHaveAttribute("aria-label"); - }); - }); - - describe("table structure", () => { - it("renders a element", () => { - renderTable(); - expect(screen.getByRole("table")).toBeInTheDocument(); - }); - - it("renders a thead and tbody as row groups", () => { - renderTable(); - expect(screen.getAllByRole("rowgroup")).toHaveLength(2); - }); + } else { + expect(screen.getByRole("table", { name: expected })).toBeInTheDocument(); + } }); }); diff --git a/ui/apps/console/src/components/common/__tests__/DeviceLimitBanner.test.tsx b/ui/apps/console/src/components/common/__tests__/DeviceLimitBanner.test.tsx index b7b19ba2f0e..53e4ce9ebea 100644 --- a/ui/apps/console/src/components/common/__tests__/DeviceLimitBanner.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/DeviceLimitBanner.test.tsx @@ -72,15 +72,6 @@ describe("DeviceLimitBanner", () => { }); }); - it("shows RED (role=alert) when cap=10 and registered=10", async () => { - setHandlers(makeLicense(10), { registered_devices: 10 }); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - it("shows RED (role=alert) when cap=0 and registered=0 (cap===0 -> over)", async () => { setHandlers(makeLicense(0), { registered_devices: 0 }); renderBanner(); @@ -105,15 +96,6 @@ describe("DeviceLimitBanner", () => { ).toBeInTheDocument(); }); }); - - it("shows YELLOW (role=status) when cap=10 and registered=9 (90% boundary)", async () => { - setHandlers(makeLicense(10), { registered_devices: 9 }); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - }); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); }); describe("visibility guards", () => { @@ -160,18 +142,21 @@ describe("DeviceLimitBanner", () => { expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); - it("is absent when no license is installed", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json({}, { status: 400 }), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - }); + it.each([400, 500])( + "is absent when the license query fails with %i", + async (status) => { + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status }), + ), + ); + renderBanner(); + await waitFor(() => { + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + }, + ); it("is absent when useAdminStats errors", async () => { server.use( @@ -190,19 +175,6 @@ describe("DeviceLimitBanner", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); - - it("is absent when the license query errored", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - }); }); describe("cloud deployment", () => { diff --git a/ui/apps/console/src/components/common/__tests__/EmptyState.test.tsx b/ui/apps/console/src/components/common/__tests__/EmptyState.test.tsx index 894e41a1baa..d5866798e54 100644 --- a/ui/apps/console/src/components/common/__tests__/EmptyState.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/EmptyState.test.tsx @@ -83,30 +83,19 @@ describe("EmptyState", () => { expect(screen.queryByRole("list")).not.toBeInTheDocument(); }); - it("applies the yellow accent to the overline", () => { + it.each([ + ["yellow", "text-accent-yellow/80"], + [undefined, "text-primary/80"], + ] as const)("accent=%s colours the overline %s", (accent, className) => { render( } - overline="Vault Locked" - title="Locked" - description="d" - accent="yellow" - />, - ); - expect(screen.getByText("Vault Locked")).toHaveClass( - "text-accent-yellow/80", - ); - }); - - it("defaults to the primary accent", () => { - render( - } - overline="Networking" + overline="Overline" title="t" description="d" + accent={accent} />, ); - expect(screen.getByText("Networking")).toHaveClass("text-primary/80"); + expect(screen.getByText("Overline")).toHaveClass(className); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/FilterBadge.test.tsx b/ui/apps/console/src/components/common/__tests__/FilterBadge.test.tsx index 28b85ccba70..3a03b5b0f41 100644 --- a/ui/apps/console/src/components/common/__tests__/FilterBadge.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/FilterBadge.test.tsx @@ -3,53 +3,30 @@ import { render, screen } from "@testing-library/react"; import FilterBadge from "../FilterBadge"; +function tag(name: string) { + return { name, tenant_id: "", created_at: "", updated_at: "" }; +} + describe("FilterBadge", () => { - describe("tags variant — uses Badge component with color=primary", () => { - it("renders a chip for each tag", () => { - render( - , - ); - expect(screen.getByText("web")).toBeInTheDocument(); - expect(screen.getByText("iot")).toBeInTheDocument(); - }); + it("renders a chip for each tag", () => { + render(); + expect(screen.getByText("web")).toBeInTheDocument(); + expect(screen.getByText("iot")).toBeInTheDocument(); }); - describe("hostname variant — stays inline (font-mono, no font-weight — NOT a Badge)", () => { - it("renders the hostname text", () => { - render(); - expect(screen.getByText("web-server-01")).toBeInTheDocument(); - }); - - it("chip keeps font-mono (not font-medium) — non-lossless, must stay inline", () => { - render(); - const chip = screen.getByText("web-server-01").closest("span"); - expect(chip!.className).toContain("font-mono"); - expect(chip!.className).not.toContain("font-medium"); - }); + it("renders the hostname in font-mono, not as a Badge", () => { + render(); + const chip = screen.getByText("web-server-01").closest("span"); + expect(chip!.className).toContain("font-mono"); + expect(chip!.className).not.toContain("font-medium"); }); - describe("all-devices variant — stays inline (bg-hover-medium, no palette — NOT a Badge)", () => { - it("renders 'All devices' when filter is empty", () => { - render(); - expect(screen.getByText("All devices")).toBeInTheDocument(); - }); - - it("renders 'All devices' when hostname is '.*'", () => { - render(); - expect(screen.getByText("All devices")).toBeInTheDocument(); - }); - - it("chip uses bg-hover-medium (not a palette colour — must stay inline)", () => { - render(); + it.each([[{}], [{ hostname: ".*" }]])( + "renders 'All devices' for filter %j", + (filter) => { + render(); const chip = screen.getByText("All devices").closest("span"); expect(chip!.className).toContain("bg-hover-medium"); - }); - }); + }, + ); }); diff --git a/ui/apps/console/src/components/common/__tests__/InfoItem.test.tsx b/ui/apps/console/src/components/common/__tests__/InfoItem.test.tsx index a92ad2e4f30..a1309a57e28 100644 --- a/ui/apps/console/src/components/common/__tests__/InfoItem.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/InfoItem.test.tsx @@ -16,27 +16,10 @@ describe("InfoItem", () => { expect(screen.getByRole("definition")).toHaveTextContent("abc-123"); }); - it("applies monospace styling when mono is true", () => { - renderInfoItem(); - - const span = screen.getByText("aa:bb:cc"); - expect(span).toHaveClass("font-mono"); - }); - - it("applies font-medium when mono is false", () => { - renderInfoItem(); - - const span = screen.getByText("my-device"); - expect(span).toHaveClass("font-medium"); - expect(span).not.toHaveClass("font-mono"); - }); - it("renders a CopyButton when copyable is true and value is truthy", () => { renderInfoItem(); - expect( - screen.getByRole("button", { name: /copy/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument(); }); it("does not render a CopyButton when value is empty", () => { @@ -54,18 +37,10 @@ describe("InfoItem", () => { expect(screen.getByText("abcdefgh")).toBeInTheDocument(); expect(screen.queryByText("abcdefgh-1234")).not.toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /copy/i }), - ).toBeInTheDocument(); - }); - - it("renders an em-dash when value is empty", () => { - renderInfoItem(); - - expect(screen.getByRole("definition")).toHaveTextContent("—"); + expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument(); }); - it("renders an em-dash when value is not provided", () => { + it("renders an em-dash when no value is provided", () => { renderInfoItem(); expect(screen.getByRole("definition")).toHaveTextContent("—"); @@ -96,7 +71,9 @@ describe("InfoItem", () => { expect( screen.queryByRole("button", { name: /copy/i }), ).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Custom link" })).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Custom link" }), + ).toBeInTheDocument(); }); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/LicenseBanner.test.tsx b/ui/apps/console/src/components/common/__tests__/LicenseBanner.test.tsx index 277d0727718..216200f84c0 100644 --- a/ui/apps/console/src/components/common/__tests__/LicenseBanner.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/LicenseBanner.test.tsx @@ -8,6 +8,8 @@ import type { GetLicense200 as GetLicenseResponse } from "@/client/model"; import { useAuthStore } from "@/stores/authStore"; import LicenseBanner from "../LicenseBanner"; +const DAY = 86400; + const mockGetConfig = vi.mocked(getConfig); function makeLicense( @@ -34,6 +36,26 @@ function makeLicense( } as GetLicenseResponse; } +function setLicense(overrides: Partial = {}) { + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json(makeLicense(overrides)), + ), + ); +} + +function setNoLicense() { + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); +} + +function nowSeconds() { + return Math.floor(Date.now() / 1000); +} + function renderBanner() { return render(, { wrapper: createTestWrapper() }); } @@ -45,7 +67,7 @@ beforeEach(() => { }); describe("LicenseBanner", () => { - describe("visibility", () => { + describe("hidden states", () => { it("is hidden while the license check is in progress", () => { server.use(http.get("*/admin/api/license", () => new Promise(() => {}))); renderBanner(); @@ -53,331 +75,109 @@ describe("LicenseBanner", () => { expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); - it("is hidden when the query is not enabled (non-admin)", async () => { - useAuthStore.setState({ isAdmin: false }); - server.use( - http.get("*/admin/api/license", () => HttpResponse.json(makeLicense())), - ); - renderBanner(); - await waitFor(() => - expect(screen.queryByRole("alert")).not.toBeInTheDocument(), - ); - }); - - it("is hidden when the query fails unexpectedly", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - }); - - it("is hidden when license is valid", async () => { - server.use( - http.get("*/admin/api/license", () => HttpResponse.json(makeLicense())), - ); + it.each([ + [ + "the query is not enabled (non-admin)", + () => { + useAuthStore.setState({ isAdmin: false }); + setLicense(); + }, + ], + [ + "the query fails unexpectedly", + () => + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 500 }), + ), + ), + ], + ["the license is valid", () => setLicense()], + [ + "the edition is cloud, so getLicense never fires", + () => + mockGetConfig.mockReturnValue({ + ...defaultConfig, + edition: "cloud", + }), + ], + ])("is hidden when %s", async (_label, arrange) => { + arrange(); renderBanner(); await waitFor(() => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); }); - - it("is shown when no license is installed", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json({}, { status: 400 }), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - }); - - it("is shown when license is expired", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ expired: true, grace_period: false }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect( - screen.getByText(/your license has expired\./i), - ).toBeInTheDocument(); - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - }); - - it("is shown when license is in the grace period", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json(makeLicense({ expired: true, grace_period: true })), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByText(/grace period/i)).toBeInTheDocument(); - expect(screen.getByRole("status")).toBeInTheDocument(); - }); - }); - - it("is shown when license is about to expire", async () => { - const expiresAt = Math.floor(Date.now() / 1000) + 5 * 86400; - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect( - screen.getByText(/about to expire|expires in/i), - ).toBeInTheDocument(); - expect(screen.getByRole("status")).toBeInTheDocument(); - }); - }); - }); - - describe("severity", () => { - it("uses error (role=alert) when no license is installed", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json({}, { status: 400 }), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - - it("uses error (role=alert) when license is expired", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ expired: true, grace_period: false }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - - it("uses warning (role=status) when license is in the grace period", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json(makeLicense({ expired: true, grace_period: true })), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - }); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); - - it("uses warning (role=status) when license is about to expire", async () => { - const expiresAt = Math.floor(Date.now() / 1000) + 5 * 86400; - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - }); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); - }); - - describe("messages", () => { - it("shows the no-license message", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json({}, { status: 400 }), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - expect(screen.getByText(/no license installed/i)).toBeInTheDocument(); - }); - }); - - it("shows the expired message", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ expired: true, grace_period: false }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - expect( - screen.getByText( - /your license has expired\. this instance won't function/i, - ), - ).toBeInTheDocument(); - }); - }); - - it("shows the grace period message", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json(makeLicense({ expired: true, grace_period: true })), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - expect(screen.getByText(/grace period/i)).toBeInTheDocument(); - }); - }); - - it("shows days remaining when about to expire and days are known", async () => { - const expiresAt = Math.floor(Date.now() / 1000) + 1 * 86400; - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - expect(screen.getByText(/expires in 1 day\b/i)).toBeInTheDocument(); - }); - }); - - it("uses the plural form when more than one day remains", async () => { - const expiresAt = Math.floor(Date.now() / 1000) + 5 * 86400; - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - expect(screen.getByText(/expires in 5 days/i)).toBeInTheDocument(); - }); - }); - - it("shows the fallback about-to-expire message when expires_at is not set", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ about_to_expire: true, expires_at: -1 }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - expect(screen.getByText(/is about to expire/i)).toBeInTheDocument(); - }); - }); - - it("shows fallback about-to-expire copy when expires_at is in the past", async () => { - const expiredAt = Math.floor(Date.now() / 1000) - 1; - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ about_to_expire: true, expires_at: expiredAt }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - expect(screen.getByText(/is about to expire/i)).toBeInTheDocument(); - }); - }); - - it("shows fallback about-to-expire copy when days would be zero", async () => { - const nowSeconds = Math.floor(Date.now() / 1000); - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ about_to_expire: true, expires_at: nowSeconds }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - expect(screen.queryByText(/expires in 0 day/i)).not.toBeInTheDocument(); - expect(screen.getByText(/is about to expire/i)).toBeInTheDocument(); - }); - }); }); - describe("no CTA link", () => { - it("never renders any link when no license is installed (error state)", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json({}, { status: 400 }), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - expect(screen.queryByRole("link")).not.toBeInTheDocument(); - expect(screen.queryByText(/upload license/i)).not.toBeInTheDocument(); - }); - - it("never renders any link when license is about to expire (warning state)", async () => { - const expiresAt = Math.floor(Date.now() / 1000) + 5 * 86400; - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), - ), - ), - ); - renderBanner(); - await waitFor(() => { - expect(screen.getByRole("status")).toBeInTheDocument(); - }); + describe("severity and message", () => { + it.each([ + ["no license is installed", setNoLicense, "alert", /no license installed/i], + [ + "the license is expired", + () => setLicense({ expired: true, grace_period: false }), + "alert", + /your license has expired\. this instance won't function/i, + ], + [ + "the license is in the grace period", + () => setLicense({ expired: true, grace_period: true }), + "status", + /grace period/i, + ], + [ + "one day remains", + () => + setLicense({ about_to_expire: true, expires_at: nowSeconds() + DAY }), + "status", + /expires in 1 day\b/i, + ], + [ + "several days remain", + () => + setLicense({ + about_to_expire: true, + expires_at: nowSeconds() + 5 * DAY, + }), + "status", + /expires in 5 days/i, + ], + [ + "expires_at is not set", + () => setLicense({ about_to_expire: true, expires_at: -1 }), + "status", + /is about to expire/i, + ], + [ + "expires_at is already in the past", + () => setLicense({ about_to_expire: true, expires_at: nowSeconds() - 1 }), + "status", + /is about to expire/i, + ], + [ + "the remaining days would round to zero", + () => setLicense({ about_to_expire: true, expires_at: nowSeconds() }), + "status", + /is about to expire/i, + ], + ] as const)("when %s it is a %s reading '%s'", async ( + _label, + arrange, + role, + message, + ) => { + arrange(); + renderBanner(); + + await waitFor(() => expect(screen.getByRole(role)).toBeInTheDocument()); + expect(screen.getByText(message)).toBeInTheDocument(); + expect( + screen.queryByRole(role === "alert" ? "status" : "alert"), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/expires in 0 day/i)).not.toBeInTheDocument(); expect(screen.queryByRole("link")).not.toBeInTheDocument(); expect(screen.queryByText(/upload license/i)).not.toBeInTheDocument(); }); }); - - describe("cloud deployment", () => { - it("is hidden when cloud=true and admin=true (getLicense never fires)", async () => { - mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); - - renderBanner(); - - await waitFor(() => { - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - }); - }); }); diff --git a/ui/apps/console/src/components/common/__tests__/NamespaceGuard.test.tsx b/ui/apps/console/src/components/common/__tests__/NamespaceGuard.test.tsx index 76d17ce0c25..368af270b4e 100644 --- a/ui/apps/console/src/components/common/__tests__/NamespaceGuard.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/NamespaceGuard.test.tsx @@ -42,77 +42,35 @@ function renderGuard(initialPath = "/dashboard") { } describe("NamespaceGuard", () => { - describe("loading state", () => { - it("shows a loading spinner while namespaces are not yet loaded", () => { - server.use(http.get("*/api/namespaces", () => new Promise(() => {}))); - renderGuard(); - expect(screen.getByText(/loading/i)).toBeInTheDocument(); - }); - - it("does not render the outlet while loading", () => { - server.use(http.get("*/api/namespaces", () => new Promise(() => {}))); - renderGuard(); - expect(screen.queryByText("dashboard content")).not.toBeInTheDocument(); - }); + it("holds the outlet back behind a spinner while namespaces load", () => { + server.use(http.get("*/api/namespaces", () => new Promise(() => {}))); + renderGuard(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + expect(screen.queryByText("dashboard content")).not.toBeInTheDocument(); }); - describe("with namespaces", () => { - it("renders the outlet when namespaces exist", async () => { - server.use( - http.get("*/api/namespaces", () => - jsonWithTotal([mockNamespace({ tenant_id: "t1", name: "ns1" })]), - ), - ); - renderGuard(); - expect(await screen.findByText("dashboard content")).toBeInTheDocument(); - }); - - it("does not show the create-namespace screen when namespaces exist", async () => { - server.use( - http.get("*/api/namespaces", () => - jsonWithTotal([mockNamespace({ tenant_id: "t1", name: "ns1" })]), - ), - ); - renderGuard(); - expect(await screen.findByText("dashboard content")).toBeInTheDocument(); - expect(screen.queryByTestId("create-namespace")).not.toBeInTheDocument(); - }); + it("renders the outlet when namespaces exist", async () => { + server.use( + http.get("*/api/namespaces", () => + jsonWithTotal([mockNamespace({ tenant_id: "t1", name: "ns1" })]), + ), + ); + renderGuard(); + expect(await screen.findByText("dashboard content")).toBeInTheDocument(); + expect(screen.queryByTestId("create-namespace")).not.toBeInTheDocument(); }); - describe("without namespaces — non-profile route", () => { - it("shows the create-namespace screen", async () => { - renderGuard("/dashboard"); - expect(await screen.findByTestId("create-namespace")).toBeInTheDocument(); - }); - - it("does not render the outlet", async () => { - renderGuard("/dashboard"); - expect(await screen.findByTestId("create-namespace")).toBeInTheDocument(); - expect(screen.queryByText("dashboard content")).not.toBeInTheDocument(); - }); - - it("renders UserMenu in the minimal header", async () => { - renderGuard("/dashboard"); - expect(await screen.findByTestId("user-menu")).toBeInTheDocument(); - }); + it("replaces a non-profile route with the create-namespace screen and its minimal header", async () => { + renderGuard("/dashboard"); + expect(await screen.findByTestId("create-namespace")).toBeInTheDocument(); + expect(screen.getByTestId("user-menu")).toBeInTheDocument(); + expect(screen.queryByText("dashboard content")).not.toBeInTheDocument(); }); - describe("without namespaces — /profile route", () => { - it("renders the outlet instead of the create-namespace screen", async () => { - renderGuard("/profile"); - expect(await screen.findByText("profile content")).toBeInTheDocument(); - }); - - it("does not show the create-namespace screen", async () => { - renderGuard("/profile"); - expect(await screen.findByText("profile content")).toBeInTheDocument(); - expect(screen.queryByTestId("create-namespace")).not.toBeInTheDocument(); - }); - - it("does not show the minimal header", async () => { - renderGuard("/profile"); - expect(await screen.findByText("profile content")).toBeInTheDocument(); - expect(screen.queryByTestId("user-menu")).not.toBeInTheDocument(); - }); + it("lets /profile through without namespaces", async () => { + renderGuard("/profile"); + expect(await screen.findByText("profile content")).toBeInTheDocument(); + expect(screen.queryByTestId("create-namespace")).not.toBeInTheDocument(); + expect(screen.queryByTestId("user-menu")).not.toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/OnlineDot.test.tsx b/ui/apps/console/src/components/common/__tests__/OnlineDot.test.tsx index e1a585b2d16..218c99fe00c 100644 --- a/ui/apps/console/src/components/common/__tests__/OnlineDot.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/OnlineDot.test.tsx @@ -14,45 +14,16 @@ vi.mock("@shellhub/design-system/primitives", () => ({ role="img" aria-label={online === false ? "Offline" : "Online"} className={`status-dot-mock ${className ?? ""}`} - data-testid="status-dot" /> ), })); describe("OnlineDot", () => { - describe("online", () => { - it("renders aria-label Online", () => { - render(); - expect(screen.getByRole("img", { name: "Online" })).toBeInTheDocument(); - }); - - it("has mx-auto class on outer span", () => { - render(); - const el = screen.getByRole("img", { name: "Online" }); - expect(el.className).toContain("mx-auto"); - }); - - it("delegates to StatusDot", () => { - render(); - expect(screen.getByTestId("status-dot")).toBeInTheDocument(); - }); - }); - - describe("offline", () => { - it("renders aria-label Offline", () => { - render(); - expect(screen.getByRole("img", { name: "Offline" })).toBeInTheDocument(); - }); - - it("has mx-auto class on the single span", () => { - render(); - const el = screen.getByRole("img", { name: "Offline" }); - expect(el.className).toContain("mx-auto"); - }); - - it("delegates to StatusDot", () => { - render(); - expect(screen.getByTestId("status-dot")).toBeInTheDocument(); - }); + it.each([ + [true, "Online"], + [false, "Offline"], + ])("online=%s renders aria-label '%s'", (online, label) => { + render(); + expect(screen.getByRole("img", { name: label })).toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/Pagination.test.tsx b/ui/apps/console/src/components/common/__tests__/Pagination.test.tsx index a343c0b56dd..5555e78a463 100644 --- a/ui/apps/console/src/components/common/__tests__/Pagination.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/Pagination.test.tsx @@ -4,145 +4,131 @@ import userEvent from "@testing-library/user-event"; import Pagination from "../Pagination"; describe("Pagination", () => { - it("Prev and Next buttons have explicit type='button'", () => { + it("gives Prev and Next an explicit type='button'", () => { render( - {}} />, + {}} + />, ); - const buttons = screen.getAllByRole("button"); - buttons.forEach((btn) => { + screen.getAllByRole("button").forEach((btn) => { expect(btn).toHaveAttribute("type", "button"); }); }); - it("shows count for single-page non-empty list and no navigation buttons", () => { - const { container } = render( - {}} />, - ); - expect(container.firstChild).not.toBeNull(); - expect(screen.getByText("5 items")).toBeInTheDocument(); - expect(screen.queryAllByRole("button")).toHaveLength(0); - }); - - it("uses singular label when totalCount is 1", () => { - const { container } = render( - {}} />, - ); - expect(container.firstChild).not.toBeNull(); - expect(screen.getByText("1 item")).toBeInTheDocument(); - }); - - it("renders null for an empty list (totalCount 0, totalPages 0)", () => { - const { container } = render( - {}} />, - ); - expect(container.firstChild).toBeNull(); - }); - - it("shows count but no navigation when totalPages is 0 yet totalCount is provided and non-zero", () => { - const { container } = render( - {}} />, - ); - expect(container.firstChild).not.toBeNull(); - expect(screen.getByText("7 items")).toBeInTheDocument(); - expect(screen.queryAllByRole("button")).toHaveLength(0); - }); - - it("renders null when totalCount is omitted and totalPages is 1", () => { - const { container } = render( - {}} />, - ); - expect(container.firstChild).toBeNull(); - }); + it.each([ + [0, 0], + [1, undefined], + [1, 0], + ])( + "renders nothing for totalPages=%s and totalCount=%s", + (totalPages, totalCount) => { + const { container } = render( + {}} + />, + ); + expect(container.firstChild).toBeNull(); + }, + ); + + it.each([ + [1, 5, "5 items"], + [1, 1, "1 item"], + [0, 7, "7 items"], + ])( + "totalPages=%s with totalCount=%s shows '%s' and no navigation", + (totalPages, totalCount, label) => { + render( + {}} + />, + ); + expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.queryAllByRole("button")).toHaveLength(0); + }, + ); - it("renders Prev/Next navigation when totalPages > 1 and totalCount is omitted", () => { - render( - {}} />, - ); + it("falls back to the page indicator as the left label when totalCount is omitted", () => { + render( {}} />); expect(screen.queryAllByRole("button")).toHaveLength(2); expect(screen.getByText("1 / 3")).toBeInTheDocument(); - expect(screen.queryByText(/item/i)).not.toBeInTheDocument(); expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + expect(screen.queryByText(/item/i)).not.toBeInTheDocument(); }); - it("Prev is disabled on the first page when totalCount is omitted", () => { - render( - {}} />, - ); - expect(screen.getByText("Prev")).toBeDisabled(); - expect(screen.getByText("Next")).not.toBeDisabled(); - }); - - it("Next is disabled on the last page when totalCount is omitted", () => { - render( - {}} />, - ); - expect(screen.getByText("Next")).toBeDisabled(); - expect(screen.getByText("Prev")).not.toBeDisabled(); - }); - - it("shows count, two navigation buttons, and page indicator with multiple pages", () => { + it("shows the count alongside the navigation when there are multiple pages", () => { render( - {}} />, + {}} + />, ); expect(screen.getByText("30 items")).toBeInTheDocument(); expect(screen.queryAllByRole("button")).toHaveLength(2); expect(screen.getByText("2 / 3")).toBeInTheDocument(); }); - it("Prev button calls onPageChange with page - 1", async () => { - const onPageChange = vi.fn(); - render( - , - ); - await userEvent.click(screen.getByText("Prev")); - expect(onPageChange).toHaveBeenCalledOnce(); - expect(onPageChange).toHaveBeenCalledWith(1); + it.each([ + [1, "Previous page", "Next page"], + [3, "Next page", "Previous page"], + ])("on page %s of 3, %s is disabled and %s is not", (page, off, on) => { + render( {}} />); + expect(screen.getByRole("button", { name: off })).toBeDisabled(); + expect(screen.getByRole("button", { name: on })).not.toBeDisabled(); }); - it("Next button calls onPageChange with page + 1", async () => { + it.each([ + ["Previous page", 1], + ["Next page", 3], + ])("%s calls onPageChange with %i", async (name, expected) => { const onPageChange = vi.fn(); render( - , + , ); - await userEvent.click(screen.getByText("Next")); + await userEvent.click(screen.getByRole("button", { name })); expect(onPageChange).toHaveBeenCalledOnce(); - expect(onPageChange).toHaveBeenCalledWith(3); + expect(onPageChange).toHaveBeenCalledWith(expected); }); - it("renders null when totalCount is 0 and totalPages is 1", () => { - const { container } = render( - {}} />, + it("wraps the controls in a labelled navigation landmark", () => { + render( + {}} + />, ); - expect(container.firstChild).toBeNull(); + expect( + screen.getByRole("navigation", { name: "Pagination" }), + ).toBeInTheDocument(); }); - describe("accessibility", () => { - it("wraps the controls in a labelled navigation landmark", () => { - render( - {}} />, - ); - expect( - screen.getByRole("navigation", { name: "Pagination" }), - ).toBeInTheDocument(); - }); - - it("exposes the Prev/Next buttons via aria-label, not just text", () => { - render( - {}} />, - ); - expect( - screen.getByRole("button", { name: "Previous page" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Next page" }), - ).toBeInTheDocument(); - }); - - it("marks the page indicator with aria-current", () => { - render( - {}} />, - ); - expect(screen.getByText("2 / 3")).toHaveAttribute("aria-current", "page"); - }); + it("marks the page indicator with aria-current", () => { + render( + {}} + />, + ); + expect(screen.getByText("2 / 3")).toHaveAttribute("aria-current", "page"); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/PlatformBadge.test.tsx b/ui/apps/console/src/components/common/__tests__/PlatformBadge.test.tsx index d043c43d1cb..c8f5641ce6c 100644 --- a/ui/apps/console/src/components/common/__tests__/PlatformBadge.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/PlatformBadge.test.tsx @@ -4,33 +4,14 @@ import { render, screen } from "@testing-library/react"; import PlatformBadge from "../PlatformBadge"; describe("PlatformBadge", () => { - describe("docker variant — Badge color=blue", () => { - it("renders the text 'Docker'", () => { - render(); - expect(screen.getByText("Docker")).toBeInTheDocument(); - }); - - it("chip carries blue colour classes (bg-accent-blue/10 text-accent-blue)", () => { - render(); - const chip = screen.getByText("Docker").closest("span"); - expect(chip).not.toBeNull(); - expect(chip!.className).toContain("bg-accent-blue/10"); - expect(chip!.className).toContain("text-accent-blue"); - }); - }); - - describe("native variant — Badge color=green", () => { - it("renders the text 'Native'", () => { - render(); - expect(screen.getByText("Native")).toBeInTheDocument(); - }); - - it("chip carries green colour classes (bg-accent-green/10 text-accent-green)", () => { - render(); - const chip = screen.getByText("Native").closest("span"); - expect(chip).not.toBeNull(); - expect(chip!.className).toContain("bg-accent-green/10"); - expect(chip!.className).toContain("text-accent-green"); - }); + it.each([ + ["docker", "Docker", "accent-blue"], + ["native", "Native", "accent-green"], + ] as const)("the %s variant reads '%s' in %s", (platform, label, colour) => { + render(); + const chip = screen.getByText(label).closest("span"); + expect(chip).not.toBeNull(); + expect(chip!.className).toContain(`bg-${colour}/10`); + expect(chip!.className).toContain(`text-${colour}`); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/RenameSection.test.tsx b/ui/apps/console/src/components/common/__tests__/RenameSection.test.tsx index e436e73041d..933646946a1 100644 --- a/ui/apps/console/src/components/common/__tests__/RenameSection.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/RenameSection.test.tsx @@ -50,20 +50,6 @@ beforeEach(() => { describe("RenameSection", () => { describe("display mode", () => { - it("renders the current name as a heading", () => { - renderAndEdit(); - expect( - screen.getByRole("heading", { name: "my-device" }), - ).toBeInTheDocument(); - }); - - it("shows the rename button when canRename is true", () => { - renderAndEdit(); - expect( - screen.getByRole("button", { name: /rename device/i }), - ).toBeInTheDocument(); - }); - it("hides the rename button when canRename is false", () => { renderAndEdit({ canRename: false }); expect( @@ -80,18 +66,6 @@ describe("RenameSection", () => { expect(screen.getByRole("textbox")).toHaveValue("my-device"); }); - it("shows save and cancel buttons", async () => { - const { user } = renderAndEdit(); - await enterEditMode(user); - - expect( - screen.getByRole("button", { name: /save device name/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /cancel rename/i }), - ).toBeInTheDocument(); - }); - it("cancels editing when the cancel button is clicked", async () => { const { user } = renderAndEdit(); await enterEditMode(user); diff --git a/ui/apps/console/src/components/common/__tests__/RestrictedAction.test.tsx b/ui/apps/console/src/components/common/__tests__/RestrictedAction.test.tsx index efa0550d328..6d5cd41e9c1 100644 --- a/ui/apps/console/src/components/common/__tests__/RestrictedAction.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/RestrictedAction.test.tsx @@ -14,34 +14,25 @@ describe("RestrictedAction", () => { useAuthStore.setState({ role: "administrator" }); }); - it("renders the child element", () => { - render( - - - , - ); - expect(screen.getByRole("button", { name: "Add Key" })).toBeInTheDocument(); - }); - - it("does not wrap children in a disabled container", () => { + it("renders the child undisabled and lets it be clicked", async () => { + const user = userEvent.setup(); + let clicked = false; render( - + , ); const button = screen.getByRole("button", { name: "Add Key" }); expect(button.closest("[aria-disabled]")).toBeNull(); - }); - it("allows the child button to be clicked", async () => { - const user = userEvent.setup(); - let clicked = false; - render( - - - , - ); - await user.click(screen.getByRole("button", { name: "Add Key" })); + await user.click(button); expect(clicked).toBe(true); }); }); @@ -51,35 +42,19 @@ describe("RestrictedAction", () => { useAuthStore.setState({ role: "observer" }); }); - it("still renders the child element (visible but restricted)", () => { - render( - - - , - ); - expect(screen.getByRole("button", { name: "Add Key" })).toBeInTheDocument(); - }); - - it("wraps children in a container with aria-disabled=true", () => { + it("marks the wrapper aria-disabled, inert, and titled with the default message", () => { render( , ); const button = screen.getByRole("button", { name: "Add Key" }); - const wrapper = button.closest("[aria-disabled='true']"); - expect(wrapper).toBeInTheDocument(); - }); - - it("shows the default restriction message as a title tooltip", () => { - render( - - - , + expect(button.closest("[aria-disabled='true']")).toBeInTheDocument(); + expect(button.closest("[inert]")).toBeInTheDocument(); + expect(button.closest("[title]")).toHaveAttribute( + "title", + "You don't have permission to perform this action.", ); - const button = screen.getByRole("button", { name: "Add Key" }); - const wrapper = button.closest("[title]"); - expect(wrapper).toHaveAttribute("title", "You don't have permission to perform this action."); }); it("shows a custom message when provided", () => { @@ -89,41 +64,7 @@ describe("RestrictedAction", () => { , ); const button = screen.getByRole("button", { name: "Add Key" }); - const wrapper = button.closest("[title]"); - expect(wrapper).toHaveAttribute("title", "Admins only."); - }); - - it("prevents click events on children via pointer-events-none", () => { - render( - - - , - ); - const button = screen.getByRole("button", { name: "Add Key" }); - const inner = button.closest(".pointer-events-none"); - expect(inner).toBeInTheDocument(); - }); - - it("blocks keyboard interaction via inert attribute on inner wrapper", () => { - render( - - - , - ); - const button = screen.getByRole("button", { name: "Add Key" }); - const inner = button.closest("[inert]"); - expect(inner).toBeInTheDocument(); - }); - - it("applies cursor-not-allowed to the outer wrapper", () => { - render( - - - , - ); - const button = screen.getByRole("button", { name: "Add Key" }); - const outer = button.closest(".cursor-not-allowed"); - expect(outer).toBeInTheDocument(); + expect(button.closest("[title]")).toHaveAttribute("title", "Admins only."); }); }); @@ -135,7 +76,9 @@ describe("RestrictedAction", () => { , ); - expect(screen.getByRole("button").closest("[aria-disabled='true']")).toBeInTheDocument(); + expect( + screen.getByRole("button").closest("[aria-disabled='true']"), + ).toBeInTheDocument(); useAuthStore.setState({ role: "administrator" }); rerender( diff --git a/ui/apps/console/src/components/common/__tests__/StatCard.test.tsx b/ui/apps/console/src/components/common/__tests__/StatCard.test.tsx index d77097ab4a8..5e885130e08 100644 --- a/ui/apps/console/src/components/common/__tests__/StatCard.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/StatCard.test.tsx @@ -33,13 +33,9 @@ function renderButton() { } describe("StatCard", () => { - it("title is visible", () => { + it("shows the title and the value", () => { renderLink(); expect(screen.getByText("Online Devices")).toBeInTheDocument(); - }); - - it("value is visible", () => { - renderLink(); expect(screen.getByText("42")).toBeInTheDocument(); }); diff --git a/ui/apps/console/src/components/common/__tests__/TagFilterDropdown.test.tsx b/ui/apps/console/src/components/common/__tests__/TagFilterDropdown.test.tsx index d9cbde36d59..0e00056ddee 100644 --- a/ui/apps/console/src/components/common/__tests__/TagFilterDropdown.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/TagFilterDropdown.test.tsx @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { fireEvent } from "@testing-library/react"; import { setTags } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import TagFilterDropdown from "../TagFilterDropdown"; @@ -41,11 +40,6 @@ describe("TagFilterDropdown", () => { }); describe("trigger button", () => { - it("renders the Tags trigger button", () => { - renderDropdown(); - expect(screen.getByRole("button", { name: /tags/i })).toBeInTheDocument(); - }); - it("does not show a count badge when no tags are active", () => { renderDropdown({ filterTags: [] }); const btn = screen.getByRole("button", { name: /tags/i }); @@ -57,33 +51,9 @@ describe("TagFilterDropdown", () => { const btn = screen.getByRole("button", { name: /tags/i }); expect(btn.textContent).toContain("2"); }); - - it("shows badge with count 1 when one tag is active", () => { - renderDropdown({ filterTags: ["alpha"] }); - const btn = screen.getByRole("button", { name: /tags/i }); - expect(btn.textContent).toContain("1"); - }); }); describe("opening the popover", () => { - it("opens the popover when the trigger is clicked", async () => { - renderDropdown(); - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await screen.findByPlaceholderText("Search tags..."); - }); - - it("renders all available tags in the list when opened", async () => { - renderDropdown(); - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await screen.findByRole("button", { name: /^alpha$/i }); - expect( - screen.getByRole("button", { name: /^beta$/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /^gamma$/i }), - ).toBeInTheDocument(); - }); - it("clears the search state when reopened", async () => { renderDropdown(); const trigger = screen.getByRole("button", { name: /tags/i }); @@ -100,35 +70,6 @@ describe("TagFilterDropdown", () => { }); }); - describe("closing the popover", () => { - it("closes the popover on Escape key", async () => { - renderDropdown(); - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await screen.findByPlaceholderText("Search tags..."); - - fireEvent.keyDown(document, { key: "Escape" }); - - await waitFor(() => { - expect( - screen.queryByPlaceholderText("Search tags..."), - ).not.toBeInTheDocument(); - }); - }); - - it("toggles closed when trigger is clicked while open", async () => { - renderDropdown(); - const trigger = screen.getByRole("button", { name: /tags/i }); - - await userEvent.click(trigger); - await screen.findByPlaceholderText("Search tags..."); - - await userEvent.click(trigger); - expect( - screen.queryByPlaceholderText("Search tags..."), - ).not.toBeInTheDocument(); - }); - }); - describe("search filtering", () => { it("filters the tag list as the user types in the search input", async () => { renderDropdown(); @@ -192,18 +133,6 @@ describe("TagFilterDropdown", () => { expect(onAdd).toHaveBeenCalledWith("alpha"); }); - - it("does not call onRemove when clicking an inactive tag", async () => { - const onRemove = vi.fn(); - renderDropdown({ filterTags: [], onRemove }); - - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await userEvent.click( - await screen.findByRole("button", { name: /^alpha$/i }), - ); - - expect(onRemove).not.toHaveBeenCalled(); - }); }); describe("removing a tag (toggle)", () => { @@ -218,39 +147,9 @@ describe("TagFilterDropdown", () => { expect(onRemove).toHaveBeenCalledWith("alpha"); }); - - it("does not call onAdd when clicking an active tag", async () => { - const onAdd = vi.fn(); - renderDropdown({ filterTags: ["alpha"], onAdd }); - - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await userEvent.click( - await screen.findByRole("button", { name: /^alpha$/i }), - ); - - expect(onAdd).not.toHaveBeenCalled(); - }); }); describe("clear all button", () => { - it("is not rendered when no tags are active", async () => { - renderDropdown({ filterTags: [] }); - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await screen.findByPlaceholderText("Search tags..."); - expect( - screen.queryByRole("button", { name: /clear all/i }), - ).not.toBeInTheDocument(); - }); - - it("is rendered when at least one tag is active", async () => { - renderDropdown({ filterTags: ["alpha"] }); - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await screen.findByPlaceholderText("Search tags..."); - expect( - screen.getByRole("button", { name: /clear all/i }), - ).toBeInTheDocument(); - }); - it("calls onClearAll when Clear all is clicked", async () => { const onClearAll = vi.fn(); renderDropdown({ filterTags: ["alpha"], onClearAll }); @@ -261,24 +160,9 @@ describe("TagFilterDropdown", () => { expect(onClearAll).toHaveBeenCalledTimes(1); }); - - it("closes the popover after clearing all", async () => { - const onClearAll = vi.fn(); - renderDropdown({ filterTags: ["alpha"], onClearAll }); - - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await screen.findByPlaceholderText("Search tags..."); - await userEvent.click(screen.getByRole("button", { name: /clear all/i })); - - await waitFor(() => { - expect( - screen.queryByPlaceholderText("Search tags..."), - ).not.toBeInTheDocument(); - }); - }); }); - describe("manage tags button — absent", () => { + describe("manage tags button", () => { it("is NOT rendered when onManageTags is not provided", async () => { renderDropdown({ onManageTags: undefined }); await userEvent.click(screen.getByRole("button", { name: /tags/i })); @@ -287,17 +171,6 @@ describe("TagFilterDropdown", () => { screen.queryByRole("button", { name: /manage tags/i }), ).not.toBeInTheDocument(); }); - }); - - describe("manage tags button — present", () => { - it("IS rendered when onManageTags is provided", async () => { - renderDropdown({ onManageTags: vi.fn() }); - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await screen.findByPlaceholderText("Search tags..."); - expect( - screen.getByRole("button", { name: /manage tags/i }), - ).toBeInTheDocument(); - }); it("calls onManageTags when the button is clicked", async () => { const onManageTags = vi.fn(); @@ -311,22 +184,5 @@ describe("TagFilterDropdown", () => { expect(onManageTags).toHaveBeenCalledTimes(1); }); - - it("closes the popover after clicking Manage tags", async () => { - const onManageTags = vi.fn(); - renderDropdown({ onManageTags }); - - await userEvent.click(screen.getByRole("button", { name: /tags/i })); - await screen.findByPlaceholderText("Search tags..."); - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - await waitFor(() => { - expect( - screen.queryByPlaceholderText("Search tags..."), - ).not.toBeInTheDocument(); - }); - }); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/TagsPopover.test.tsx b/ui/apps/console/src/components/common/__tests__/TagsPopover.test.tsx index 3861a2c1989..bbb5bc58dee 100644 --- a/ui/apps/console/src/components/common/__tests__/TagsPopover.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/TagsPopover.test.tsx @@ -31,6 +31,30 @@ function renderPopover( ); } +async function openPopover() { + await userEvent.click(screen.getByRole("button", { name: /manage tags/i })); + return screen.getByRole("dialog", { name: /manage tags/i }); +} + +function tagInput() { + return screen.getByRole("textbox", { name: /search or create tag/i }); +} + +async function addProductionViaSuggestion() { + await openPopover(); + await userEvent.type(tagInput(), "prod"); + await userEvent.click( + await screen.findByRole("button", { name: /^production$/i }), + ); +} + +async function removeAlpha() { + const dialog = await openPopover(); + await userEvent.click( + within(dialog).getByRole("button", { name: /remove tag alpha/i }), + ); +} + describe("TagsPopover", () => { beforeEach(() => { vi.clearAllMocks(); @@ -39,17 +63,6 @@ describe("TagsPopover", () => { }); describe("tag chip rendering", () => { - it("renders each tag as a clickable button", () => { - renderPopover({ tags: ["alpha", "beta"] }); - - expect( - screen.getByRole("button", { name: /^alpha$/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /^beta$/i }), - ).toBeInTheDocument(); - }); - it("calls onFilterTag with the tag name when a chip is clicked", async () => { const onFilterTag = vi.fn(); renderPopover({ tags: ["alpha"], onFilterTag }); @@ -66,14 +79,6 @@ describe("TagsPopover", () => { }); describe("edit button visibility", () => { - it("shows the edit button when user has tag:edit permission", () => { - renderPopover(); - - expect( - screen.getByRole("button", { name: /manage tags/i }), - ).toBeInTheDocument(); - }); - it("hides the edit button when user lacks tag:edit permission", () => { useAuthStore.setState({ role: "observer" }); renderPopover(); @@ -92,267 +97,111 @@ describe("TagsPopover", () => { }); }); - describe("popover open", () => { - it("opens the popover when the edit button is clicked", async () => { - renderPopover(); - - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - expect( - screen.getByRole("dialog", { name: /manage tags/i }), - ).toBeInTheDocument(); - }); - - it("shows the search input when the popover is open", async () => { - renderPopover(); - - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - expect( - screen.getByRole("textbox", { name: /search or create tag/i }), - ).toBeInTheDocument(); - }); - - it("shows current tags with remove buttons in the popover", async () => { - renderPopover({ tags: ["alpha", "beta"] }); - - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - const dialog = screen.getByRole("dialog", { name: /manage tags/i }); - expect( - within(dialog).getByRole("button", { name: /remove tag alpha/i }), - ).toBeInTheDocument(); - expect( - within(dialog).getByRole("button", { name: /remove tag beta/i }), - ).toBeInTheDocument(); - }); - - it("closes the popover on Escape key", async () => { - renderPopover(); - - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - expect( - screen.getByRole("dialog", { name: /manage tags/i }), - ).toBeInTheDocument(); - - fireEvent.keyDown(document, { key: "Escape" }); - - await waitFor(() => { - expect( - screen.queryByRole("dialog", { name: /manage tags/i }), - ).not.toBeInTheDocument(); - }); - }); - }); - - describe("max tags reached", () => { - it("shows 'Max 3 tags' message when there are already 3 tags", async () => { - renderPopover({ tags: ["alpha", "beta", "gamma"] }); - - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - expect(screen.getByText(/max 3 tags/i)).toBeInTheDocument(); - }); - - it("does not show the text input when there are already 3 tags", async () => { - renderPopover({ tags: ["alpha", "beta", "gamma"] }); + it("closes the popover on Escape key", async () => { + renderPopover(); - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); + await openPopover(); + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => { expect( - screen.queryByRole("textbox", { name: /search or create tag/i }), + screen.queryByRole("dialog", { name: /manage tags/i }), ).not.toBeInTheDocument(); }); }); - describe("adding a tag via suggestion", () => { - it("calls addTag when a suggestion is clicked", async () => { - mockAddTag.mockResolvedValue(undefined); - setTags(["production"]); - - renderPopover({ tags: [] }); + it("replaces the tag input with a 'Max 3 tags' message at the limit", async () => { + renderPopover({ tags: ["alpha", "beta", "gamma"] }); - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); + await openPopover(); - const input = screen.getByRole("textbox", { - name: /search or create tag/i, - }); - await userEvent.type(input, "prod"); - - await userEvent.click( - await screen.findByRole("button", { name: /^production$/i }), - ); - - await waitFor(() => { - expect(mockAddTag).toHaveBeenCalledWith({ - uid: "entity-1", - name: "production", - }); - }); - }); + expect(screen.getByText(/max 3 tags/i)).toBeInTheDocument(); + expect( + screen.queryByRole("textbox", { name: /search or create tag/i }), + ).not.toBeInTheDocument(); }); - describe("client-side validation", () => { - it("does not call addTag when tag has invalid characters", async () => { - renderPopover({ tags: [] }); + it("calls addTag when a suggestion is clicked", async () => { + mockAddTag.mockResolvedValue(undefined); + setTags(["production"]); + renderPopover({ tags: [] }); - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); + await addProductionViaSuggestion(); - const input = screen.getByRole("textbox", { - name: /search or create tag/i, + await waitFor(() => { + expect(mockAddTag).toHaveBeenCalledWith({ + uid: "entity-1", + name: "production", }); - await userEvent.type(input, "bad-tag{Enter}"); - - expect(mockAddTag).not.toHaveBeenCalled(); }); + }); - it("does not call addTag when tag is too short", async () => { - renderPopover({ tags: [] }); + it.each([ + ["invalid characters", "bad-tag"], + ["too few characters", "ab"], + ])("does not call addTag for a tag with %s", async (_label, value) => { + renderPopover({ tags: [] }); - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); + await openPopover(); + await userEvent.type(tagInput(), `${value}{Enter}`); - const input = screen.getByRole("textbox", { - name: /search or create tag/i, - }); - await userEvent.type(input, "ab{Enter}"); - - expect(mockAddTag).not.toHaveBeenCalled(); - }); + expect(mockAddTag).not.toHaveBeenCalled(); }); - describe("removing a tag", () => { - it("calls removeTag when remove button is clicked", async () => { - mockRemoveTag.mockResolvedValue(undefined); - - renderPopover({ tags: ["alpha"] }); - - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); + it("calls removeTag when remove button is clicked", async () => { + mockRemoveTag.mockResolvedValue(undefined); + renderPopover({ tags: ["alpha"] }); - const dialog = screen.getByRole("dialog", { name: /manage tags/i }); - await userEvent.click( - within(dialog).getByRole("button", { name: /remove tag alpha/i }), - ); + await removeAlpha(); - await waitFor(() => { - expect(mockRemoveTag).toHaveBeenCalledWith({ - uid: "entity-1", - name: "alpha", - }); + await waitFor(() => { + expect(mockRemoveTag).toHaveBeenCalledWith({ + uid: "entity-1", + name: "alpha", }); }); }); describe("error states", () => { - it("shows an error alert when addTag fails", async () => { - mockAddTag.mockRejectedValue(new Error("network error")); + it.each([ + ["a network error", new Error("network error"), null], + ["a 403", { status: 403 }, /you don't have permission to add tags/i], + ] as const)("addTag rejecting with %s raises an alert", async ( + _label, + rejection, + message, + ) => { + mockAddTag.mockRejectedValue(rejection); setTags(["production"]); - renderPopover({ tags: [] }); - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - const input = screen.getByRole("textbox", { - name: /search or create tag/i, - }); - await userEvent.type(input, "prod"); - - await userEvent.click( - await screen.findByRole("button", { name: /^production$/i }), - ); + await addProductionViaSuggestion(); await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); + const alert = screen.getByRole("alert"); + if (message) expect(alert).toHaveTextContent(message); }); }); - it("shows permission error when addTag fails with 403", async () => { - mockAddTag.mockRejectedValue({ status: 403 }); - setTags(["production"]); - - renderPopover({ tags: [] }); - - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - const input = screen.getByRole("textbox", { - name: /search or create tag/i, - }); - await userEvent.type(input, "prod"); - - await userEvent.click( - await screen.findByRole("button", { name: /^production$/i }), - ); - - await waitFor(() => { - expect(screen.getByRole("alert")).toHaveTextContent( - /you don't have permission to add tags/i, - ); - }); - }); - - it("shows permission error when removeTag fails with 403", async () => { - mockRemoveTag.mockRejectedValue({ status: 403 }); - + it.each([ + ["a 403", { status: 403 }, /you don't have permission to remove tags/i], + [ + "a non-403 status", + new Error("server error"), + /failed to remove "alpha"/i, + ], + ] as const)("removeTag rejecting with %s reports '%s'", async ( + _label, + rejection, + message, + ) => { + mockRemoveTag.mockRejectedValue(rejection); renderPopover({ tags: ["alpha"] }); - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - const dialog = screen.getByRole("dialog", { name: /manage tags/i }); - await userEvent.click( - within(dialog).getByRole("button", { name: /remove tag alpha/i }), - ); - - await waitFor(() => { - expect(screen.getByRole("alert")).toHaveTextContent( - /you don't have permission to remove tags/i, - ); - }); - }); - - it("shows generic error when removeTag fails with non-403 status", async () => { - mockRemoveTag.mockRejectedValue(new Error("server error")); - - renderPopover({ tags: ["alpha"] }); - - await userEvent.click( - screen.getByRole("button", { name: /manage tags/i }), - ); - - const dialog = screen.getByRole("dialog", { name: /manage tags/i }); - await userEvent.click( - within(dialog).getByRole("button", { name: /remove tag alpha/i }), - ); + await removeAlpha(); await waitFor(() => { - expect(screen.getByRole("alert")).toHaveTextContent( - /failed to remove "alpha"/i, - ); + expect(screen.getByRole("alert")).toHaveTextContent(message); }); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/TagsSection.test.tsx b/ui/apps/console/src/components/common/__tests__/TagsSection.test.tsx index d33a1564efb..6eef57904cc 100644 --- a/ui/apps/console/src/components/common/__tests__/TagsSection.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/TagsSection.test.tsx @@ -66,14 +66,6 @@ describe("TagsSection", () => { ).not.toBeInTheDocument(); expect(screen.queryByLabelText("New tag")).not.toBeInTheDocument(); }); - - it("shows remove buttons and input when user has tag:edit permission", () => { - renderTagsSection({ tags: ["web"] }); - expect( - screen.getByRole("button", { name: /remove tag web/i }), - ).toBeInTheDocument(); - expect(screen.getByLabelText("New tag")).toBeInTheDocument(); - }); }); describe("adding a tag", () => { @@ -284,22 +276,4 @@ describe("TagsSection", () => { expect(firstOption).toHaveAttribute("aria-selected", "true"); }); }); - - describe("accessibility", () => { - it("renders error messages with role=alert", async () => { - renderTagsSection(); - await typeAndSubmit("ab"); - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - - it("provides descriptive aria-labels on remove buttons", () => { - renderTagsSection({ tags: ["web", "prod"] }); - expect( - screen.getByRole("button", { name: "Remove tag web" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Remove tag prod" }), - ).toBeInTheDocument(); - }); - }); }); diff --git a/ui/apps/console/src/components/common/fields/__tests__/KeyFileInput.test.tsx b/ui/apps/console/src/components/common/fields/__tests__/KeyFileInput.test.tsx index 8772630861d..00727cc7f91 100644 --- a/ui/apps/console/src/components/common/fields/__tests__/KeyFileInput.test.tsx +++ b/ui/apps/console/src/components/common/fields/__tests__/KeyFileInput.test.tsx @@ -64,20 +64,7 @@ function mockFileReader(content: string) { } describe("KeyFileInput", () => { - describe("label", () => { - it("renders the label text", () => { - renderComponent({ label: "Private Key" }); - expect(screen.getByText("Private Key")).toBeInTheDocument(); - }); - }); - describe("mode toggle", () => { - it("shows File and Text buttons when not disabled", () => { - renderComponent(); - expect(screen.getByRole("button", { name: "File" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument(); - }); - it("hides mode toggle buttons when disabled", () => { renderComponent({ disabled: true }); expect( @@ -88,8 +75,14 @@ describe("KeyFileInput", () => { ).not.toBeInTheDocument(); }); - it("shows the drop zone by default (file mode)", () => { + it.each([ + ["by default", [] as string[]], + ["after switching to Text and back to File", ["Text", "File"]], + ])("shows the drop zone %s", async (_label, clicks) => { renderComponent(); + for (const name of clicks) { + await userEvent.click(screen.getByRole("button", { name })); + } expect( screen.getByText("Drop key file, paste, or browse"), ).toBeInTheDocument(); @@ -101,46 +94,20 @@ describe("KeyFileInput", () => { await userEvent.click(screen.getByRole("button", { name: "Text" })); expect(screen.getByRole("textbox")).toBeInTheDocument(); }); - - it("switches back to drop zone when File button is clicked after Text", async () => { - renderComponent(); - await userEvent.click(screen.getByRole("button", { name: "Text" })); - await userEvent.click(screen.getByRole("button", { name: "File" })); - expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); - expect( - screen.getByText("Drop key file, paste, or browse"), - ).toBeInTheDocument(); - }); }); - describe("drop zone — empty state", () => { - it("renders the empty label", () => { - renderComponent(); - expect( - screen.getByText("Drop key file, paste, or browse"), - ).toBeInTheDocument(); - }); - + describe("drop zone", () => { it("renders a custom emptyLabel", () => { renderComponent({ emptyLabel: "Upload your public key" }); expect(screen.getByText("Upload your public key")).toBeInTheDocument(); }); - }); - - describe("drop zone — loaded state", () => { - it("renders the loaded label when a value is present", () => { - renderComponent({ value: "ssh-rsa AAAA" }); - expect(screen.getByText("Key loaded")).toBeInTheDocument(); - }); - - it("renders a custom loadedLabel", () => { - renderComponent({ value: "ssh-rsa AAAA", loadedLabel: "Key ready" }); - expect(screen.getByText("Key ready")).toBeInTheDocument(); - }); - it("renders a Clear button when key is loaded", () => { - renderComponent({ value: "ssh-rsa AAAA" }); - expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); + it.each([ + [undefined, "Key loaded"], + ["Key ready", "Key ready"], + ])("loadedLabel=%s renders as '%s'", (loadedLabel, expected) => { + renderComponent({ value: "ssh-rsa AAAA", loadedLabel }); + expect(screen.getByText(expected)).toBeInTheDocument(); }); it("calls onChange('') when Clear is clicked", async () => { @@ -149,37 +116,11 @@ describe("KeyFileInput", () => { await userEvent.click(screen.getByRole("button", { name: "Clear" })); expect(onChange).toHaveBeenCalledWith(""); }); - }); - - describe("drop zone — dragging state", () => { - it("sets dragging visual when dragOver fires on the drop zone", () => { - const { container } = renderComponent(); - const dropZone = container.querySelector("[ondragover], .border-dashed"); - if (!dropZone) throw new Error("drop zone not found"); - - fireEvent.dragOver(dropZone, { preventDefault: () => {} }); - expect(dropZone.className).toMatch(/border-primary/); - }); - - it("removes dragging visual on dragLeave", () => { - const { container } = renderComponent(); - const dropZone = container.querySelector(".border-dashed") as HTMLElement; - fireEvent.dragOver(dropZone); - fireEvent.dragLeave(dropZone); - expect(dropZone.className).not.toMatch(/bg-primary\/5/); - }); - }); - describe("drop zone — error state", () => { it("renders the error message", () => { renderComponent({ error: "Invalid key format" }); expect(screen.getByText("Invalid key format")).toBeInTheDocument(); }); - - it("does not render an error message when error is null", () => { - renderComponent({ error: null }); - expect(screen.queryByText(/invalid/i)).not.toBeInTheDocument(); - }); }); describe("drag and drop", () => { @@ -247,41 +188,25 @@ describe("KeyFileInput", () => { }); describe("disabled state", () => { - it("renders a textarea (not the drop zone) when disabled", () => { + it("renders a disabled textarea instead of the drop zone", () => { renderComponent({ disabled: true }); - expect(screen.getByRole("textbox")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toBeDisabled(); expect( screen.queryByText("Drop key file, paste, or browse"), ).not.toBeInTheDocument(); }); - it("textarea is disabled", () => { - renderComponent({ disabled: true }); - expect(screen.getByRole("textbox")).toBeDisabled(); - }); - - it("renders disabledHint when disabled", () => { - renderComponent({ disabled: true, disabledHint: "Cannot edit now" }); - expect(screen.getByText("Cannot edit now")).toBeInTheDocument(); - }); - - it("does not render hint when disabled", () => { - renderComponent({ disabled: true, hint: "Upload a key" }); - expect(screen.queryByText("Upload a key")).not.toBeInTheDocument(); - }); - }); - - describe("hint", () => { - it("renders the hint when not disabled", () => { - renderComponent({ hint: "Paste or drag your key file" }); - expect( - screen.getByText("Paste or drag your key file"), - ).toBeInTheDocument(); - }); - - it("does not render disabledHint when not disabled", () => { - renderComponent({ disabledHint: "Read only" }); - expect(screen.queryByText("Read only")).not.toBeInTheDocument(); + it.each([ + [true, "Cannot edit now", "Upload a key"], + [false, "Upload a key", "Cannot edit now"], + ])("disabled=%s shows '%s' and not '%s'", (disabled, shown, hidden) => { + renderComponent({ + disabled, + hint: "Upload a key", + disabledHint: "Cannot edit now", + }); + expect(screen.getByText(shown)).toBeInTheDocument(); + expect(screen.queryByText(hidden)).not.toBeInTheDocument(); }); }); @@ -293,17 +218,11 @@ describe("KeyFileInput", () => { expect(screen.getByLabelText("Public Key")).toBeInTheDocument(); }); - it("marks the textarea aria-invalid when error is provided (text mode)", async () => { + it("marks the textarea invalid and links the error paragraph in text mode", async () => { renderComponent({ error: "Bad key", id: "pub-key" }); await userEvent.click(screen.getByRole("button", { name: "Text" })); const textarea = screen.getByRole("textbox"); expect(textarea).toHaveAttribute("aria-invalid", "true"); - }); - - it("links the error paragraph via aria-describedby when id is provided (text mode)", async () => { - renderComponent({ error: "Bad key", id: "pub-key" }); - await userEvent.click(screen.getByRole("button", { name: "Text" })); - const textarea = screen.getByRole("textbox"); expect(textarea).toHaveAttribute("aria-describedby", "pub-key-error"); }); }); diff --git a/ui/apps/console/src/components/common/fields/__tests__/StepperField.test.tsx b/ui/apps/console/src/components/common/fields/__tests__/StepperField.test.tsx index f0abc4e8a61..9c2004a5acc 100644 --- a/ui/apps/console/src/components/common/fields/__tests__/StepperField.test.tsx +++ b/ui/apps/console/src/components/common/fields/__tests__/StepperField.test.tsx @@ -19,11 +19,6 @@ function renderField(overrides: Overrides = {}) { } describe("StepperField", () => { - it("renders the current value", () => { - renderField({ value: 7 }); - expect(screen.getByRole("textbox", { name: "Count" })).toHaveValue("7"); - }); - it("increments the value when + is clicked", async () => { const user = userEvent.setup(); const { onChange } = renderField(); @@ -54,9 +49,7 @@ describe("StepperField", () => { it("does not disable the increase button when no max is set", () => { renderField({ value: 999 }); - expect( - screen.getByRole("button", { name: "Increase" }), - ).not.toBeDisabled(); + expect(screen.getByRole("button", { name: "Increase" })).not.toBeDisabled(); }); it("calls onChange when a valid in-range number is typed", async () => { diff --git a/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx b/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx index ad10a7a9a6d..565afe6cfa4 100644 --- a/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx +++ b/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx @@ -100,38 +100,16 @@ describe("AppLayout", () => { renderLayout(); expect(await screen.findByTestId("app-bar")).toBeInTheDocument(); }); - - it("renders alongside the sidebar when namespaces exist", async () => { - server.use( - http.get("*/api/namespaces", () => jsonWithTotal([mockNamespace()])), - ); - renderLayout(); - expect(await screen.findByTestId("sidebar")).toBeInTheDocument(); - expect(screen.getByTestId("app-bar")).toBeInTheDocument(); - }); - }); - - describe("ConnectivityBanner", () => { - it("is always mounted", async () => { - renderLayout(); - expect( - await screen.findByTestId("connectivity-banner"), - ).toBeInTheDocument(); - }); }); describe("skip link", () => { - it("renders the skip link pointing at main content", async () => { + it("points at the main landmark, which is focusable", async () => { renderLayout(); const link = await screen.findByRole("link", { name: /skip to main content/i, }); expect(link).toHaveAttribute("href", "#main-content"); - }); - it("exposes the main landmark with id and tabindex", async () => { - renderLayout(); - await screen.findByRole("link", { name: /skip to main content/i }); const main = screen.getByRole("main"); expect(main).toHaveAttribute("id", "main-content"); expect(main).toHaveAttribute("tabindex", "-1"); @@ -147,16 +125,6 @@ describe("AppLayout", () => { link.compareDocumentPosition(main) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); }); - - it("renders the skip link when the sidebar is visible", async () => { - server.use( - http.get("*/api/namespaces", () => jsonWithTotal([mockNamespace()])), - ); - renderLayout(); - expect( - await screen.findByRole("link", { name: /skip to main content/i }), - ).toBeInTheDocument(); - }); }); describe("enterprise banners", () => { @@ -172,23 +140,17 @@ describe("AppLayout", () => { expect(screen.getByTestId("license-banner")).toBeInTheDocument(); }); - it("does not mount the enterprise banners on a community instance", async () => { - renderLayout(); - await screen.findByTestId("app-bar"); - expect( - screen.queryByTestId("device-limit-banner"), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("license-banner")).not.toBeInTheDocument(); - }); - - it("does not mount the enterprise banners in a cloud instance", async () => { - mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); - renderLayout(); - await screen.findByTestId("app-bar"); - expect( - screen.queryByTestId("device-limit-banner"), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("license-banner")).not.toBeInTheDocument(); - }); + it.each(["community", "cloud"] as const)( + "does not mount the enterprise banners on a %s instance", + async (edition) => { + mockGetConfig.mockReturnValue({ ...defaultConfig, edition }); + renderLayout(); + await screen.findByTestId("app-bar"); + expect( + screen.queryByTestId("device-limit-banner"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("license-banner")).not.toBeInTheDocument(); + }, + ); }); }); diff --git a/ui/apps/console/src/components/layout/__tests__/SupportButton.test.tsx b/ui/apps/console/src/components/layout/__tests__/SupportButton.test.tsx index bd04794ddb3..4ee23393173 100644 --- a/ui/apps/console/src/components/layout/__tests__/SupportButton.test.tsx +++ b/ui/apps/console/src/components/layout/__tests__/SupportButton.test.tsx @@ -25,7 +25,7 @@ beforeEach(() => { describe("SupportButton", () => { describe("non-cloud branch", () => { - it("renders a link pointing to the GitHub issue tracker", () => { + it("renders a GitHub issue tracker link that opens safely in a new tab", () => { renderWithStatus(makeHandle("non-cloud")); const link = screen.getByRole("link", { name: /report an issue on github/i, @@ -34,13 +34,6 @@ describe("SupportButton", () => { "href", "https://github.com/shellhub-io/shellhub/issues/new", ); - }); - - it("opens the link in a new tab with safe rel attributes", () => { - renderWithStatus(makeHandle("non-cloud")); - const link = screen.getByRole("link", { - name: /report an issue on github/i, - }); expect(link).toHaveAttribute("target", "_blank"); expect(link).toHaveAttribute("rel", "noopener noreferrer"); }); @@ -54,23 +47,12 @@ describe("SupportButton", () => { }); describe("no-subscription branch", () => { - it("renders an enabled button with the paywall aria-label", () => { - renderWithStatus(makeHandle("no-subscription")); - const button = screen.getByRole("button", { - name: /paid plan required/i, - }); - expect(button).not.toHaveAttribute("aria-disabled"); - }); - - it("does not open the paywall dialog before the user clicks", () => { - renderWithStatus(makeHandle("no-subscription")); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - it("clicking the button opens the paywall dialog and never calls openWidget", async () => { const openWidget = vi.fn(); renderWithStatus(makeHandle("no-subscription", openWidget)); const user = userEvent.setup(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await user.click( screen.getByRole("button", { name: /paid plan required/i }), ); @@ -129,12 +111,6 @@ describe("SupportButton", () => { }); describe("ready branch", () => { - it("renders an enabled button with the correct aria-label", () => { - renderWithStatus(makeHandle("ready")); - const button = screen.getByRole("button", { name: /open support chat/i }); - expect(button).not.toHaveAttribute("aria-disabled"); - }); - it("clicking the button calls openWidget", async () => { const openWidget = vi.fn(); renderWithStatus(makeHandle("ready", openWidget)); diff --git a/ui/apps/console/src/components/mfa/__tests__/MfaDisableDialog.test.tsx b/ui/apps/console/src/components/mfa/__tests__/MfaDisableDialog.test.tsx index 063b1ba3b83..e021ae9b9b3 100644 --- a/ui/apps/console/src/components/mfa/__tests__/MfaDisableDialog.test.tsx +++ b/ui/apps/console/src/components/mfa/__tests__/MfaDisableDialog.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { server } from "@/tests/msw"; @@ -42,15 +42,6 @@ describe("MfaDisableDialog", () => { }); describe("Mode Switching", () => { - it("defaults to TOTP mode", () => { - renderDialog(); - - expect(screen.getByText(/Verification Code/i)).toBeInTheDocument(); - expect( - screen.getByText(/Use recovery code instead/i), - ).toBeInTheDocument(); - }); - it("switches to recovery code mode", async () => { const user = renderDialog(); @@ -176,38 +167,6 @@ describe("MfaDisableDialog", () => { }); }); - describe("Dialog Behavior", () => { - it("closes when cancel button is clicked", async () => { - const user = renderDialog(); - - await user.click(screen.getByText(/cancel/i)); - - expect(onClose).toHaveBeenCalled(); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it("closes when clicking outside (backdrop)", () => { - renderDialog(); - - const dialog = document.querySelector("dialog") as HTMLElement; - fireEvent.mouseDown(dialog); - fireEvent.click(dialog); - expect(onClose).toHaveBeenCalled(); - }); - - it("does not render when open is false", () => { - const { container } = render( - , - ); - - expect(container.firstChild).toBeNull(); - }); - }); - describe("Loading State", () => { it("disables submit button while submitting", async () => { server.use( @@ -229,31 +188,6 @@ describe("MfaDisableDialog", () => { }); }); - describe("Error Handling", () => { - it("shows error after failed TOTP submit and switches to recovery mode", async () => { - server.use( - http.put("*/api/user/mfa/disable", () => - HttpResponse.json({}, { status: 403 }), - ), - ); - const user = renderDialog(); - - await fillTotpCode(user, "999999"); - - await user.click(screen.getByRole("button", { name: /disable mfa/i })); - - await waitFor(() => { - expect( - screen.getByText(/Invalid verification code/i), - ).toBeInTheDocument(); - }); - - await user.click(screen.getByText(/use recovery code instead/i)); - - expect(screen.getByPlaceholderText(/recovery code/i)).toBeInTheDocument(); - }); - }); - describe("Email-Reset Mode", () => { beforeEach(() => { useAuthStore.setState({ user: "admin" }); @@ -296,23 +230,6 @@ describe("MfaDisableDialog", () => { } } - it("shows OTP inputs after requesting codes", async () => { - const user = renderDialog(); - - await navigateToEmailReset(user); - await requestCodes(user); - - expect( - screen.getAllByLabelText(/main email code character/i), - ).toHaveLength(5); - expect( - screen.getAllByLabelText(/recovery email code character/i), - ).toHaveLength(5); - expect( - screen.getByRole("button", { name: /disable mfa/i }), - ).toBeInTheDocument(); - }); - it("submits email codes and calls onSuccess/onClose", async () => { const user = renderDialog(); diff --git a/ui/apps/console/src/components/mfa/__tests__/MfaEnableDrawer.test.tsx b/ui/apps/console/src/components/mfa/__tests__/MfaEnableDrawer.test.tsx index 80f0af05fc7..6cead101437 100644 --- a/ui/apps/console/src/components/mfa/__tests__/MfaEnableDrawer.test.tsx +++ b/ui/apps/console/src/components/mfa/__tests__/MfaEnableDrawer.test.tsx @@ -169,15 +169,6 @@ describe("MfaEnableDrawer", () => { expect(nextButton).toBeEnabled(); }); - - it("downloads recovery codes", async () => { - const user = userEvent.setup(); - - const downloadButton = screen.getByText(/download/i); - await user.click(downloadButton); - - expect(downloadButton).toBeInTheDocument(); - }); }); describe("Step 3: QR Code and Verification", () => { @@ -204,18 +195,11 @@ describe("MfaEnableDrawer", () => { await user.click(nextButton); }); - it("displays QR code", async () => { + it("displays the QR code and the secret for manual entry", async () => { await waitFor(() => { expect(screen.getByText(/scan this qr code/i)).toBeInTheDocument(); }); - }); - - it("displays secret for manual entry", async () => { - await waitFor(() => { - expect( - screen.getByDisplayValue(mockMfaData.secret), - ).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue(mockMfaData.secret)).toBeInTheDocument(); }); it("validates OTP and enables MFA on success", async () => { @@ -338,46 +322,46 @@ describe("MfaEnableDrawer", () => { }); }); - describe("State Cleanup", () => { - it("resets state when drawer is closed and reopened", async () => { - const user = userEvent.setup(); - const { rerender } = render( - , - ); + it("rewinds to step 1 when the drawer is cancelled and reopened", async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); - const continueButton = screen.getByRole("button", { name: /continue/i }); - await user.click(continueButton); - await waitFor(() => { - expect(screen.getByText(/Save Recovery Codes/i)).toBeInTheDocument(); - }); + await user.click(screen.getByRole("button", { name: /continue/i })); + await waitFor(() => { + expect(screen.getByText(/Save Recovery Codes/i)).toBeInTheDocument(); + }); - rerender( - , - ); + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); - rerender( - , - ); + rerender( + , + ); + rerender( + , + ); - await waitFor(() => { - expect(screen.getByText(/Save Recovery Codes/i)).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText(/Confirm Recovery Email/i)).toBeInTheDocument(); }); + expect(screen.queryByText(/Save Recovery Codes/i)).not.toBeInTheDocument(); }); describe("Error Handling", () => { diff --git a/ui/apps/console/src/components/mfa/__tests__/MfaRecoveryTimeoutModal.test.tsx b/ui/apps/console/src/components/mfa/__tests__/MfaRecoveryTimeoutModal.test.tsx index 0a875861806..666f250d15e 100644 --- a/ui/apps/console/src/components/mfa/__tests__/MfaRecoveryTimeoutModal.test.tsx +++ b/ui/apps/console/src/components/mfa/__tests__/MfaRecoveryTimeoutModal.test.tsx @@ -32,20 +32,6 @@ describe("MfaRecoveryTimeoutModal", () => { expect(container.firstChild).toBeNull(); }); - - it("renders when open is true", () => { - const expiresAt = Math.floor(Date.now() / 1000) + 10 * 60; - render( - , - ); - - expect(screen.getByText(/recovery window/i)).toBeInTheDocument(); - }); }); describe("Countdown Display", () => { @@ -123,8 +109,11 @@ describe("MfaRecoveryTimeoutModal", () => { }); describe("Disable Button", () => { - it("enables disable button when countdown is active", () => { - const expiresAt = Math.floor(Date.now() / 1000) + 10 * 60; + it("is enabled while the countdown runs and disabled once it expires", () => { + vi.useFakeTimers(); + const now = Math.floor(Date.now() / 1000) * 1000; + vi.setSystemTime(now); + const expiresAt = now / 1000 + 1; // 1 second render( { name: /disable mfa/i, }); expect(disableButton).toBeEnabled(); - }); - - it("disables disable button when countdown expires", () => { - vi.useFakeTimers(); - const now = Math.floor(Date.now() / 1000) * 1000; - vi.setSystemTime(now); - const expiresAt = now / 1000 + 1; // 1 second - - render( - , - ); act(() => { vi.advanceTimersByTime(2000); @@ -162,9 +135,6 @@ describe("MfaRecoveryTimeoutModal", () => { vi.useRealTimers(); - const disableButton = screen.getByRole("button", { - name: /disable mfa/i, - }); expect(disableButton).toBeDisabled(); }); @@ -261,30 +231,6 @@ describe("MfaRecoveryTimeoutModal", () => { }); describe("Auto-close on Expiry", () => { - it("shows expired state after countdown reaches zero", () => { - vi.useFakeTimers(); - const now = Math.floor(Date.now() / 1000) * 1000; - vi.setSystemTime(now); - const expiresAt = now / 1000 + 2; // 2 seconds - - render( - , - ); - - act(() => { - vi.advanceTimersByTime(3000); - }); - - vi.useRealTimers(); - - expect(screen.getByText(/expired/i)).toBeInTheDocument(); - }); - it("does not auto-close while a disable operation is in progress", async () => { vi.useFakeTimers(); const now = Math.floor(Date.now() / 1000) * 1000; @@ -337,8 +283,7 @@ describe("MfaRecoveryTimeoutModal", () => { const user = userEvent.setup(); const expiresAt = Math.floor(Date.now() / 1000) + 10 * 60; - const suppressRejection = () => { - }; + const suppressRejection = () => {}; process.on("unhandledRejection", suppressRejection); onDisable.mockImplementation(() => @@ -366,59 +311,12 @@ describe("MfaRecoveryTimeoutModal", () => { }); }); - describe("Warning Messages", () => { - it("displays recovery window description", () => { - const expiresAt = Math.floor(Date.now() / 1000) + 10 * 60; - - render( - , - ); - - expect( - screen.getByText(/successfully used a recovery code/i), - ).toBeInTheDocument(); - }); - - it("displays security explanation note", () => { - const expiresAt = Math.floor(Date.now() / 1000) + 10 * 60; - - render( - , - ); - - expect(screen.getByText(/security measure/i)).toBeInTheDocument(); - }); - }); - describe("Invalid Timestamp", () => { - it("handles invalid timestamp gracefully", () => { + it.each([NaN, 0])("still renders when expiresAt is %p", (expiresAt) => { render( , - ); - - expect(screen.getByText(/recovery window/i)).toBeInTheDocument(); - }); - - it("handles zero timestamp", () => { - render( - , diff --git a/ui/apps/console/src/components/sessions/__tests__/RecentSessionsTable.test.tsx b/ui/apps/console/src/components/sessions/__tests__/RecentSessionsTable.test.tsx index 3573f40b7de..739187c4c93 100644 --- a/ui/apps/console/src/components/sessions/__tests__/RecentSessionsTable.test.tsx +++ b/ui/apps/console/src/components/sessions/__tests__/RecentSessionsTable.test.tsx @@ -62,168 +62,82 @@ describe("RecentSessionsTable", () => { ); }); - describe("default (non-admin)", () => { - it("renders 'View all' link to /sessions", async () => { - renderTable(); - await waitFor(() => { - expect(screen.getByRole("link", { name: /view all/i })).toHaveAttribute( - "href", - "/sessions", - ); - }); - }); - - it("navigates to /sessions/:uid on row click", async () => { + it.each([ + [false, "*/api/sessions", ""], + [true, "*/admin/api/sessions", "/admin"], + ] as const)( + "isAdmin=%s links View all, the row and the device chip under '%s'", + async (isAdmin, endpoint, prefix) => { const user = userEvent.setup(); server.use( - http.get("*/api/sessions", () => - jsonWithTotal([makeSession({ uid: "s-1" })]), - ), + http.get(endpoint, () => jsonWithTotal([makeSession({ uid: "s-1" })])), ); - renderTable(); + renderTable(isAdmin); await waitFor(() => { expect(screen.getByText("root")).toBeInTheDocument(); }); - await user.click(screen.getByText("root")); - expect(mockNavigate).toHaveBeenCalledWith("/sessions/s-1"); - }); - it("renders device chip with link to /devices/:uid", async () => { - server.use( - http.get("*/api/sessions", () => jsonWithTotal([makeSession()])), + expect(screen.getByRole("link", { name: /view all/i })).toHaveAttribute( + "href", + `${prefix}/sessions`, ); - renderTable(); - - await waitFor(() => { - expect(screen.getByText("my-device")).toBeInTheDocument(); - }); - const chip = screen.getByText("my-device").closest("a"); - expect(chip).toHaveAttribute("href", "/devices/device-1"); - }); - }); - - describe("admin mode", () => { - it("renders 'View all' link to /admin/sessions", async () => { - renderTable(true); - await waitFor(() => { - expect(screen.getByRole("link", { name: /view all/i })).toHaveAttribute( - "href", - "/admin/sessions", - ); - }); - }); - - it("navigates to /admin/sessions/:uid on row click", async () => { - const user = userEvent.setup(); - server.use( - http.get("*/admin/api/sessions", () => - jsonWithTotal([makeSession({ uid: "s-2" })]), - ), + expect(screen.getByText("my-device").closest("a")).toHaveAttribute( + "href", + `${prefix}/devices/device-1`, ); - renderTable(true); - await waitFor(() => { - expect(screen.getByText("root")).toBeInTheDocument(); - }); await user.click(screen.getByText("root")); - expect(mockNavigate).toHaveBeenCalledWith("/admin/sessions/s-2"); - }); - - it("renders device chip with link to /admin/devices/:uid", async () => { - server.use( - http.get("*/admin/api/sessions", () => jsonWithTotal([makeSession()])), - ); - renderTable(true); - - await waitFor(() => { - expect(screen.getByText("my-device")).toBeInTheDocument(); - }); - const chip = screen.getByText("my-device").closest("a"); - expect(chip).toHaveAttribute("href", "/admin/devices/device-1"); - }); - }); - - describe("loading state", () => { - it("shows loading message while fetching", () => { - server.use(http.get("*/api/sessions", () => new Promise(() => {}))); - renderTable(); - expect(screen.getByText(/loading sessions/i)).toBeInTheDocument(); - }); + expect(mockNavigate).toHaveBeenCalledWith(`${prefix}/sessions/s-1`); + }, + ); + + it("shows loading message while fetching", () => { + server.use(http.get("*/api/sessions", () => new Promise(() => {}))); + renderTable(); + expect(screen.getByText(/loading sessions/i)).toBeInTheDocument(); }); - describe("error state", () => { - it("renders error callout on fetch failure", async () => { - server.use( - http.get("*/api/sessions", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderTable(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); + it("renders error callout on fetch failure", async () => { + server.use( + http.get("*/api/sessions", () => HttpResponse.json({}, { status: 500 })), + ); + renderTable(); + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); }); }); - describe("empty state", () => { - it("shows empty message when no sessions", async () => { - renderTable(); - await waitFor(() => { - expect(screen.getByText("No recent sessions")).toBeInTheDocument(); - }); + it("shows empty message when no sessions", async () => { + renderTable(); + await waitFor(() => { + expect(screen.getByText("No recent sessions")).toBeInTheDocument(); }); }); - describe("unauthenticated session", () => { - it("renders warning icon for unauthenticated sessions", async () => { - server.use( - http.get("*/api/sessions", () => - jsonWithTotal([makeSession({ authenticated: false })]), - ), - ); - renderTable(); - await waitFor(() => { - expect(screen.getByTitle("Not authenticated")).toBeInTheDocument(); - }); + it("renders warning icon for unauthenticated sessions", async () => { + server.use( + http.get("*/api/sessions", () => + jsonWithTotal([makeSession({ authenticated: false })]), + ), + ); + renderTable(); + await waitFor(() => { + expect(screen.getByTitle("Not authenticated")).toBeInTheDocument(); }); }); - describe("session data rendering", () => { - it("renders session username", async () => { - server.use( - http.get("*/api/sessions", () => - jsonWithTotal([makeSession({ username: "admin" })]), - ), - ); - renderTable(); - await waitFor(() => { - expect(screen.getByText("admin")).toBeInTheDocument(); - }); - }); - - it("renders session type badge", async () => { - server.use( - http.get("*/api/sessions", () => - jsonWithTotal([ - makeSession({ events: { types: ["shell"], seats: [] } }), - ]), - ), - ); - renderTable(); - await waitFor(() => { - expect(screen.getByText("shell")).toBeInTheDocument(); - }); - }); - - it("renders device name", async () => { - server.use( - http.get("*/api/sessions", () => jsonWithTotal([makeSession()])), - ); - renderTable(); - await waitFor(() => { - expect(screen.getByText("my-device")).toBeInTheDocument(); - }); + it("renders session type badge", async () => { + server.use( + http.get("*/api/sessions", () => + jsonWithTotal([ + makeSession({ events: { types: ["shell"], seats: [] } }), + ]), + ), + ); + renderTable(); + await waitFor(() => { + expect(screen.getByText("shell")).toBeInTheDocument(); }); }); }); diff --git a/ui/apps/console/src/components/vault/__tests__/VaultSettingsSection.test.tsx b/ui/apps/console/src/components/vault/__tests__/VaultSettingsSection.test.tsx index 05a3842086b..282def95fef 100644 --- a/ui/apps/console/src/components/vault/__tests__/VaultSettingsSection.test.tsx +++ b/ui/apps/console/src/components/vault/__tests__/VaultSettingsSection.test.tsx @@ -85,19 +85,14 @@ beforeEach(() => { }); describe("VaultSettingsSection", () => { - describe("when vault is not unlocked", () => { - it("renders nothing when locked", () => { - useVaultStore.setState({ status: "locked" }); + it.each(["locked", "uninitialized"] as const)( + "renders nothing when %s", + (status) => { + useVaultStore.setState({ status }); const { container } = renderSection(); expect(container).toBeEmptyDOMElement(); - }); - - it("renders nothing when uninitialized", () => { - useVaultStore.setState({ status: "uninitialized" }); - const { container } = renderSection(); - expect(container).toBeEmptyDOMElement(); - }); - }); + }, + ); describe("Auto-lock timeout menu", () => { it("renders all 5 timeout options when opened", async () => { @@ -119,7 +114,13 @@ describe("VaultSettingsSection", () => { ]); }); - it("calls updateAutoLockSettings with correct timeout when an option is selected", async () => { + it.each([ + ["30 minutes", 30], + [/never/i, 0], + ] as const)("selecting %s persists a timeout of %i", async ( + option, + expected, + ) => { setUnlocked({ autoLockTimeoutMinutes: 15 }); const updateAutoLockSettings = vi.fn(); useVaultStore.setState({ updateAutoLockSettings }); @@ -129,126 +130,58 @@ describe("VaultSettingsSection", () => { await userEvent.click( screen.getByRole("button", { name: /auto-lock timeout/i }), ); - await userEvent.click( - screen.getByRole("menuitem", { name: "30 minutes" }), - ); + await userEvent.click(screen.getByRole("menuitem", { name: option })); expect(updateAutoLockSettings).toHaveBeenCalledWith({ - autoLockTimeoutMinutes: 30, + autoLockTimeoutMinutes: expected, }); }); - it("calls updateAutoLockSettings with 0 when Never is selected", async () => { - setUnlocked({ autoLockTimeoutMinutes: 15 }); - const updateAutoLockSettings = vi.fn(); - useVaultStore.setState({ updateAutoLockSettings }); - - renderSection(); - - await userEvent.click( - screen.getByRole("button", { name: /auto-lock timeout/i }), - ); - await userEvent.click(screen.getByRole("menuitem", { name: /never/i })); - - expect(updateAutoLockSettings).toHaveBeenCalledWith({ - autoLockTimeoutMinutes: 0, - }); - }); - - it("reflects the persisted timeout value on first paint (15 min)", () => { - setUnlocked({ autoLockTimeoutMinutes: 15 }); + it.each([ + [15, "15 minutes"], + [0, "Never"], + ])("a persisted timeout of %i paints as '%s'", (minutes, label) => { + setUnlocked({ autoLockTimeoutMinutes: minutes }); renderSection(); expect( screen.getByRole("button", { name: /auto-lock timeout/i }), - ).toHaveTextContent("15 minutes"); - }); - - it("reflects the persisted timeout value on first paint (Never)", () => { - setUnlocked({ autoLockTimeoutMinutes: 0 }); - renderSection(); - - expect( - screen.getByRole("button", { name: /auto-lock timeout/i }), - ).toHaveTextContent("Never"); + ).toHaveTextContent(label); }); }); describe("Lock-when-tab-hidden checkbox", () => { - it("renders the lock-when-hidden checkbox with a description", () => { - setUnlocked(); - renderSection(); - - const checkbox = screen.getByRole("checkbox", { - name: /lock when hidden/i, - }); - expect(checkbox).toBeInTheDocument(); - }); + function hiddenCheckbox() { + return screen.getByRole("checkbox", { name: /lock when hidden/i }); + } - it("renders the description text about switching away", () => { - setUnlocked(); - renderSection(); + it.each([false, true])( + "toggling from lockOnHidden=%s persists the opposite", + async (lockOnHidden) => { + setUnlocked({ lockOnHidden }); + const updateAutoLockSettings = vi.fn(); + useVaultStore.setState({ updateAutoLockSettings }); - expect( - screen.getByText( - /locks the vault about a minute after you switch away or minimize/i, - ), - ).toBeInTheDocument(); - }); - - it("calls updateAutoLockSettings with lockOnHidden:true when checked", async () => { - setUnlocked({ lockOnHidden: false }); - const updateAutoLockSettings = vi.fn(); - useVaultStore.setState({ updateAutoLockSettings }); - - renderSection(); - - const checkbox = screen.getByRole("checkbox", { - name: /lock when hidden/i, - }); - await userEvent.click(checkbox); - - expect(updateAutoLockSettings).toHaveBeenCalledWith({ - lockOnHidden: true, - }); - }); - - it("calls updateAutoLockSettings with lockOnHidden:false when unchecked", async () => { - setUnlocked({ lockOnHidden: true }); - const updateAutoLockSettings = vi.fn(); - useVaultStore.setState({ updateAutoLockSettings }); - - renderSection(); - - const checkbox = screen.getByRole("checkbox", { - name: /lock when hidden/i, - }); - await userEvent.click(checkbox); - - expect(updateAutoLockSettings).toHaveBeenCalledWith({ - lockOnHidden: false, - }); - }); + renderSection(); - it("reflects persisted lockOnHidden=true on first paint (checked)", () => { - setUnlocked({ lockOnHidden: true }); - renderSection(); + await userEvent.click(hiddenCheckbox()); - const checkbox = screen.getByRole("checkbox", { - name: /lock when hidden/i, - }); - expect(checkbox).toBeChecked(); - }); + expect(updateAutoLockSettings).toHaveBeenCalledWith({ + lockOnHidden: !lockOnHidden, + }); + }, + ); - it("reflects persisted lockOnHidden=false on first paint (unchecked)", () => { - setUnlocked({ lockOnHidden: false }); - renderSection(); + it.each([true, false])( + "a persisted lockOnHidden=%s paints the checkbox accordingly", + (lockOnHidden) => { + setUnlocked({ lockOnHidden }); + renderSection(); - const checkbox = screen.getByRole("checkbox", { - name: /lock when hidden/i, - }); - expect(checkbox).not.toBeChecked(); - }); + if (lockOnHidden) expect(hiddenCheckbox()).toBeChecked(); + else expect(hiddenCheckbox()).not.toBeChecked(); + }, + ); }); describe("Change master password", () => { @@ -304,205 +237,60 @@ describe("VaultSettingsSection", () => { }); describe("Storage row", () => { - it("is visible when isVaultServerEnabled() returns true", () => { - vi.mocked(isVaultServerEnabled).mockReturnValue(true); - useVaultStore.setState({ storageMode: "local" }); - setUnlocked(); - renderSection(); - - expect(screen.getByText(/^storage$/i)).toBeInTheDocument(); - }); - - it("is absent when isVaultServerEnabled() returns false", () => { - vi.mocked(isVaultServerEnabled).mockReturnValue(false); - useVaultStore.setState({ storageMode: "local" }); - setUnlocked(); - renderSection(); - - expect(screen.queryByText(/^storage$/i)).not.toBeInTheDocument(); - }); - }); - - describe("SettingsCard layout (unit 5)", () => { - describe("Vault Settings card", () => { - it("renders a heading 'Vault Settings'", () => { - setUnlocked(); - renderSection(); - expect( - screen.getByRole("heading", { name: "Vault Settings" }), - ).toBeInTheDocument(); - }); - - it("renders row title 'Change Master Password'", () => { - setUnlocked(); - renderSection(); - expect( - screen.getAllByText("Change Master Password").length, - ).toBeGreaterThanOrEqual(1); - }); - - it("renders row description for Change Master Password", () => { - setUnlocked(); - renderSection(); - expect( - screen.getByText("Re-encrypt all keys with a new password."), - ).toBeInTheDocument(); - }); - - it("renders a 'Change' button with aria-label 'Change master password'", () => { - setUnlocked(); - renderSection(); - const btn = screen.getByRole("button", { - name: "Change master password", - }); - expect(btn).toHaveTextContent("Change"); - }); - - it("renders row title 'Auto-lock Timeout'", () => { - setUnlocked(); - renderSection(); - expect(screen.getByText("Auto-lock Timeout")).toBeInTheDocument(); - }); - - it("renders row description for Auto-lock Timeout", () => { - setUnlocked(); - renderSection(); - expect( - screen.getByText( - "Automatically lock the vault after this period of inactivity.", - ), - ).toBeInTheDocument(); - }); - - it("renders row title 'Lock when hidden'", () => { - setUnlocked(); - renderSection(); - expect( - screen.getAllByText("Lock when hidden").length, - ).toBeGreaterThanOrEqual(1); - }); - - it("renders a 'Lock' button with aria-label 'Lock vault'", () => { - setUnlocked(); - renderSection(); - const btn = screen.getByRole("button", { name: "Lock vault" }); - expect(btn).toHaveTextContent("Lock"); - }); - - it("renders row title 'Lock Vault'", () => { - setUnlocked(); - renderSection(); - expect(screen.getByText("Lock Vault")).toBeInTheDocument(); - }); - - it("renders row description for Lock Vault", () => { - setUnlocked(); - renderSection(); - expect( - screen.getByText("Clear decrypted keys from memory."), - ).toBeInTheDocument(); - }); - }); - - describe("Storage row (unit 5)", () => { - it("renders 'Move' button label when storageMode is server", () => { - vi.mocked(isVaultServerEnabled).mockReturnValue(true); - useVaultStore.setState({ storageMode: "server" }); - setUnlocked(); - renderSection(); - - const btn = screen.getByRole("button", { - name: "Change vault storage location", - }); - expect(btn).toHaveTextContent("Move"); - }); - - it("renders 'Sync' button label when storageMode is local", () => { - vi.mocked(isVaultServerEnabled).mockReturnValue(true); + it.each([true, false])( + "isVaultServerEnabled()=%s decides whether the row renders", + (enabled) => { + vi.mocked(isVaultServerEnabled).mockReturnValue(enabled); useVaultStore.setState({ storageMode: "local" }); setUnlocked(); renderSection(); - const btn = screen.getByRole("button", { - name: "Change vault storage location", - }); - expect(btn).toHaveTextContent("Sync"); - }); - - it("renders description for server storageMode", () => { - vi.mocked(isVaultServerEnabled).mockReturnValue(true); - useVaultStore.setState({ storageMode: "server" }); - setUnlocked(); - renderSection(); - - expect( - screen.getByText( - "Synced with the ShellHub server. Click to move it to this device.", - ), - ).toBeInTheDocument(); - }); - - it("renders description for local storageMode", () => { - vi.mocked(isVaultServerEnabled).mockReturnValue(true); - useVaultStore.setState({ storageMode: "local" }); - setUnlocked(); - renderSection(); - - expect( - screen.getByText( - "Stored in this browser only. Click to sync it to the ShellHub server.", - ), - ).toBeInTheDocument(); - }); + const row = screen.queryByText(/^storage$/i); + if (enabled) expect(row).toBeInTheDocument(); + else expect(row).not.toBeInTheDocument(); + }, + ); + }); - it("opens VaultSyncDialog when storage button is clicked", async () => { - vi.mocked(isVaultServerEnabled).mockReturnValue(true); - useVaultStore.setState({ storageMode: "local" }); - setUnlocked(); - renderSection(); + describe("Storage row copy", () => { + beforeEach(() => { + vi.mocked(isVaultServerEnabled).mockReturnValue(true); + }); - const btn = screen.getByRole("button", { - name: "Change vault storage location", - }); - await userEvent.click(btn); + it.each([ + [ + "server", + "Move", + "Synced with the ShellHub server. Click to move it to this device.", + ], + [ + "local", + "Sync", + "Stored in this browser only. Click to sync it to the ShellHub server.", + ], + ] as const)("a %s vault offers '%s'", (storageMode, action, copy) => { + useVaultStore.setState({ storageMode }); + setUnlocked(); + renderSection(); - expect( - screen.getByRole("heading", { name: /sync/i }), - ).toBeInTheDocument(); - }); + expect( + screen.getByRole("button", { name: "Change vault storage location" }), + ).toHaveTextContent(action); + expect(screen.getByText(copy)).toBeInTheDocument(); }); - describe("Danger Zone card (unit 5)", () => { - it("renders a heading 'Danger Zone'", () => { - setUnlocked(); - renderSection(); - expect( - screen.getByRole("heading", { name: "Danger Zone" }), - ).toBeInTheDocument(); - }); - - it("renders row title 'Reset Vault'", () => { - setUnlocked(); - renderSection(); - expect(screen.getByText("Reset Vault")).toBeInTheDocument(); - }); + it("opens VaultSyncDialog when storage button is clicked", async () => { + useVaultStore.setState({ storageMode: "local" }); + setUnlocked(); + renderSection(); - it("renders row description for Reset Vault", () => { - setUnlocked(); - renderSection(); - expect( - screen.getByText( - "Permanently delete all stored keys. This cannot be undone.", - ), - ).toBeInTheDocument(); - }); + await userEvent.click( + screen.getByRole("button", { name: "Change vault storage location" }), + ); - it("renders a 'Reset' button with aria-label 'Reset vault'", () => { - setUnlocked(); - renderSection(); - const btn = screen.getByRole("button", { name: "Reset vault" }); - expect(btn).toHaveTextContent("Reset"); - }); + expect( + screen.getByRole("heading", { name: /sync/i }), + ).toBeInTheDocument(); }); }); }); diff --git a/ui/apps/console/src/components/wizard/__tests__/WelcomeWizard.test.tsx b/ui/apps/console/src/components/wizard/__tests__/WelcomeWizard.test.tsx index 727c3c7f459..97ea8e8d26f 100644 --- a/ui/apps/console/src/components/wizard/__tests__/WelcomeWizard.test.tsx +++ b/ui/apps/console/src/components/wizard/__tests__/WelcomeWizard.test.tsx @@ -88,15 +88,6 @@ describe("WelcomeWizard", () => { }); }); - describe("when open=true", () => { - it("renders the dialog with the accessible label", () => { - renderWizard(); - expect( - screen.getByRole("dialog", { name: /welcome to shellhub/i }), - ).toBeInTheDocument(); - }); - }); - describe("Step 1 (install)", () => { it("opens on the install step, at step 1 of 2", () => { renderWizard(); @@ -106,14 +97,6 @@ describe("WelcomeWizard", () => { expect(screen.getByTestId("step-install")).toBeInTheDocument(); }); - it("has an enabled 'Next' and a 'Skip'", () => { - renderWizard(); - expect(screen.getByRole("button", { name: /next/i })).toBeEnabled(); - expect( - screen.getByRole("button", { name: /^skip$/i }), - ).toBeInTheDocument(); - }); - it("clicking 'Skip' dismisses for good, not just closes", async () => { const user = userEvent.setup(); const { onClose, onDismiss } = renderWizard(); @@ -203,16 +186,6 @@ describe("WelcomeWizard", () => { return { user, ...result }; } - it("shows 'Finish' and keeps the X close affordance", async () => { - await goToFinal(); - expect( - screen.getByRole("button", { name: /finish/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /close wizard/i }), - ).toBeInTheDocument(); - }); - it("clicking 'Finish' dismisses for good", async () => { const { user, onClose, onDismiss } = await goToFinal(); diff --git a/ui/apps/console/src/components/wizard/__tests__/WelcomeWizardTrigger.test.tsx b/ui/apps/console/src/components/wizard/__tests__/WelcomeWizardTrigger.test.tsx index cf12b02b29d..a684e76cfa0 100644 --- a/ui/apps/console/src/components/wizard/__tests__/WelcomeWizardTrigger.test.tsx +++ b/ui/apps/console/src/components/wizard/__tests__/WelcomeWizardTrigger.test.tsx @@ -78,9 +78,12 @@ describe("WelcomeWizardTrigger", () => { expect(await screen.findByTestId("welcome-wizard")).toBeInTheDocument(); }); - it("does not call markWelcomeSeen when merely closed", async () => { + it("hides the wizard when closed without marking it seen", async () => { renderTrigger(); (await screen.findByTestId("welcome-wizard")).click(); + await waitFor(() => { + expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); + }); expect(mockMarkWelcomeSeen).not.toHaveBeenCalled(); }); @@ -92,14 +95,6 @@ describe("WelcomeWizardTrigger", () => { }); }); - it("hides the wizard after it is closed", async () => { - renderTrigger(); - (await screen.findByTestId("welcome-wizard")).click(); - await waitFor(() => { - expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); - }); - }); - it("refetches stats when wizard is closed", async () => { let callCount = 0; server.use( @@ -118,7 +113,7 @@ describe("WelcomeWizardTrigger", () => { }); describe("when tenant has devices", () => { - it("does not show the wizard when there are registered devices", async () => { + it("neither shows the wizard nor marks it seen when devices are registered", async () => { server.use( http.get("*/api/stats", () => HttpResponse.json(mockStats({ registered_devices: 1 })), @@ -128,39 +123,21 @@ describe("WelcomeWizardTrigger", () => { await waitFor(() => { expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); }); + expect(mockMarkWelcomeSeen).not.toHaveBeenCalled(); }); - it("still shows the wizard when a device is only pending (not accepted)", async () => { - server.use( - http.get("*/api/stats", () => - HttpResponse.json(mockStats({ pending_devices: 2 })), - ), - ); - renderTrigger(); - expect(await screen.findByTestId("welcome-wizard")).toBeInTheDocument(); - }); - - it("still shows the wizard when a device is only rejected (not accepted)", async () => { - server.use( - http.get("*/api/stats", () => - HttpResponse.json(mockStats({ rejected_devices: 1 })), - ), - ); - renderTrigger(); - expect(await screen.findByTestId("welcome-wizard")).toBeInTheDocument(); - }); - - it("does not call markWelcomeSeen when there are devices", async () => { - server.use( - http.get("*/api/stats", () => - HttpResponse.json(mockStats({ registered_devices: 5 })), - ), - ); - renderTrigger(); - await waitFor(() => { - expect(mockMarkWelcomeSeen).not.toHaveBeenCalled(); - }); - }); + it.each(["pending_devices", "rejected_devices"] as const)( + "still shows the wizard when a device is only counted in %s", + async (field) => { + server.use( + http.get("*/api/stats", () => + HttpResponse.json(mockStats({ [field]: 1 })), + ), + ); + renderTrigger(); + expect(await screen.findByTestId("welcome-wizard")).toBeInTheDocument(); + }, + ); }); describe("eligibility is decided once, at page load", () => { diff --git a/ui/apps/console/src/hooks/__tests__/useNamespaceCreateForm.test.tsx b/ui/apps/console/src/hooks/__tests__/useNamespaceCreateForm.test.tsx new file mode 100644 index 00000000000..bd095a99753 --- /dev/null +++ b/ui/apps/console/src/hooks/__tests__/useNamespaceCreateForm.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; +import { createTestWrapper } from "@/tests/wrapper"; +import { mockNamespace, mockUserAuth } from "@/tests/factories"; +import { useNamespaceCreateForm } from "@/hooks/useNamespaceCreateForm"; + +const createSpy = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + server.use( + http.post("*/api/namespaces", async ({ request }) => { + createSpy(await request.json()); + return HttpResponse.json(mockNamespace({ name: "my-ns" })); + }), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json(mockUserAuth({ token: "jwt-token" })), + ), + ); +}); + +function Harness({ onCreated }: { onCreated?: () => void }) { + const form = useNamespaceCreateForm(onCreated); + + return ( +
void form.submit(e)}> + form.changeName(e.target.value)} + /> + {form.error &&

{form.error}

} + + + ); +} + +function renderForm(onCreated?: () => void) { + render(, { wrapper: createTestWrapper() }); + return { + nameInput: () => screen.getByLabelText("Namespace Name"), + create: () => screen.getByRole("button", { name: "Create" }), + }; +} + +async function submitName(name: string) { + const user = userEvent.setup(); + const form = renderForm(); + await user.type(form.nameInput(), name); + await user.click(form.create()); + return user; +} + +describe("useNamespaceCreateForm", () => { + it.each([ + [409, "A namespace with this name already exists."], + [403, "You have reached the namespace limit or do not have permission."], + [400, "The namespace name is invalid."], + [500, "An unexpected error occurred. Please try again."], + ])("reports %s as '%s'", async (status, message) => { + server.use( + http.post("*/api/namespaces", () => HttpResponse.json({}, { status })), + ); + + await submitName("my-ns"); + + expect(await screen.findByRole("alert")).toHaveTextContent(message); + }); + + it("reports a network failure with the generic message", async () => { + server.use(http.post("*/api/namespaces", () => HttpResponse.error())); + + await submitName("my-ns"); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "An unexpected error occurred. Please try again.", + ); + }); + + it("rejects an invalid name without sending a request", async () => { + await submitName("ab"); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Name must be at least 3 characters", + ); + expect(createSpy).not.toHaveBeenCalled(); + }); + + it("clears the error once the name changes again", async () => { + server.use( + http.post("*/api/namespaces", () => HttpResponse.json({}, { status: 409 })), + ); + + const user = await submitName("my-ns"); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + + await user.type(screen.getByLabelText("Namespace Name"), "x"); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("sends the typed name and runs onCreated once the namespace exists", async () => { + const onCreated = vi.fn(); + const user = userEvent.setup(); + const form = renderForm(onCreated); + + await user.type(form.nameInput(), "my-ns"); + await user.click(form.create()); + + await waitFor(() => expect(onCreated).toHaveBeenCalledOnce()); + expect(createSpy).toHaveBeenCalledWith({ name: "my-ns" }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/apps/console/src/hooks/__tests__/useTableSort.test.ts b/ui/apps/console/src/hooks/__tests__/useTableSort.test.ts new file mode 100644 index 00000000000..861719dfa63 --- /dev/null +++ b/ui/apps/console/src/hooks/__tests__/useTableSort.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useTableSort } from "@/hooks/useTableSort"; + +function renderTableSort(onSortChange?: () => void) { + return renderHook(() => + useTableSort<"name" | "last_seen">({ + defaultField: "last_seen", + onSortChange, + }), + ); +} + +describe("useTableSort", () => { + it("starts on the default field, descending", () => { + const { result } = renderTableSort(); + + expect(result.current.sortBy).toBe("last_seen"); + expect(result.current.orderBy).toBe("desc"); + }); + + it("flips the direction when the sorted column is chosen again", () => { + const { result } = renderTableSort(); + + act(() => result.current.handleSort("last_seen")); + expect(result.current.orderBy).toBe("asc"); + + act(() => result.current.handleSort("last_seen")); + expect(result.current.orderBy).toBe("desc"); + }); + + it("switching column restarts the direction — name ascending, anything else descending", () => { + const { result } = renderTableSort(); + + act(() => result.current.handleSort("name")); + expect(result.current.sortBy).toBe("name"); + expect(result.current.orderBy).toBe("asc"); + + act(() => result.current.handleSort("last_seen")); + expect(result.current.sortBy).toBe("last_seen"); + expect(result.current.orderBy).toBe("desc"); + }); + + it("reports every sort change so the caller can go back to page one", () => { + const onSortChange = vi.fn(); + const { result } = renderTableSort(onSortChange); + + act(() => result.current.handleSort("name")); + act(() => result.current.handleSort("name")); + + expect(onSortChange).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/apps/console/src/hooks/useNamespaceCreateForm.ts b/ui/apps/console/src/hooks/useNamespaceCreateForm.ts new file mode 100644 index 00000000000..ede3793ca87 --- /dev/null +++ b/ui/apps/console/src/hooks/useNamespaceCreateForm.ts @@ -0,0 +1,58 @@ +import { useState, type FormEvent } from "react"; +import { isSdkError } from "@/api/errors"; +import { validateNamespaceName } from "@/utils/validation"; +import { useCreateNamespace } from "@/hooks/useNamespaceMutations"; + +const GENERIC_ERROR = "An unexpected error occurred. Please try again."; + +const SUBMIT_ERRORS: Record = { + 400: "The namespace name is invalid.", + 403: "You have reached the namespace limit or do not have permission.", + 409: "A namespace with this name already exists.", +}; + +/** + * Drives the namespace name form shared by the create-namespace screen and dialog: it validates + * before the request, keeps the field and its error in step, and turns a rejected creation into + * text keyed by status. `onCreated` runs only after a successful creation. + */ +export function useNamespaceCreateForm(onCreated?: () => void) { + const [name, setName] = useState(""); + const [validationError, setValidationError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const createNs = useCreateNamespace(); + + const changeName = (value: string) => { + setName(value); + setValidationError(null); + setSubmitError(null); + createNs.reset(); + }; + + const submit = async (e: FormEvent) => { + e.preventDefault(); + const invalid = validateNamespaceName(name); + if (invalid) { + setValidationError(invalid); + return; + } + setValidationError(null); + setSubmitError(null); + try { + await createNs.mutateAsync(name); + onCreated?.(); + } catch (caught) { + setSubmitError( + (isSdkError(caught) && SUBMIT_ERRORS[caught.status]) || GENERIC_ERROR, + ); + } + }; + + return { + name, + changeName, + submit, + error: validationError ?? submitError, + isPending: createNs.isPending, + }; +} diff --git a/ui/apps/console/src/pages/__tests__/AcceptDevice.test.tsx b/ui/apps/console/src/pages/__tests__/AcceptDevice.test.tsx index 25d781cbe74..6094b3b6114 100644 --- a/ui/apps/console/src/pages/__tests__/AcceptDevice.test.tsx +++ b/ui/apps/console/src/pages/__tests__/AcceptDevice.test.tsx @@ -126,19 +126,6 @@ describe("AcceptDeviceFlow standalone", () => { expect(screen.getByText(/choose where it belongs/i)).toBeInTheDocument(); }); - it("shows error state on invalid/expired code", async () => { - server.use( - http.get("*/api/devices/login-code/:code", () => - HttpResponse.json({}, { status: 404 }), - ), - ); - renderFlow({ initialCode: "BADCODE1" }); - - expect( - await screen.findByRole("heading", { name: /invalid or expired code/i }), - ).toBeInTheDocument(); - }); - it("shows already-accepted state", async () => { setResolveCode(mockDevice({ status: "accepted" })); renderFlow(); @@ -176,28 +163,6 @@ describe("AcceptDeviceFlow standalone", () => { ).toBeInTheDocument(); }); - it("shows dashboard link in missing-code state", () => { - renderFlow({ initialCode: "" }); - - expect( - screen.getByRole("link", { name: /go to dashboard/i }), - ).toHaveAttribute("href", "/dashboard"); - }); - - it("shows dashboard link in error state", async () => { - server.use( - http.get("*/api/devices/login-code/:code", () => - HttpResponse.json({}, { status: 404 }), - ), - ); - renderFlow({ initialCode: "BADCODE1" }); - - await screen.findByRole("heading", { name: /invalid or expired code/i }); - expect( - screen.getByRole("link", { name: /go to dashboard/i }), - ).toHaveAttribute("href", "/dashboard"); - }); - it("resets to code form via 'Enter another code' on error", async () => { server.use( http.get("*/api/devices/login-code/:code", () => @@ -266,36 +231,41 @@ describe("AcceptDeviceFlow standalone", () => { }); }); -describe("AcceptDeviceFlow dialog mode", () => { - it("shows code entry form when no code provided", () => { - renderFlow({ initialCode: "", inDialog: true }); - - expect(screen.getByText("Claim a device")).toBeInTheDocument(); - expect(screen.getAllByRole("textbox")).toHaveLength(8); - }); - - it("does not show dashboard link in missing-code state", () => { - renderFlow({ initialCode: "", inDialog: true }); - - expect( - screen.queryByRole("link", { name: /go to dashboard/i }), - ).not.toBeInTheDocument(); - }); - - it("does not show dashboard link in error state", async () => { +describe("AcceptDeviceFlow dashboard link", () => { + async function renderDeadEnd( + state: "missing-code" | "error", + inDialog: boolean, + ) { + if (state === "missing-code") { + renderFlow({ initialCode: "", inDialog }); + return; + } server.use( http.get("*/api/devices/login-code/:code", () => HttpResponse.json({}, { status: 404 }), ), ); - renderFlow({ initialCode: "BADCODE1", inDialog: true }); - + renderFlow({ initialCode: "BADCODE1", inDialog }); await screen.findByRole("heading", { name: /invalid or expired code/i }); - expect( - screen.queryByRole("link", { name: /go to dashboard/i }), - ).not.toBeInTheDocument(); - }); + } + + it.each([ + ["missing-code", false, true], + ["error", false, true], + ["missing-code", true, false], + ["error", true, false], + ] as const)( + "%s state with inDialog=%s offers a dashboard link: %s", + async (state, inDialog, offered) => { + await renderDeadEnd(state, inDialog); + const link = screen.queryByRole("link", { name: /go to dashboard/i }); + if (offered) expect(link).toHaveAttribute("href", "/dashboard"); + else expect(link).not.toBeInTheDocument(); + }, + ); +}); +describe("AcceptDeviceFlow dialog mode", () => { it("resets to form via 'Use a different code' on ready state", async () => { renderFlow({ inDialog: true }); diff --git a/ui/apps/console/src/pages/__tests__/AcceptInvite.test.tsx b/ui/apps/console/src/pages/__tests__/AcceptInvite.test.tsx index c1719e539ea..aa3cab51fc7 100644 --- a/ui/apps/console/src/pages/__tests__/AcceptInvite.test.tsx +++ b/ui/apps/console/src/pages/__tests__/AcceptInvite.test.tsx @@ -142,19 +142,6 @@ describe("AcceptInvite", () => { }); }); - it("renders the Namespace Invitation heading with Accept", async () => { - renderPage(VALID_PARAMS); - expect( - await screen.findByRole("heading", { name: /namespace invitation/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /accept/i }), - ).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: /decline/i }), - ).not.toBeInTheDocument(); - }); - it("accepts and switches namespace using the resolved tenant", async () => { const user = userEvent.setup(); renderPage(VALID_PARAMS); @@ -207,14 +194,10 @@ describe("AcceptInvite", () => { await user.type(screen.getByLabelText(/confirm password/i), "Secret123"); } - it("renders the invite completion form with the resolved email", async () => { + it("shows the resolved email as text instead of an editable field", async () => { renderPage(VALID_PARAMS); - expect( - await screen.findByRole("heading", { name: /you've been invited/i }), - ).toBeInTheDocument(); + await screen.findByRole("heading", { name: /you've been invited/i }); expect(screen.getByText(/alice@example.com/)).toBeInTheDocument(); - expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/^username$/i)).toBeInTheDocument(); expect(screen.queryByLabelText(/^email$/i)).not.toBeInTheDocument(); }); diff --git a/ui/apps/console/src/pages/__tests__/Login.test.tsx b/ui/apps/console/src/pages/__tests__/Login.test.tsx index 8cdd7c1af5e..9e9b8bb104d 100644 --- a/ui/apps/console/src/pages/__tests__/Login.test.tsx +++ b/ui/apps/console/src/pages/__tests__/Login.test.tsx @@ -57,9 +57,7 @@ async function fillAndSubmit( function setLoginError(status: number, headers?: Record) { server.use( - http.post("*/api/login", () => - HttpResponse.json({}, { status, headers }), - ), + http.post("*/api/login", () => HttpResponse.json({}, { status, headers })), ); } @@ -100,27 +98,6 @@ describe("Login", () => { }); describe("form rendering", () => { - it("renders username and password fields with a submit button", () => { - renderLogin(); - expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /sign in/i }), - ).toBeInTheDocument(); - }); - - it("shows no error by default", () => { - renderLogin(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); - - it("trims username before submitting", async () => { - renderLogin(); - await fillAndSubmit(" admin ", "secret"); - - expect(mockNavigate).toHaveBeenCalledWith("/dashboard"); - }); - it("shows a field error on the username field after blur when empty", async () => { const user = userEvent.setup(); renderLogin(); @@ -165,49 +142,14 @@ describe("Login", () => { }); describe("loading state", () => { - it("shows Authenticating... and disables the button while the request is in flight", async () => { - let resolveHandler!: () => void; - server.use( - http.post( - "*/api/login", - () => - new Promise((resolve) => { - resolveHandler = () => - resolve(HttpResponse.json(mockUserAuth())); - }), - ), - ); - - renderLogin(); - await userEvent.type(screen.getByLabelText(/username/i), "admin"); - await userEvent.tab(); - await userEvent.type(screen.getByLabelText(/^password$/i), "secret"); - await userEvent.tab(); - - const clickPromise = userEvent.click( - screen.getByRole("button", { name: /sign in/i }), - ); - - await waitFor(() => - expect(screen.getByText(/authenticating/i)).toBeInTheDocument(), - ); - expect( - screen.getByRole("button", { name: /authenticating/i }), - ).toBeDisabled(); - - resolveHandler(); - await clickPromise; - }); - - it("marks the submit button aria-busy while the request is in flight (DS Button loading prop)", async () => { + it("shows Authenticating... and marks the button busy while the request is in flight", async () => { let resolveHandler!: () => void; server.use( http.post( "*/api/login", () => new Promise((resolve) => { - resolveHandler = () => - resolve(HttpResponse.json(mockUserAuth())); + resolveHandler = () => resolve(HttpResponse.json(mockUserAuth())); }), ), ); @@ -225,10 +167,9 @@ describe("Login", () => { await waitFor(() => expect(screen.getByText(/authenticating/i)).toBeInTheDocument(), ); - - expect( - screen.getByRole("button", { name: /authenticating/i }), - ).toHaveAttribute("aria-busy", "true"); + const button = screen.getByRole("button", { name: /authenticating/i }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-busy", "true"); resolveHandler(); await clickPromise; @@ -285,9 +226,7 @@ describe("Login", () => { }); it("shows generic error on network errors", async () => { - server.use( - http.post("*/api/login", () => HttpResponse.error()), - ); + server.use(http.post("*/api/login", () => HttpResponse.error())); renderLogin(); await fillAndSubmit(); diff --git a/ui/apps/console/src/pages/__tests__/MfaLogin.test.tsx b/ui/apps/console/src/pages/__tests__/MfaLogin.test.tsx index 4bf2fccb9e7..5e4d61fd8a8 100644 --- a/ui/apps/console/src/pages/__tests__/MfaLogin.test.tsx +++ b/ui/apps/console/src/pages/__tests__/MfaLogin.test.tsx @@ -102,18 +102,16 @@ describe("MfaLogin", () => { expect(recoveryLink).toHaveAttribute("href", "/mfa-recover"); }); - it("disables submit button when code is incomplete", () => { + it.each([ + [3, true], + [6, false], + ])("a %i-digit code leaves submit disabled: %s", (digits, disabled) => { renderMfaLogin(); - fillCode(3); + fillCode(digits); - expect(screen.getByRole("button", { name: /verify/i })).toBeDisabled(); - }); - - it("enables submit button when code is complete", () => { - renderMfaLogin(); - fillCode(); - - expect(screen.getByRole("button", { name: /verify/i })).not.toBeDisabled(); + const verify = screen.getByRole("button", { name: /verify/i }); + if (disabled) expect(verify).toBeDisabled(); + else expect(verify).not.toBeDisabled(); }); it("shows loading state during submission", () => { diff --git a/ui/apps/console/src/pages/__tests__/MfaRecover.test.tsx b/ui/apps/console/src/pages/__tests__/MfaRecover.test.tsx index 018ba09433d..12ce75156e6 100644 --- a/ui/apps/console/src/pages/__tests__/MfaRecover.test.tsx +++ b/ui/apps/console/src/pages/__tests__/MfaRecover.test.tsx @@ -38,13 +38,6 @@ beforeEach(() => { }); describe("MfaRecover", () => { - it("renders recovery form", () => { - renderRecover(); - - expect(screen.getByText(/Account Recovery/i)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(/recovery code/i)).toBeInTheDocument(); - }); - it("submits recovery code successfully", async () => { const mockRecover = vi.fn().mockResolvedValue(undefined); useAuthStore.setState({ recoverWithCode: mockRecover }); @@ -139,12 +132,6 @@ describe("MfaRecover", () => { ).toBeInTheDocument(); }); - it("shows 10-minute recovery window warning note", () => { - renderRecover(); - - expect(screen.getByText(/10-minute window/i)).toBeInTheDocument(); - }); - it("redirects to login when no identifier and no mfaToken", async () => { useAuthStore.setState({ user: null, @@ -187,14 +174,6 @@ describe("MfaRecover", () => { }); }); - it("does not show recovery timeout modal initially", () => { - renderRecover(); - - expect( - screen.queryByText(/Recovery Window Active/i), - ).not.toBeInTheDocument(); - }); - it("clears the recovery code field after a failed submission", async () => { const mockRecover = vi.fn().mockRejectedValue(new Error("Invalid")); useAuthStore.setState({ recoverWithCode: mockRecover }); diff --git a/ui/apps/console/src/pages/__tests__/Profile.test.tsx b/ui/apps/console/src/pages/__tests__/Profile.test.tsx index e468c7063fb..4b098856910 100644 --- a/ui/apps/console/src/pages/__tests__/Profile.test.tsx +++ b/ui/apps/console/src/pages/__tests__/Profile.test.tsx @@ -6,8 +6,6 @@ import { http, HttpResponse } from "msw"; import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import Profile from "../Profile"; -import * as SettingsCardModule from "@/components/common/SettingsCard"; -import * as SettingsRowModule from "@/components/common/SettingsRow"; import { getConfig, defaultConfig } from "@/env"; import { seedAuthStore } from "@/tests/seedAuthStore"; @@ -33,55 +31,11 @@ beforeEach(() => { }); describe("Profile", () => { - describe("shared component usage", () => { - it("uses the shared SettingsCard component (not a local copy)", () => { - const spy = vi.spyOn(SettingsCardModule, "default"); - renderProfile(); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it("uses the shared SettingsRow component (not a local copy)", () => { - const spy = vi.spyOn(SettingsRowModule, "default"); - renderProfile(); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - }); - describe("renders profile sections", () => { - it("shows the Profile card heading", () => { - renderProfile(); - const headings = screen.getAllByRole("heading", { name: /^profile$/i }); - expect(headings.length).toBeGreaterThanOrEqual(1); - }); - - it("shows the Security card heading", () => { - renderProfile(); - expect( - screen.getByRole("heading", { name: /^security$/i }), - ).toBeInTheDocument(); - }); - - it("shows the Danger Zone card heading", () => { - renderProfile(); - expect( - screen.getByRole("heading", { name: /danger zone/i }), - ).toBeInTheDocument(); - }); - - it("renders the user name in the Profile card", () => { + it("renders the signed-in user's details", () => { renderProfile(); expect(screen.getByText("Admin User")).toBeInTheDocument(); - }); - - it("renders the user email in the Profile card", () => { - renderProfile(); expect(screen.getByText("admin@test.com")).toBeInTheDocument(); - }); - - it("renders the recovery email in the Profile card", () => { - renderProfile(); expect( screen.getAllByText("recovery@test.com").length, ).toBeGreaterThanOrEqual(1); @@ -120,24 +74,6 @@ describe("Profile", () => { }); }); - describe("danger zone", () => { - it("renders the delete account button", () => { - renderProfile(); - expect( - screen.getByRole("button", { name: /delete/i }), - ).toBeInTheDocument(); - }); - }); - - describe("page header", () => { - it("renders the Edit Profile button in the header", () => { - renderProfile(); - expect( - screen.getByRole("button", { name: /edit profile/i }), - ).toBeInTheDocument(); - }); - }); - describe("ChangePasswordDrawer", () => { async function openChangePasswordDrawer() { const user = userEvent.setup(); diff --git a/ui/apps/console/src/pages/__tests__/Settings.test.tsx b/ui/apps/console/src/pages/__tests__/Settings.test.tsx index ac7913fe364..12847aee233 100644 --- a/ui/apps/console/src/pages/__tests__/Settings.test.tsx +++ b/ui/apps/console/src/pages/__tests__/Settings.test.tsx @@ -17,8 +17,6 @@ vi.mock("@/components/common/CopyButton", async () => ({ })); import Settings from "../Settings"; -import * as SettingsCardModule from "@/components/common/SettingsCard"; -import * as SettingsRowModule from "@/components/common/SettingsRow"; import { getConfig, defaultConfig } from "@/env"; const mockedGetConfig = vi.mocked(getConfig); @@ -94,63 +92,11 @@ beforeEach(() => { }); describe("Settings", () => { - describe("shared component usage", () => { - it("uses the shared SettingsCard component (not a local copy)", async () => { - const spy = vi.spyOn(SettingsCardModule, "default"); - renderSettings(); - await screen.findByRole("heading", { name: /^general$/i }); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it("uses the shared SettingsRow component (not a local copy)", async () => { - const spy = vi.spyOn(SettingsRowModule, "default"); - renderSettings(); - await screen.findByRole("heading", { name: /^general$/i }); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - }); - describe("renders settings sections", () => { - it("shows the General card heading", async () => { - renderSettings(); - expect( - await screen.findByRole("heading", { name: /^general$/i }), - ).toBeInTheDocument(); - }); - - it("shows the SSH card heading", async () => { - renderSettings(); - expect( - await screen.findByRole("heading", { name: /^ssh$/i }), - ).toBeInTheDocument(); - }); - - it("shows the Danger Zone card heading", async () => { - renderSettings(); - expect( - await screen.findByRole("heading", { name: /danger zone/i }), - ).toBeInTheDocument(); - }); - - it("renders the namespace name", async () => { + it("renders the namespace name and tenant ID", async () => { renderSettings(); expect(await screen.findByText("my-namespace")).toBeInTheDocument(); - }); - - it("renders the tenant ID", async () => { - renderSettings(); - expect(await screen.findByText("tenant-456")).toBeInTheDocument(); - }); - }); - - describe("danger zone", () => { - it("renders the Delete button for owners", async () => { - renderSettings(); - expect( - await screen.findByRole("button", { name: /delete/i }), - ).toBeInTheDocument(); + expect(screen.getByText("tenant-456")).toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/pages/__tests__/Setup.test.tsx b/ui/apps/console/src/pages/__tests__/Setup.test.tsx index 52393b9191f..31589325a56 100644 --- a/ui/apps/console/src/pages/__tests__/Setup.test.tsx +++ b/ui/apps/console/src/pages/__tests__/Setup.test.tsx @@ -17,9 +17,8 @@ const mockLoginWithToken = vi.hoisted(() => vi.fn()); vi.mock("@/stores/authStore", () => ({ useAuthStore: Object.assign( - ( - selector: (s: { loginWithToken: typeof mockLoginWithToken }) => unknown, - ) => selector({ loginWithToken: mockLoginWithToken }), + (selector: (s: { loginWithToken: typeof mockLoginWithToken }) => unknown) => + selector({ loginWithToken: mockLoginWithToken }), { getState: () => ({ token: null, @@ -53,75 +52,43 @@ beforeEach(() => { mockLoginWithToken.mockResolvedValue(undefined); mockGetConfig.mockReturnValue({ ...defaultConfig }); server.use( - http.post("*/api/setup", () => - HttpResponse.json({ token: "jwt-token" }), - ), + http.post("*/api/setup", () => HttpResponse.json({ token: "jwt-token" })), ); }); describe("Setup", () => { - describe("initial render", () => { - it("renders all account form fields and the submit button", () => { - renderSetup(); - expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/^username$/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/^confirm password$/i)).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /complete setup/i }), - ).toBeInTheDocument(); - }); - - it("submit button is disabled when all fields are empty", () => { - renderSetup(); - expect( - screen.getByRole("button", { name: /complete setup/i }), - ).toBeDisabled(); - }); - }); - describe("field validation — errors only after blur (onTouched mode)", () => { - it("does not show name error before the field is touched", () => { - renderSetup(); - expect(screen.queryByText(/name must be/i)).not.toBeInTheDocument(); - }); - - it("shows name error after blurring an empty name field", async () => { + it("shows name error only after blurring an empty name field", async () => { const user = userEvent.setup(); renderSetup(); + expect(screen.queryByText(/name must be/i)).not.toBeInTheDocument(); + await user.click(screen.getByLabelText(/^name$/i)); await user.tab(); expect(await screen.findByText(/name must be/i)).toBeInTheDocument(); }); - it("does not show username error before the field is touched", () => { - renderSetup(); - expect(screen.queryByText(/username must be/i)).not.toBeInTheDocument(); - }); - - it("shows username error after blurring with too-short value", async () => { + it("shows username error only after blurring with a too-short value", async () => { const user = userEvent.setup(); renderSetup(); + expect(screen.queryByText(/username must be/i)).not.toBeInTheDocument(); + await user.type(screen.getByLabelText(/^username$/i), "ab"); await user.tab(); expect(await screen.findByText(/username must be/i)).toBeInTheDocument(); }); - it("does not show email error before the field is touched", () => { + it("shows email error only after blurring with an invalid address", async () => { + const user = userEvent.setup(); renderSetup(); + expect( screen.queryByText(/enter a valid email/i), ).not.toBeInTheDocument(); - }); - - it("shows email error after blurring with an invalid address", async () => { - const user = userEvent.setup(); - renderSetup(); await user.type(screen.getByLabelText(/^email$/i), "not-an-email"); await user.tab(); @@ -131,15 +98,12 @@ describe("Setup", () => { ).toBeInTheDocument(); }); - it("does not show password error before the field is touched", () => { - renderSetup(); - expect(screen.queryByText(/password must be/i)).not.toBeInTheDocument(); - }); - - it("shows password error after blurring an empty password field", async () => { + it("shows password error only after blurring an empty password field", async () => { const user = userEvent.setup(); renderSetup(); + expect(screen.queryByText(/password must be/i)).not.toBeInTheDocument(); + await user.click(screen.getByLabelText(/^password$/i)); await user.tab(); @@ -178,7 +142,7 @@ describe("Setup", () => { }); describe("successful submission", () => { - it("shows the success screen after setup", async () => { + it("shows the success screen and logs in with the returned token", async () => { const user = userEvent.setup(); renderSetup(); @@ -186,15 +150,6 @@ describe("Setup", () => { await user.click(screen.getByRole("button", { name: /complete setup/i })); expect(await screen.findByText(/instance ready/i)).toBeInTheDocument(); - }); - - it("logs in with the returned token", async () => { - const user = userEvent.setup(); - renderSetup(); - - await fillValidForm(user); - await user.click(screen.getByRole("button", { name: /complete setup/i })); - await waitFor(() => expect(mockLoginWithToken).toHaveBeenCalledWith("jwt-token"), ); @@ -262,9 +217,7 @@ describe("Setup", () => { describe("error handling", () => { it("shows 'Setup has already been completed' on 409", async () => { server.use( - http.post("*/api/setup", () => - HttpResponse.json({}, { status: 409 }), - ), + http.post("*/api/setup", () => HttpResponse.json({}, { status: 409 })), ); const user = userEvent.setup(); renderSetup(); @@ -279,9 +232,7 @@ describe("Setup", () => { it("shows a generic error on unexpected server errors", async () => { server.use( - http.post("*/api/setup", () => - HttpResponse.json({}, { status: 500 }), - ), + http.post("*/api/setup", () => HttpResponse.json({}, { status: 500 })), ); const user = userEvent.setup(); renderSetup(); diff --git a/ui/apps/console/src/pages/__tests__/SignUp.test.tsx b/ui/apps/console/src/pages/__tests__/SignUp.test.tsx index ae7f62445f1..bb491830eb5 100644 --- a/ui/apps/console/src/pages/__tests__/SignUp.test.tsx +++ b/ui/apps/console/src/pages/__tests__/SignUp.test.tsx @@ -73,15 +73,6 @@ beforeEach(() => { }); describe("SignUp", () => { - describe("initial render", () => { - it("disables the submit button when all fields are empty", () => { - renderSignUp(); - - const submit = screen.getByRole("button", { name: /create account/i }); - expect(submit).toBeDisabled(); - }); - }); - describe("successful submission", () => { it("navigates to confirm-account after valid submission", async () => { const user = userEvent.setup(); @@ -99,15 +90,12 @@ describe("SignUp", () => { }); describe("field validation on blur", () => { - it("does not show a name error before the field is touched", () => { - renderSignUp(); - expect(screen.queryByText(/name must be/i)).not.toBeInTheDocument(); - }); - - it("shows name error after blur when field is empty", async () => { + it("shows name error only after blurring an empty field", async () => { const user = userEvent.setup(); renderSignUp(); + expect(screen.queryByText(/name must be/i)).not.toBeInTheDocument(); + const nameInput = screen.getByLabelText(/^name$/i); await user.click(nameInput); await user.tab(); // blur @@ -115,15 +103,12 @@ describe("SignUp", () => { expect(await screen.findByText(/name must be/i)).toBeInTheDocument(); }); - it("does not show username error before the field is touched", () => { - renderSignUp(); - expect(screen.queryByText(/username must be/i)).not.toBeInTheDocument(); - }); - - it("shows username error after blur when field is too short", async () => { + it("shows username error only after blurring a too-short field", async () => { const user = userEvent.setup(); renderSignUp(); + expect(screen.queryByText(/username must be/i)).not.toBeInTheDocument(); + await user.type(screen.getByLabelText(/^username$/i), "ab"); await user.tab(); @@ -136,6 +121,10 @@ describe("SignUp", () => { const user = userEvent.setup(); renderSignUp(); + expect( + screen.getByRole("button", { name: /create account/i }), + ).toBeDisabled(); + await user.type(screen.getByLabelText(/^name$/i), "Alice Smith"); await user.type(screen.getByLabelText(/^username$/i), "alice"); await user.type(screen.getByLabelText(/^email$/i), "alice@example.com"); @@ -151,11 +140,16 @@ describe("SignUp", () => { }); describe("password mismatch", () => { - it("shows 'Passwords do not match' when confirmPassword differs from password", async () => { + it("shows 'Passwords do not match' only once confirmPassword is touched", async () => { const user = userEvent.setup(); renderSignUp(); await user.type(screen.getByLabelText(/^password$/i), "Secret123"); + + expect( + screen.queryByText(/passwords do not match/i), + ).not.toBeInTheDocument(); + await user.type( screen.getByLabelText(/^confirm password$/i), "DifferentPass", @@ -166,17 +160,6 @@ describe("SignUp", () => { await screen.findByText(/passwords do not match/i), ).toBeInTheDocument(); }); - - it("does not show mismatch error before confirmPassword is touched", async () => { - const user = userEvent.setup(); - renderSignUp(); - - await user.type(screen.getByLabelText(/^password$/i), "Secret123"); - - expect( - screen.queryByText(/passwords do not match/i), - ).not.toBeInTheDocument(); - }); }); describe("server field errors", () => { diff --git a/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx b/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx index a35c4c0c64a..576563a7c8d 100644 --- a/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx +++ b/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx @@ -36,62 +36,43 @@ beforeEach(() => { describe("UpdatePassword", () => { describe("uid/token guard", () => { - it("renders an error card with a link when uid and token are missing", () => { - renderWithParams("?id=&token="); - - expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument(); - expect( - screen.getByRole("link", { name: /request a new reset link/i }), - ).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: /update password/i }), - ).not.toBeInTheDocument(); - }); - - it("renders an error card when only uid is missing", () => { - renderWithParams("?token=tok456"); - - expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument(); - }); - - it("renders an error card when only token is missing", () => { - renderWithParams("?id=uid123"); - - expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument(); - }); - }); - - describe("validation — no errors before blur", () => { - it("shows no password error on initial render", () => { - renderWithParams(); - - expect(screen.queryByText(/password must be/i)).not.toBeInTheDocument(); - }); - - it("shows no mismatch error on initial render", () => { - renderWithParams(); - - expect( - screen.queryByText(/passwords do not match/i), - ).not.toBeInTheDocument(); - }); + it.each(["?id=&token=", "?token=tok456", "?id=uid123"])( + "renders an error card with a link instead of the form for %s", + (search) => { + renderWithParams(search); + + expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /request a new reset link/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /update password/i }), + ).not.toBeInTheDocument(); + }, + ); }); describe("validation — errors appear after blur", () => { - it("shows a too-short error after the password field is blurred with a short value", async () => { + it("shows a too-short error only after the password field is blurred", async () => { const user = userEvent.setup(); renderWithParams(); + expect(screen.queryByText(/password must be/i)).not.toBeInTheDocument(); + await user.type(screen.getByLabelText(/^new password$/i), "abc"); await user.tab(); expect(await screen.findByText(/password must be/i)).toBeInTheDocument(); }); - it("shows a mismatch error after confirmPassword is blurred with a non-matching value", async () => { + it("shows a mismatch error only after confirmPassword is blurred", async () => { const user = userEvent.setup(); renderWithParams(); + expect( + screen.queryByText(/passwords do not match/i), + ).not.toBeInTheDocument(); + await user.type(screen.getByLabelText(/^new password$/i), "Secret123"); await user.type( screen.getByLabelText(/^confirm password$/i), @@ -106,17 +87,13 @@ describe("UpdatePassword", () => { }); describe("submit button gate", () => { - it("disables the submit button on initial render", () => { + it("enables the submit button only when both password fields contain valid, matching values", async () => { + const user = userEvent.setup(); renderWithParams(); expect( screen.getByRole("button", { name: /update password/i }), ).toBeDisabled(); - }); - - it("enables the submit button only when both password fields contain valid, matching values", async () => { - const user = userEvent.setup(); - renderWithParams(); await user.type(screen.getByLabelText(/^new password$/i), "Secret123"); await user.type( diff --git a/ui/apps/console/src/pages/__tests__/WebEndpoints.test.tsx b/ui/apps/console/src/pages/__tests__/WebEndpoints.test.tsx index a541d85c85a..989fbb477cb 100644 --- a/ui/apps/console/src/pages/__tests__/WebEndpoints.test.tsx +++ b/ui/apps/console/src/pages/__tests__/WebEndpoints.test.tsx @@ -58,16 +58,10 @@ beforeEach(() => { }); describe("WebEndpoints — pagination count / controls decoupling", () => { - it("shows the endpoint count when totalCount > 0 and only one page exists", async () => { + it("shows the count but no Prev/Next controls when only one page exists", async () => { setEndpoints([ep("ep1.example.com")], 1); renderPage(); expect(await screen.findByText(/1 endpoint/i)).toBeInTheDocument(); - }); - - it("hides Prev/Next controls when only one page exists", async () => { - setEndpoints([ep("ep1.example.com")], 1); - renderPage(); - await screen.findByText(/1 endpoint/i); expect( screen.queryByRole("button", { name: /previous page/i }), ).not.toBeInTheDocument(); @@ -102,9 +96,7 @@ describe("WebEndpoints — pagination count / controls decoupling", () => { it("does not flash a '0 endpoints' count while a search request is in-flight", () => { mockUseDebouncedValue.mockReturnValue("some-query"); - server.use( - http.get("*/api/web-endpoints", () => new Promise(() => {})), - ); + server.use(http.get("*/api/web-endpoints", () => new Promise(() => {}))); renderPage(); expect(screen.queryByText(/0 endpoints/i)).not.toBeInTheDocument(); expect( @@ -113,62 +105,6 @@ describe("WebEndpoints — pagination count / controls decoupling", () => { }); }); -describe("WebEndpoints — URL hydration", () => { - it("passes page=2 and a filter containing the search term from the URL", async () => { - setEndpoints([ep("ep1.example.com")], 1); - renderPage(["/?page=2&search=myhost"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); - const filter = lastRequestUrl!.searchParams.get("filter") ?? ""; - expect(atob(filter)).toContain("myhost"); - }); - }); - - it("falls back to page=1 and no filter when URL has no params", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - expect(lastRequestUrl!.searchParams.get("filter")).toBeNull(); - }); - }); -}); - -describe("WebEndpoints — search resets page to 1", () => { - it("resets to page=1 when a new search term is typed while on page 2", async () => { - const user = userEvent.setup(); - setEndpoints([ep("ep1.example.com")], 25); - renderPage(["/?page=2"]); - await screen.findByText(/25 endpoints/i); - - const searchInput = screen.getByPlaceholderText(/search by address/i); - await user.type(searchInput, "x"); - - await waitFor(() => { - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - }); -}); - -describe("WebEndpoints — page change writes to URL", () => { - it("calls listWebEndpoints with page=2 after clicking the Next page button", async () => { - const user = userEvent.setup(); - setEndpoints( - Array.from({ length: 10 }, (_, i) => ep(`ep${i + 1}.example.com`)), - 15, - ); - renderPage(["/"]); - await screen.findByText(/15 endpoints/i); - - await user.click(screen.getByRole("button", { name: /next page/i })); - - await waitFor(() => { - expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); - }); - }); -}); - async function openEndpointDrawer(user: ReturnType) { setEndpoints([ep("ep1.example.com")], 1); renderPage(); @@ -177,37 +113,19 @@ async function openEndpointDrawer(user: ReturnType) { ); } -describe("WebEndpoints — expiration toggle", () => { - it("exposes the expiration control as a switch with aria-checked and no aria-pressed", async () => { - const user = userEvent.setup(); - await openEndpointDrawer(user); - const toggle = screen.getByRole("switch", { name: /set expiration/i }); - expect(toggle).toHaveAttribute("aria-checked", "false"); - expect(toggle).not.toHaveAttribute("aria-pressed"); - }); +describe("WebEndpoints — drawer toggles", () => { + it.each([/set expiration/i, /uses https/i])( + "exposes %s as a switch whose aria-checked flips on click", + async (name) => { + const user = userEvent.setup(); + await openEndpointDrawer(user); + const toggle = screen.getByRole("switch", { name }); + expect(toggle).toHaveAttribute("aria-checked", "false"); + expect(toggle).not.toHaveAttribute("aria-pressed"); - it("flips aria-checked when the expiration switch is clicked", async () => { - const user = userEvent.setup(); - await openEndpointDrawer(user); - const toggle = screen.getByRole("switch", { name: /set expiration/i }); - await user.click(toggle); - expect(toggle).toHaveAttribute("aria-checked", "true"); - }); -}); + await user.click(toggle); -describe("WebEndpoints — TLS toggle", () => { - it("exposes the TLS control as a switch with aria-checked reflecting state", async () => { - const user = userEvent.setup(); - await openEndpointDrawer(user); - const toggle = screen.getByRole("switch", { name: /uses https/i }); - expect(toggle).toHaveAttribute("aria-checked", "false"); - }); - - it("calls the TLS handler with the toggled boolean when clicked", async () => { - const user = userEvent.setup(); - await openEndpointDrawer(user); - const toggle = screen.getByRole("switch", { name: /uses https/i }); - await user.click(toggle); - expect(toggle).toHaveAttribute("aria-checked", "true"); - }); + expect(toggle).toHaveAttribute("aria-checked", "true"); + }, + ); }); diff --git a/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx b/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx index 5e594596267..4ca21f404bb 100644 --- a/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx +++ b/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx @@ -48,22 +48,6 @@ describe("AdminDashboard", () => { renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); }); - - it("does not render page header while loading", () => { - server.use( - http.get("*/admin/api/stats", () => new Promise(() => {})), - ); - renderPage(); - expect(screen.queryByText("System Overview")).not.toBeInTheDocument(); - }); - - it("does not render stat cards while loading", () => { - server.use( - http.get("*/admin/api/stats", () => new Promise(() => {})), - ); - renderPage(); - expect(screen.queryByText("Registered Users")).not.toBeInTheDocument(); - }); }); describe("error state", () => { @@ -75,80 +59,15 @@ describe("AdminDashboard", () => { ); renderPage(); await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - }); - - it("displays the expected error message", async () => { - server.use( - http.get("*/admin/api/stats", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - await waitFor(() => { - expect( - screen.getByText("Failed to load dashboard statistics"), - ).toBeInTheDocument(); - }); - }); - - it("does not render stat cards on stats error", async () => { - server.use( - http.get("*/admin/api/stats", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent( + "Failed to load dashboard statistics", + ); }); expect(screen.queryByText("Registered Users")).not.toBeInTheDocument(); }); - - it("does not render sessions table on stats error", async () => { - server.use( - http.get("*/admin/api/stats", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - expect( - screen.queryByTestId("recent-sessions-table"), - ).not.toBeInTheDocument(); - }); }); describe("success state — all fields present, sessions present", () => { - it("renders page header with correct title", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("System Overview")).toBeInTheDocument(); - }); - }); - - it("renders page header with correct overline", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("Admin Dashboard")).toBeInTheDocument(); - }); - }); - - it("renders all six stat card titles", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("Registered Users")).toBeInTheDocument(); - }); - expect(screen.getByText("Registered Devices")).toBeInTheDocument(); - expect(screen.getByText("Online Devices")).toBeInTheDocument(); - expect(screen.getByText("Active Sessions")).toBeInTheDocument(); - expect(screen.getByText("Pending Devices")).toBeInTheDocument(); - expect(screen.getByText("Rejected Devices")).toBeInTheDocument(); - }); - it("renders correct numeric values for each stat", async () => { renderPage(); await waitFor(() => { @@ -161,50 +80,18 @@ describe("AdminDashboard", () => { expect(screen.getByText("3")).toBeInTheDocument(); }); - it("'View all Users' link points to /admin/users", async () => { + it.each([ + [/view all users/i, "/admin/users"], + [/view all sessions/i, "/admin/sessions"], + [/devices/i, "/admin/devices"], + ])("links matching %s point to %s", async (name, href) => { renderPage(); await waitFor(() => { - expect( - screen.getByRole("link", { name: /view all users/i }), - ).toBeInTheDocument(); + expect(screen.getAllByRole("link", { name }).length).toBeGreaterThan(0); }); - expect( - screen.getByRole("link", { name: /view all users/i }), - ).toHaveAttribute("href", "/admin/users"); - }); - - it("'View all Sessions' link in stat card points to /admin/sessions", async () => { - renderPage(); - await waitFor(() => { - expect( - screen.getByRole("link", { name: /view all sessions/i }), - ).toBeInTheDocument(); + screen.getAllByRole("link", { name }).forEach((link) => { + expect(link).toHaveAttribute("href", href); }); - expect( - screen.getByRole("link", { name: /view all sessions/i }), - ).toHaveAttribute("href", "/admin/sessions"); - }); - - it("device card links point to /admin/devices", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("Registered Devices")).toBeInTheDocument(); - }); - const deviceLinks = screen.getAllByRole("link", { name: /devices/i }); - deviceLinks.forEach((link) => { - expect(link).toHaveAttribute("href", "/admin/devices"); - }); - }); - - it("renders RecentSessionsTable with isAdmin", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByTestId("recent-sessions-table")).toBeInTheDocument(); - }); - expect(screen.getByTestId("recent-sessions-table")).toHaveAttribute( - "data-admin", - "true", - ); }); }); @@ -222,17 +109,5 @@ describe("AdminDashboard", () => { const zeros = screen.getAllByText("0"); expect(zeros.length).toBeGreaterThanOrEqual(5); }); - - it("renders all zeros when stats is an empty object", async () => { - server.use( - http.get("*/admin/api/stats", () => HttpResponse.json({})), - ); - renderPage(); - await waitFor(() => { - expect(screen.getByText("System Overview")).toBeInTheDocument(); - }); - const zeros = screen.getAllByText("0"); - expect(zeros.length).toBeGreaterThanOrEqual(6); - }); }); }); diff --git a/ui/apps/console/src/pages/admin/__tests__/License.test.tsx b/ui/apps/console/src/pages/admin/__tests__/License.test.tsx index 1ed23010a99..08949436496 100644 --- a/ui/apps/console/src/pages/admin/__tests__/License.test.tsx +++ b/ui/apps/console/src/pages/admin/__tests__/License.test.tsx @@ -44,12 +44,9 @@ const gracePeriodLicense = { expired: true, grace_period: true, }; -const regionalLicense = { ...validLicense, allowed_regions: ["BR", "US"] }; function setLicense(data: JsonBodyType) { - server.use( - http.get("*/admin/api/license", () => HttpResponse.json(data)), - ); + server.use(http.get("*/admin/api/license", () => HttpResponse.json(data))); } function renderPage() { @@ -66,6 +63,12 @@ function renderPage() { return { ...result, fileInput }; } +function datFile(content = "license-content") { + return new File([content], "license.dat", { + type: "application/octet-stream", + }); +} + beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ isAdmin: true }); @@ -79,86 +82,63 @@ beforeEach(() => { }); describe("AdminLicense", () => { - describe("loading state", () => { - it("renders spinner with role='status'", () => { - server.use( - http.get("*/admin/api/license", () => new Promise(() => {})), - ); - renderPage(); - expect(screen.getByRole("status")).toBeInTheDocument(); - }); + it("renders spinner with role='status' while loading", () => { + server.use(http.get("*/admin/api/license", () => new Promise(() => {}))); + renderPage(); + expect(screen.getByRole("status")).toBeInTheDocument(); }); - describe("error state", () => { - it("renders error message with role='alert' for non-400 errors", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - expect(await screen.findByRole("alert")).toBeInTheDocument(); - expect( - screen.getByText("Failed to load license information"), - ).toBeInTheDocument(); - }); + it("renders an error alert for non-400 errors", async () => { + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 500 }), + ), + ); + renderPage(); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect( + screen.getByText("Failed to load license information"), + ).toBeInTheDocument(); + }); - it("shows no-license info alert and upload section when 400 (no license stored)", async () => { - server.use( - http.get("*/admin/api/license", () => - HttpResponse.json({}, { status: 400 }), - ), - ); - renderPage(); - expect( - await screen.findByText("You do not have an installed license"), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /choose a \.dat file/i }), - ).toBeInTheDocument(); - expect( - screen.queryByText("Failed to load license information"), - ).not.toBeInTheDocument(); - }); + it("shows no-license info alert and upload section when 400 (no license stored)", async () => { + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); + renderPage(); + expect( + await screen.findByText("You do not have an installed license"), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /choose a \.dat file/i }), + ).toBeInTheDocument(); + expect( + screen.queryByText("Failed to load license information"), + ).not.toBeInTheDocument(); }); - describe("no data (query disabled)", () => { - it("renders upload section but no license details", () => { - useAuthStore.setState({ isAdmin: false }); - renderPage(); - expect(screen.queryByText("License Information")).not.toBeInTheDocument(); - }); + it("renders no license details when the query is disabled for non-admins", () => { + useAuthStore.setState({ isAdmin: false }); + renderPage(); + expect(screen.queryByText("License Information")).not.toBeInTheDocument(); }); describe("status alerts", () => { - it("shows info alert when no license (data is empty object)", async () => { - renderPage(); - expect( - await screen.findByText("You do not have an installed license"), - ).toBeInTheDocument(); - }); - - it("shows info alert when about_to_expire", async () => { - setLicense(aboutToExpireLicense); - renderPage(); - expect( - await screen.findByText("Your license is about to expire!"), - ).toBeInTheDocument(); - }); - - it("shows warning when expired + grace period", async () => { - setLicense(gracePeriodLicense); - renderPage(); - expect(await screen.findByRole("alert")).toBeInTheDocument(); - expect(screen.getByText(/grace period/i)).toBeInTheDocument(); - }); - - it("shows error when expired without grace period", async () => { - setLicense(expiredLicense); + it.each([ + ["no license", {}, "You do not have an installed license"], + [ + "about to expire", + aboutToExpireLicense, + "Your license is about to expire!", + ], + ["expired within the grace period", gracePeriodLicense, /grace period/i], + ["expired", expiredLicense, "Your license has expired!"], + ])("a %s license reports '%s'", async (_label, license, message) => { + setLicense(license); renderPage(); - expect( - await screen.findByText("Your license has expired!"), - ).toBeInTheDocument(); + expect(await screen.findByText(message)).toBeInTheDocument(); }); it("shows no alert when license is valid", async () => { @@ -176,44 +156,32 @@ describe("AdminLicense", () => { }); describe("license details", () => { - it("does not render license details section when no license", async () => { - renderPage(); - await screen.findByText("You do not have an installed license"); - expect(screen.queryByText("License Information")).not.toBeInTheDocument(); - }); - it("renders dates formatted correctly", async () => { setLicense(validLicense); renderPage(); await screen.findByText("License Information"); - const jan2024 = screen.getAllByText("Jan 1, 2024"); - expect(jan2024.length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Jan 1, 2024").length).toBeGreaterThanOrEqual( + 1, + ); expect(screen.getByText("Jan 1, 2025")).toBeInTheDocument(); }); it("shows 'Now' for -1 timestamps", async () => { - const licenseWithNow = { ...validLicense, issued_at: -1, starts_at: -1 }; - setLicense(licenseWithNow); + setLicense({ ...validLicense, issued_at: -1, starts_at: -1 }); renderPage(); await screen.findByText("License Information"); - const nowElements = screen.getAllByText("Now"); - expect(nowElements.length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByText("Now").length).toBeGreaterThanOrEqual(2); }); - it("shows 'Global' when allowed_regions is empty", async () => { - setLicense(validLicense); + it.each([ + [[], "Global"], + [["BR", "US"], "BR, US"], + ])("allowed_regions=%j renders as '%s'", async (regions, expected) => { + setLicense({ ...validLicense, allowed_regions: regions }); renderPage(); - expect(await screen.findByText("Global")).toBeInTheDocument(); + expect(await screen.findByText(expected)).toBeInTheDocument(); }); - it("shows region list when regions are non-empty", async () => { - setLicense(regionalLicense); - renderPage(); - expect(await screen.findByText("BR, US")).toBeInTheDocument(); - }); - }); - - describe("license owner", () => { it("displays customer fields", async () => { setLicense(validLicense); renderPage(); @@ -223,13 +191,6 @@ describe("AdminLicense", () => { expect(screen.getByText("test@example.com")).toBeInTheDocument(); expect(screen.getByText("Test Co")).toBeInTheDocument(); }); - - it("renders copy button for customer ID", async () => { - setLicense(validLicense); - renderPage(); - await screen.findByText("License Information"); - expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); - }); }); describe("license features", () => { @@ -239,20 +200,16 @@ describe("AdminLicense", () => { expect(await screen.findByText("Unlimited")).toBeInTheDocument(); }); - it("renders check icon for enabled boolean features", async () => { + it("marks enabled features as included and disabled ones as not included", async () => { setLicense(validLicense); renderPage(); await screen.findByText("License Information"); - const included = screen.getAllByLabelText("Included"); - expect(included.length).toBeGreaterThanOrEqual(2); - }); - - it("renders cross icon for disabled boolean features", async () => { - setLicense(validLicense); - renderPage(); - await screen.findByText("License Information"); - const notIncluded = screen.getAllByLabelText("Not included"); - expect(notIncluded.length).toBeGreaterThanOrEqual(1); + expect( + screen.getAllByLabelText("Included").length, + ).toBeGreaterThanOrEqual(2); + expect( + screen.getAllByLabelText("Not included").length, + ).toBeGreaterThanOrEqual(1); }); it("does not render login_link or reports features", async () => { @@ -265,23 +222,6 @@ describe("AdminLicense", () => { }); describe("license upload", () => { - it("renders drop zone and hidden file input", async () => { - const { fileInput } = renderPage(); - await screen.findByText("You do not have an installed license"); - expect( - screen.getByRole("button", { name: /choose a \.dat file/i }), - ).toBeInTheDocument(); - expect(fileInput()).toBeInTheDocument(); - }); - - it("upload button is disabled by default (no file selected)", async () => { - renderPage(); - await screen.findByText("You do not have an installed license"); - expect( - screen.getByRole("button", { name: /upload license/i }), - ).toBeDisabled(); - }); - it("shows validation error for wrong file extension", async () => { const { fileInput } = renderPage(); await screen.findByText("You do not have an installed license"); @@ -294,16 +234,11 @@ describe("AdminLicense", () => { ).toBeInTheDocument(); }); - it("shows remove button and clears file on click", async () => { + it("re-disables upload after the selected file is removed", async () => { const { fileInput } = renderPage(); await screen.findByText("You do not have an installed license"); - const validFile = new File(["content"], "license.dat", { - type: "application/octet-stream", - }); - await userEvent.upload(fileInput(), validFile); - const removeBtn = screen.getByRole("button", { name: /remove file/i }); - expect(removeBtn).toBeInTheDocument(); - await userEvent.click(removeBtn); + await userEvent.upload(fileInput(), datFile()); + await userEvent.click(screen.getByRole("button", { name: /remove file/i })); expect( screen.getByRole("button", { name: /upload license/i }), ).toBeDisabled(); @@ -312,13 +247,8 @@ describe("AdminLicense", () => { it("uploads license when upload button is clicked with valid file", async () => { const { fileInput } = renderPage(); await screen.findByText("You do not have an installed license"); - const validFile = new File(["license-content"], "license.dat", { - type: "application/octet-stream", - }); - await userEvent.upload(fileInput(), validFile); - const uploadBtn = screen.getByRole("button", { - name: /upload license/i, - }); + await userEvent.upload(fileInput(), datFile()); + const uploadBtn = screen.getByRole("button", { name: /upload license/i }); expect(uploadBtn).not.toBeDisabled(); await userEvent.click(uploadBtn); await waitFor(() => @@ -328,23 +258,6 @@ describe("AdminLicense", () => { ); }); - it("shows success message after upload", async () => { - const { fileInput } = renderPage(); - await screen.findByText("You do not have an installed license"); - const validFile = new File(["license-content"], "license.dat", { - type: "application/octet-stream", - }); - await userEvent.upload(fileInput(), validFile); - await userEvent.click( - screen.getByRole("button", { name: /upload license/i }), - ); - await waitFor(() => - expect( - screen.getByText("License uploaded successfully."), - ).toBeInTheDocument(), - ); - }); - it("shows error message on failed upload", async () => { server.use( http.post("*/admin/api/license", () => @@ -353,10 +266,7 @@ describe("AdminLicense", () => { ); const { fileInput } = renderPage(); await screen.findByText("You do not have an installed license"); - const validFile = new File(["license-content"], "license.dat", { - type: "application/octet-stream", - }); - await userEvent.upload(fileInput(), validFile); + await userEvent.upload(fileInput(), datFile()); await userEvent.click( screen.getByRole("button", { name: /upload license/i }), ); diff --git a/ui/apps/console/src/pages/admin/__tests__/Sessions.test.tsx b/ui/apps/console/src/pages/admin/__tests__/Sessions.test.tsx index f4160677582..3fda656455b 100644 --- a/ui/apps/console/src/pages/admin/__tests__/Sessions.test.tsx +++ b/ui/apps/console/src/pages/admin/__tests__/Sessions.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { http, HttpResponse } from "msw"; @@ -7,7 +7,6 @@ import { server, jsonWithTotal } from "@/tests/msw"; import AdminSessions from "../Sessions"; import { createTestWrapper } from "@/tests/wrapper"; import { mockSession } from "@/tests/factories"; -import { LocationProbe } from "@/tests/LocationProbe"; import { useAuthStore } from "@/stores/authStore"; const mockNavigate = vi.hoisted(() => vi.fn()); @@ -17,125 +16,70 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -let lastRequestUrl: URL | null; - function setSessions( sessions: ReturnType[], total?: number, ) { server.use( - http.get("*/admin/api/sessions", ({ request }) => { - lastRequestUrl = new URL(request.url); - return jsonWithTotal(sessions, total ?? sessions.length); - }), + http.get("*/admin/api/sessions", () => + jsonWithTotal(sessions, total ?? sessions.length), + ), ); } function renderPage(initialEntries: string[] = ["/"]) { - let lastSearch = ""; - const result = render( + return render( - { - lastSearch = s; - }} - /> , { wrapper: createTestWrapper() }, ); - return { ...result, getSearch: () => lastSearch }; } beforeEach(() => { vi.clearAllMocks(); - lastRequestUrl = null; useAuthStore.setState({ isAdmin: true }); setSessions([]); }); describe("AdminSessions", () => { - describe("loading state", () => { - it("shows a loading spinner while fetching", () => { - server.use( - http.get("*/admin/api/sessions", () => new Promise(() => {})), - ); - renderPage(); - expect(screen.getByText(/loading sessions/i)).toBeInTheDocument(); - }); - - it("does not render session rows while loading", () => { - server.use( - http.get("*/admin/api/sessions", () => new Promise(() => {})), - ); - renderPage(); - expect(screen.queryByText("root")).not.toBeInTheDocument(); - }); + it("shows a loading spinner while fetching", () => { + server.use(http.get("*/admin/api/sessions", () => new Promise(() => {}))); + renderPage(); + expect(screen.getByText(/loading sessions/i)).toBeInTheDocument(); }); - describe("empty state", () => { - it("shows 'No sessions found' when there are no sessions", async () => { - renderPage(); - expect(await screen.findByText("No sessions found")).toBeInTheDocument(); - }); + it("shows 'No sessions found' when there are no sessions", async () => { + renderPage(); + expect(await screen.findByText("No sessions found")).toBeInTheDocument(); }); - describe("error state", () => { - it("renders the error banner with role='alert'", async () => { - server.use( - http.get("*/admin/api/sessions", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - expect(await screen.findByRole("alert")).toBeInTheDocument(); - }); - - it("displays the console's own copy for the status in the banner", async () => { - server.use( - http.get("*/admin/api/sessions", () => - HttpResponse.json({}, { status: 403 }), - ), - ); - renderPage(); - const alert = await screen.findByRole("alert"); - expect(alert).toHaveTextContent("You do not have permission to do this."); - }); - - it("does not show the error banner when there is no error", async () => { - renderPage(); - await screen.findByText("No sessions found"); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); + it("displays the console's own copy for the status in the error banner", async () => { + server.use( + http.get("*/admin/api/sessions", () => + HttpResponse.json({}, { status: 403 }), + ), + ); + renderPage(); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("You do not have permission to do this."); }); describe("session rows", () => { - it("renders one row per session", async () => { + it("renders the device name, the IP and a truncated session uid", async () => { setSessions([ - mockSession({ uid: "session-1", username: "root" }), - mockSession({ uid: "session-2", username: "admin" }), + mockSession({ uid: "abcdef1234567890", ip_address: "10.0.0.1" }), ]); renderPage(); - expect(await screen.findByText("root")).toBeInTheDocument(); - expect(screen.getByText("admin")).toBeInTheDocument(); - }); - - it("renders the device name via DeviceChip", async () => { - setSessions([mockSession()]); - renderPage(); expect(await screen.findByText("my-device")).toBeInTheDocument(); + expect(screen.getByText("10.0.0.1")).toBeInTheDocument(); + expect(screen.getByText("abcdef1234")).toBeInTheDocument(); }); - it("renders the truncated session uid", async () => { - setSessions([mockSession({ uid: "abcdef1234567890" })]); - renderPage(); - expect(await screen.findByText("abcdef1234")).toBeInTheDocument(); - }); - - it("renders the IP address", async () => { - setSessions([mockSession({ ip_address: "10.0.0.1" })]); + it("shows the truncated device_uid when device object is missing", async () => { + setSessions([mockSession({ device: null, device_uid: "abcd1234efgh" })]); renderPage(); - expect(await screen.findByText("10.0.0.1")).toBeInTheDocument(); + expect(await screen.findByText("abcd1234")).toBeInTheDocument(); }); it("navigates to session detail when a row is clicked", async () => { @@ -149,114 +93,16 @@ describe("AdminSessions", () => { }); }); - describe("active indicator", () => { - it("renders a green dot for active sessions", async () => { - setSessions([mockSession({ active: true })]); - renderPage(); - await screen.findByText("root"); - const dot = document.querySelector(".bg-accent-green"); - expect(dot).toBeInTheDocument(); - }); - - it("renders a muted dot for inactive sessions", async () => { - setSessions([mockSession({ active: false })]); - renderPage(); - await screen.findByText("root"); - const dot = document.querySelector(".bg-text-muted\\/40"); - expect(dot).toBeInTheDocument(); - }); - }); - - describe("authentication indicator", () => { - it("renders the 'Authenticated' shield for authenticated sessions", async () => { - setSessions([mockSession({ authenticated: true })]); - renderPage(); - expect(await screen.findByTitle("Authenticated")).toBeInTheDocument(); - }); - - it("renders the 'Not authenticated' shield for unauthenticated sessions", async () => { - setSessions([mockSession({ authenticated: false })]); - renderPage(); - await screen.findByText("root"); - expect(screen.getAllByTitle("Not authenticated").length).toBeGreaterThan( - 0, - ); - }); - - it("shows the warning icon in the username cell for unauthenticated sessions", async () => { - setSessions([mockSession({ authenticated: false })]); - renderPage(); - await screen.findByText("root"); - expect( - screen.getAllByTitle("Not authenticated").length, - ).toBeGreaterThanOrEqual(2); - }); - }); - - describe("device fallback", () => { - it("shows the truncated device_uid when device object is missing", async () => { - setSessions([mockSession({ device: null, device_uid: "abcd1234efgh" })]); - renderPage(); - expect(await screen.findByText("abcd1234")).toBeInTheDocument(); - }); - }); - - describe("pagination", () => { - it("renders pagination when totalCount > perPage", async () => { - setSessions( - Array.from({ length: 10 }, (_, i) => - mockSession({ uid: `session-${i}`, username: `user-${i}` }), - ), - 25, - ); - renderPage(); - expect(await screen.findByText(/25/)).toBeInTheDocument(); - }); - }); - - describe("URL hydration", () => { - it("passes page=3 to the API when URL has ?page=3", async () => { - renderPage(["/?page=3"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); - }); - }); - - it("passes page=1 to the API when URL has no page param", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - }); - }); - - describe("URL writes", () => { - it("writes ?page=2 to the URL when the user clicks Next page", async () => { - const user = userEvent.setup(); - setSessions( - Array.from({ length: 10 }, (_, i) => - mockSession({ uid: `s-${i}`, username: `u-${i}` }), - ), - 30, - ); - const { getSearch } = renderPage(); - - await screen.findByText("u-0"); - - await user.click(screen.getByRole("button", { name: "Next page" })); - - await waitFor(() => { - const sp = new URLSearchParams(getSearch()); - expect(sp.get("page")).toBe("2"); - }); - }); - - it("omits ?page from the URL when on the default page 1", () => { - const { getSearch } = renderPage(["/"]); - const sp = new URLSearchParams(getSearch()); - expect(sp.get("page")).toBeNull(); - }); + it.each([ + [true, "Authenticated"], + [false, "Not authenticated"], + ])("authenticated=%s renders the '%s' shield", async ( + authenticated, + title, + ) => { + setSessions([mockSession({ authenticated })]); + renderPage(); + await screen.findByText("root"); + expect(screen.getAllByTitle(title).length).toBeGreaterThan(0); }); }); diff --git a/ui/apps/console/src/pages/admin/announcements/__tests__/AdminAnnouncements.test.tsx b/ui/apps/console/src/pages/admin/announcements/__tests__/AdminAnnouncements.test.tsx index b88383b7b30..3dbb5159ed1 100644 --- a/ui/apps/console/src/pages/admin/announcements/__tests__/AdminAnnouncements.test.tsx +++ b/ui/apps/console/src/pages/admin/announcements/__tests__/AdminAnnouncements.test.tsx @@ -80,59 +80,13 @@ describe("AdminAnnouncements", () => { setAnnouncements([]); }); - describe("rendering", () => { - it('renders the page heading "Announcements"', async () => { - renderPage(); - expect( - screen.getByRole("heading", { name: "Announcements" }), - ).toBeInTheDocument(); - }); - - it("renders the Announcements table", async () => { - renderPage(); - await screen.findByText("No announcements found"); - expect( - screen.getByRole("table", { name: "Announcements" }), - ).toBeInTheDocument(); - }); - - it("renders the column headers", async () => { - renderPage(); - await screen.findByText("No announcements found"); - expect( - screen.getByRole("columnheader", { name: "UUID" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("columnheader", { name: "Title" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("columnheader", { name: "Date" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("columnheader", { name: "Actions" }), - ).toBeInTheDocument(); - }); - - it("renders the 'New' button", () => { - renderPage(); - expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument(); - }); - }); - describe("loading state", () => { - it("renders the loading spinner with role='status'", () => { + it("renders the loading spinner while loading", () => { server.use( http.get("*/admin/api/announcements", () => new Promise(() => {})), ); renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); - }); - - it("renders 'Loading announcements...' text while loading", () => { - server.use( - http.get("*/admin/api/announcements", () => new Promise(() => {})), - ); - renderPage(); expect(screen.getByText("Loading announcements...")).toBeInTheDocument(); }); }); @@ -144,14 +98,6 @@ describe("AdminAnnouncements", () => { await screen.findByText("No announcements found"), ).toBeInTheDocument(); }); - - it("does not render announcement rows when the list is empty", async () => { - renderPage(); - await screen.findByText("No announcements found"); - expect( - screen.queryByRole("button", { name: /edit/i }), - ).not.toBeInTheDocument(); - }); }); describe("announcement rows", () => { @@ -183,31 +129,6 @@ describe("AdminAnnouncements", () => { renderPage(); expect(await screen.findByText("abcdef12")).toBeInTheDocument(); }); - - it("renders a formatted date for each row", async () => { - setAnnouncements([ - mockAnnouncement({ date: "2024-06-01T10:00:00.000Z" }), - ]); - renderPage(); - const dateCell = await screen.findByText(/\d{4}/); - expect(dateCell).toBeInTheDocument(); - }); - - it("renders an edit button for each row", async () => { - setAnnouncements([mockAnnouncement({ title: "My Announcement" })]); - renderPage(); - expect( - await screen.findByRole("button", { name: "Edit My Announcement" }), - ).toBeInTheDocument(); - }); - - it("renders a delete button for each row", async () => { - setAnnouncements([mockAnnouncement({ title: "My Announcement" })]); - renderPage(); - expect( - await screen.findByRole("button", { name: "Delete My Announcement" }), - ).toBeInTheDocument(); - }); }); describe("navigation", () => { @@ -297,25 +218,6 @@ describe("AdminAnnouncements", () => { ); }); - it("closes the DeleteAnnouncementDialog when cancel is clicked inside it", async () => { - const user = userEvent.setup(); - setAnnouncements([mockAnnouncement({ title: "Target Announcement" })]); - renderPage(); - - await user.click( - await screen.findByRole("button", { - name: "Delete Target Announcement", - }), - ); - await waitFor(() => screen.getByRole("dialog")); - - await user.click(screen.getByRole("button", { name: "Cancel delete" })); - - await waitFor(() => - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), - ); - }); - it("does not navigate when delete button is clicked (stopPropagation)", async () => { const user = userEvent.setup(); setAnnouncements([mockAnnouncement({ title: "No Nav Announcement" })]); @@ -339,19 +241,9 @@ describe("AdminAnnouncements", () => { ), ); renderPage(); - expect(await screen.findByRole("alert")).toBeInTheDocument(); - }); - - it("renders the error message text", async () => { - server.use( - http.get("*/admin/api/announcements", () => - HttpResponse.json({}, { status: 500 }), - ), + expect(await screen.findByRole("alert")).toHaveTextContent( + "Something went wrong on our side. Try again.", ); - renderPage(); - expect( - await screen.findByText("Something went wrong on our side. Try again."), - ).toBeInTheDocument(); }); }); @@ -392,43 +284,6 @@ describe("AdminAnnouncements", () => { }); }); - describe("URL hydration (usePaginatedListState)", () => { - it("passes page=2 to the API when URL has ?page=2", async () => { - renderPage(["/?page=2"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); - }); - }); - - it("passes page=1 to the API when URL has no page param", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - }); - }); - - describe("URL writes (usePaginatedListState)", () => { - it("passes page=2 to the API when the user clicks Next page", async () => { - const user = userEvent.setup(); - const manyAnnouncements = Array.from({ length: 10 }, (_, i) => - mockAnnouncement({ uuid: `uuid-${i}`, title: `Ann ${i}` }), - ); - setAnnouncements(manyAnnouncements, 30); - renderPage(); - - await screen.findByText("Ann 0"); - - await user.click(screen.getByRole("button", { name: "Next page" })); - - await waitFor(() => { - expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); - }); - }); - }); - describe("delete-last-row page decrement (usePaginatedListState)", () => { it("decrements page from 2 to 1 via URL when deleting the last item on a page", async () => { const user = userEvent.setup(); diff --git a/ui/apps/console/src/pages/admin/announcements/__tests__/DeleteAnnouncementDialog.test.tsx b/ui/apps/console/src/pages/admin/announcements/__tests__/DeleteAnnouncementDialog.test.tsx index f0b9167b086..fa699011c82 100644 --- a/ui/apps/console/src/pages/admin/announcements/__tests__/DeleteAnnouncementDialog.test.tsx +++ b/ui/apps/console/src/pages/admin/announcements/__tests__/DeleteAnnouncementDialog.test.tsx @@ -54,68 +54,7 @@ function renderDialog( } describe("DeleteAnnouncementDialog", () => { - describe("rendering — closed", () => { - it("renders nothing when open is false", () => { - renderDialog({ open: false }); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }); - - describe("rendering — open", () => { - it("renders the dialog when open is true", () => { - renderDialog(); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); - - it("renders the 'Delete Announcement' title", () => { - renderDialog(); - expect(screen.getByText("Delete Announcement")).toBeInTheDocument(); - }); - - it("renders the announcement title in the description", () => { - renderDialog(); - expect(screen.getByText("Test Announcement")).toBeInTheDocument(); - }); - - it("renders the 'This action cannot be undone' warning", () => { - renderDialog(); - expect( - screen.getByText(/this action cannot be undone/i), - ).toBeInTheDocument(); - }); - - it("renders the 'Delete' confirm button", () => { - renderDialog(); - expect( - screen.getByRole("button", { name: /^delete$/i }), - ).toBeInTheDocument(); - }); - - it("renders the Cancel button", () => { - renderDialog(); - expect( - screen.getByRole("button", { name: /cancel/i }), - ).toBeInTheDocument(); - }); - }); - describe("confirm — success", () => { - it("calls onDeleted callback after successful deletion", async () => { - const { onDeleted } = renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); - - await waitFor(() => expect(onDeleted).toHaveBeenCalledTimes(1)); - }); - - it("calls onClose after successful deletion", async () => { - const { onClose } = renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); - - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); - }); - it("calls onClose before onDeleted", async () => { const callOrder: string[] = []; const onClose = vi.fn(() => callOrder.push("onClose")); @@ -200,27 +139,6 @@ describe("DeleteAnnouncementDialog", () => { }); }); - describe("cancel", () => { - it("calls onClose when Cancel is clicked", async () => { - const { onClose } = renderDialog(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onDeleted when Cancel is clicked", async () => { - const { onDeleted } = renderDialog(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onDeleted).not.toHaveBeenCalled(); - }); - }); - - describe("null announcement", () => { - it("renders nothing meaningful in the description when announcement is null", () => { - renderDialog({ announcement: null }); - expect(screen.queryByText("Test Announcement")).not.toBeInTheDocument(); - }); - }); - describe("optional onDeleted callback", () => { it("does not throw when onDeleted is not provided and deletion succeeds", async () => { const { onClose } = renderDialog({ onDeleted: undefined }); diff --git a/ui/apps/console/src/pages/admin/devices/__tests__/AdminDeviceDetails.test.tsx b/ui/apps/console/src/pages/admin/devices/__tests__/AdminDeviceDetails.test.tsx index 377875fe98e..5e4957555d8 100644 --- a/ui/apps/console/src/pages/admin/devices/__tests__/AdminDeviceDetails.test.tsx +++ b/ui/apps/console/src/pages/admin/devices/__tests__/AdminDeviceDetails.test.tsx @@ -90,85 +90,26 @@ describe("AdminDeviceDetails", () => { expect(screen.getByText("Device not found")).toBeInTheDocument(); }); }); - - it('renders "Device not found" when the query returns an error', async () => { - server.use( - http.get("*/admin/api/devices/:uid", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - await waitFor(() => { - expect(screen.getByText("Device not found")).toBeInTheDocument(); - }); - }); - - it('renders a "Back to devices" link in the not-found state', async () => { - server.use( - http.get("*/admin/api/devices/:uid", () => - HttpResponse.json({}, { status: 404 }), - ), - ); - renderPage(); - await waitFor(() => { - expect( - screen.getByRole("link", { name: "Back to devices" }), - ).toBeInTheDocument(); - }); - }); }); describe("device data", () => { - it("renders the device name as the main heading", async () => { + it("renders the device's fields", async () => { renderPage(); await waitFor(() => { expect( screen.getByRole("heading", { name: "my-device" }), ).toBeInTheDocument(); }); - }); - - it("renders the device UID", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("test-uid")).toBeInTheDocument(); - }); - }); - - it("renders the MAC address", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("aa:bb:cc:dd:ee:ff")).toBeInTheDocument(); - }); - }); - - it("renders the operating system name", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("Ubuntu 22.04 LTS")).toBeInTheDocument(); - }); - }); - - it("renders the tenant ID", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("tenant-abc")).toBeInTheDocument(); - }); - }); - - it("renders the status chip", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("Accepted")).toBeInTheDocument(); - }); - }); - - it("renders device tags", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("production")).toBeInTheDocument(); - }); + expect(screen.getByText("test-uid")).toBeInTheDocument(); + expect(screen.getByText("aa:bb:cc:dd:ee:ff")).toBeInTheDocument(); + expect(screen.getByText("Ubuntu 22.04 LTS")).toBeInTheDocument(); + expect(screen.getByText("tenant-abc")).toBeInTheDocument(); + expect(screen.getByText("Accepted")).toBeInTheDocument(); + expect(screen.getByText("production")).toBeInTheDocument(); expect(screen.getByText("web")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "my-namespace" }), + ).toBeInTheDocument(); }); it('renders "No tags" when device has no tags', async () => { @@ -188,24 +129,5 @@ describe("AdminDeviceDetails", () => { ).toBeInTheDocument(); }); }); - - it("does not render the public key section when absent", async () => { - renderPage(); - await waitFor(() => { - expect( - screen.getByRole("heading", { name: "my-device" }), - ).toBeInTheDocument(); - }); - expect(screen.queryByText(/ssh-rsa/)).not.toBeInTheDocument(); - }); - - it("renders the namespace link", async () => { - renderPage(); - await waitFor(() => { - expect( - screen.getByRole("link", { name: "my-namespace" }), - ).toBeInTheDocument(); - }); - }); }); }); diff --git a/ui/apps/console/src/pages/admin/devices/__tests__/AdminDevices.test.tsx b/ui/apps/console/src/pages/admin/devices/__tests__/AdminDevices.test.tsx index 7622cbc45ec..c721c40a145 100644 --- a/ui/apps/console/src/pages/admin/devices/__tests__/AdminDevices.test.tsx +++ b/ui/apps/console/src/pages/admin/devices/__tests__/AdminDevices.test.tsx @@ -18,10 +18,7 @@ vi.mock("react-router-dom", async (importOriginal) => { let lastRequestUrl: URL | null; -function setDevices( - devices: ReturnType[], - total?: number, -) { +function setDevices(devices: ReturnType[], total?: number) { server.use( http.get("*/admin/api/devices", ({ request }) => { lastRequestUrl = new URL(request.url); @@ -47,35 +44,9 @@ describe("AdminDevices", () => { setDevices([]); }); - describe("rendering", () => { - it("renders the page heading", () => { - renderPage(); - expect( - screen.getByRole("heading", { name: "Devices" }), - ).toBeInTheDocument(); - }); - - it("renders the search input with correct aria-label", () => { - renderPage(); - expect( - screen.getByRole("searchbox", { name: "Search devices by hostname" }), - ).toBeInTheDocument(); - }); - - it("renders all status filter tabs", () => { - renderPage(); - expect(screen.getByRole("tab", { name: "All" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Accepted" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Pending" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Rejected" })).toBeInTheDocument(); - }); - }); - describe("loading state", () => { it('renders the loading spinner with "Loading devices..." text', () => { - server.use( - http.get("*/admin/api/devices", () => new Promise(() => {})), - ); + server.use(http.get("*/admin/api/devices", () => new Promise(() => {}))); renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByText("Loading devices...")).toBeInTheDocument(); @@ -132,118 +103,23 @@ describe("AdminDevices", () => { }); }); - describe("status tab interaction", () => { - it("re-renders without crashing after clicking a status tab", async () => { - const user = userEvent.setup(); - renderPage(); - await user.click(screen.getByRole("tab", { name: "Accepted" })); - expect( - screen.getByRole("searchbox", { name: "Search devices by hostname" }), - ).toBeInTheDocument(); - }); + it("marks the matching status tab as selected when status is in the URL", () => { + renderPage(["/?status=pending"]); + expect(screen.getByRole("tab", { name: "Pending" })).toHaveAttribute( + "aria-selected", + "true", + ); }); - describe("URL hydration — controls reflect URL params on mount", () => { - it("passes sortBy/orderBy hydrated from URL to the API", async () => { - renderPage(["/?sortField=name&sortOrder=asc"]); - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("sort_by")).toBe("name"); - expect(lastRequestUrl!.searchParams.get("order_by")).toBe("asc"); - }); - - it("passes status hydrated from URL to the API", async () => { - renderPage(["/?status=accepted"]); - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("status")).toBe("accepted"); - }); + it("clicking a status tab writes status to URL and resets page to 1", async () => { + const user = userEvent.setup(); + renderPage(["/?page=2"]); + await screen.findByText("No devices found"); - it("marks the matching status tab as selected when status is in the URL", () => { - renderPage(["/?status=pending"]); - expect(screen.getByRole("tab", { name: "Pending" })).toHaveAttribute( - "aria-selected", - "true", - ); - }); - - it("passes page hydrated from URL to the API", async () => { - renderPage(["/?page=3"]); - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); - }); + await user.click(screen.getByRole("tab", { name: "Accepted" })); - it("uses defaults when URL params are absent (last_seen/desc, page 1, no status)", async () => { - renderPage(["/"]); - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("sort_by")).toBe("last_seen"); - expect(lastRequestUrl!.searchParams.get("order_by")).toBe("desc"); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - - it("rejects an invalid status value and falls back to no status filter (All tab selected)", async () => { - renderPage(["/?status=invalid-status"]); - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("status")).toBeNull(); - expect(screen.getByRole("tab", { name: "All" })).toHaveAttribute( - "aria-selected", - "true", - ); - }); - }); - - describe("URL writes — interactions update URL and reset page", () => { - it("clicking a status tab writes status to URL and resets page to 1", async () => { - const user = userEvent.setup(); - renderPage(["/?page=2"]); - await screen.findByText("No devices found"); - - await user.click(screen.getByRole("tab", { name: "Accepted" })); - - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("status")).toBe("accepted"); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - - it("clicking a sort column header writes sort to API and resets page", async () => { - const user = userEvent.setup(); - renderPage(["/?page=3"]); - await screen.findByText("No devices found"); - - await user.click( - screen.getByRole("button", { name: /sort by hostname/i }), - ); - - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("sort_by")).toBe("name"); - expect(lastRequestUrl!.searchParams.get("order_by")).toBe("asc"); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - - it("clicking the same sort column again toggles order from asc to desc", async () => { - const user = userEvent.setup(); - renderPage(["/?sortField=name&sortOrder=asc"]); - await screen.findByText("No devices found"); - - await user.click( - screen.getByRole("button", { name: /sort by hostname/i }), - ); - - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("sort_by")).toBe("name"); - expect(lastRequestUrl!.searchParams.get("order_by")).toBe("desc"); - }); - }); - - describe("URL writes — default params are omitted from the URL", () => { - it("API receives page=1 when on the default page", async () => { - renderPage(["/"]); - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - - it("API receives no status when All tab is selected (default)", async () => { - renderPage(["/"]); - await screen.findByText("No devices found"); - expect(lastRequestUrl!.searchParams.get("status")).toBeNull(); - }); + await screen.findByText("No devices found"); + expect(lastRequestUrl!.searchParams.get("status")).toBe("accepted"); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); diff --git a/ui/apps/console/src/pages/admin/devices/__tests__/DeviceStatusChip.test.tsx b/ui/apps/console/src/pages/admin/devices/__tests__/DeviceStatusChip.test.tsx index fb16932bd6b..faafa1cf60c 100644 --- a/ui/apps/console/src/pages/admin/devices/__tests__/DeviceStatusChip.test.tsx +++ b/ui/apps/console/src/pages/admin/devices/__tests__/DeviceStatusChip.test.tsx @@ -3,28 +3,14 @@ import { render, screen } from "@testing-library/react"; import DeviceStatusChip from "../DeviceStatusChip"; describe("DeviceStatusChip", () => { - it('renders "Accepted" label for accepted status', () => { - render(); - expect(screen.getByText("Accepted")).toBeInTheDocument(); - }); - - it('renders "Pending" label for pending status', () => { - render(); - expect(screen.getByText("Pending")).toBeInTheDocument(); - }); - - it('renders "Rejected" label for rejected status', () => { - render(); - expect(screen.getByText("Rejected")).toBeInTheDocument(); - }); - - it('renders "Removed" label for removed status', () => { - render(); - expect(screen.getByText("Removed")).toBeInTheDocument(); - }); - - it('renders "Unused" label for unused status', () => { - render(); - expect(screen.getByText("Unused")).toBeInTheDocument(); + it.each([ + ["accepted", "Accepted"], + ["pending", "Pending"], + ["rejected", "Rejected"], + ["removed", "Removed"], + ["unused", "Unused"], + ] as const)("renders '%s' status as '%s'", (status, label) => { + render(); + expect(screen.getByText(label)).toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRuleDetails.test.tsx b/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRuleDetails.test.tsx index be14fa78a62..b895f211df4 100644 --- a/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRuleDetails.test.tsx +++ b/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRuleDetails.test.tsx @@ -79,7 +79,7 @@ describe("AdminFirewallRuleDetails", () => { }); describe("not-found / error state", () => { - it('renders "Firewall rule not found" when no data and no loading', async () => { + it('renders "Firewall rule not found" with a way back', async () => { server.use( http.get("*/admin/api/firewall/rules/:id", () => HttpResponse.json({}, { status: 404 }), @@ -89,147 +89,51 @@ describe("AdminFirewallRuleDetails", () => { await waitFor(() => { expect(screen.getByText("Firewall rule not found")).toBeInTheDocument(); }); - }); - - it('renders "Firewall rule not found" when the query returns an error', async () => { - server.use( - http.get("*/admin/api/firewall/rules/:id", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - await waitFor(() => { - expect(screen.getByText("Firewall rule not found")).toBeInTheDocument(); - }); - }); - - it('renders a "Back to firewall rules" link in the not-found state', async () => { - server.use( - http.get("*/admin/api/firewall/rules/:id", () => - HttpResponse.json({}, { status: 404 }), - ), - ); - renderPage(); - await waitFor(() => { - expect( - screen.getByRole("link", { name: "Back to firewall rules" }), - ).toBeInTheDocument(); - }); + expect( + screen.getByRole("link", { name: "Back to firewall rules" }), + ).toBeInTheDocument(); }); }); describe("rule data — allow rule", () => { - it('renders "Allow Rule" as the main heading', async () => { + it("renders the rule's fields", async () => { renderPage(); await waitFor(() => { expect( screen.getByRole("heading", { name: "Allow Rule" }), ).toBeInTheDocument(); }); - }); - - it("renders the rule ID", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getAllByText("rule-1").length).toBeGreaterThanOrEqual(1); - }); - }); - - it("renders the namespace as a link to the admin namespace page", async () => { - renderPage(); - await waitFor(() => { - expect( - screen.getByRole("link", { name: "tenant-abc" }), - ).toBeInTheDocument(); - }); + expect(screen.getAllByText("rule-1").length).toBeGreaterThanOrEqual(1); expect(screen.getByRole("link", { name: "tenant-abc" })).toHaveAttribute( "href", "/admin/namespaces/tenant-abc", ); - }); - - it("renders the priority number", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("1")).toBeInTheDocument(); - }); - }); - - it('renders "Allow" in the action field', async () => { - renderPage(); - await waitFor(() => { - expect(screen.getAllByText("Allow").length).toBeGreaterThanOrEqual(1); - }); - }); - - it("renders the Active badge", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1); - }); - }); - - it('renders "Any IP" when source_ip is ".*"', async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("Any IP")).toBeInTheDocument(); - }); - }); - - it('renders "All users" when username is ".*"', async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("All users")).toBeInTheDocument(); - }); - }); - - it('renders "All devices" FilterBadge when filter hostname is ".*"', async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("All devices")).toBeInTheDocument(); - }); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getAllByText("Allow").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Any IP")).toBeInTheDocument(); + expect(screen.getByText("All users")).toBeInTheDocument(); + expect(screen.getByText("All devices")).toBeInTheDocument(); }); }); - describe("rule data — deny rule", () => { - it('renders "Deny Rule" as the main heading', async () => { - setRule({ action: "deny" }); + describe("rule data — deny rule, inactive, specific IP and username", () => { + it("renders the rule's own action, state, IP and username", async () => { + setRule({ + action: "deny", + active: false, + source_ip: "10.0.0.5", + username: "alice", + }); renderPage(); await waitFor(() => { expect( screen.getByRole("heading", { name: "Deny Rule" }), ).toBeInTheDocument(); }); - }); - }); - - describe("rule data — inactive rule", () => { - it("renders the Inactive badge", async () => { - setRule({ active: false }); - renderPage(); - await waitFor(() => { - expect(screen.getAllByText("Inactive").length).toBeGreaterThanOrEqual( - 1, - ); - }); - }); - }); - - describe("rule data — specific IP and username", () => { - it("renders a specific source IP when not wildcard", async () => { - setRule({ source_ip: "10.0.0.5" }); - renderPage(); - await waitFor(() => { - expect(screen.getByText("10.0.0.5")).toBeInTheDocument(); - }); - }); - - it("renders a specific username when not wildcard", async () => { - setRule({ username: "alice" }); - renderPage(); - await waitFor(() => { - expect(screen.getByText("alice")).toBeInTheDocument(); - }); + expect(screen.getAllByText("Inactive").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("10.0.0.5")).toBeInTheDocument(); + expect(screen.getByText("alice")).toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRules.test.tsx b/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRules.test.tsx index 4bda1ae6572..d37848b1b5f 100644 --- a/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRules.test.tsx +++ b/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRules.test.tsx @@ -16,35 +16,23 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const capturedDataTableProps: Record[] = []; -vi.mock("@/components/common/DataTable", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - default: (props: Record) => { - capturedDataTableProps.push({ ...props }); - return actual.default( - props as unknown as Parameters[0], - ); - }, - }; -}); - -let lastRequestUrl: URL | null; - function setRules( rules: ReturnType[], total?: number, ) { server.use( - http.get("*/admin/api/firewall/rules", ({ request }) => { - lastRequestUrl = new URL(request.url); - return jsonWithTotal(rules, total ?? rules.length); - }), + http.get("*/admin/api/firewall/rules", () => + jsonWithTotal(rules, total ?? rules.length), + ), ); } +function searchbox() { + return screen.getByRole("searchbox", { + name: "Search firewall rules by action, priority, IP, or username", + }); +} + function renderPage(initialEntries: string[] = ["/"]) { return render( @@ -57,30 +45,10 @@ function renderPage(initialEntries: string[] = ["/"]) { describe("AdminFirewallRules", () => { beforeEach(() => { vi.clearAllMocks(); - capturedDataTableProps.length = 0; - lastRequestUrl = null; useAuthStore.setState({ isAdmin: true }); setRules([]); }); - describe("rendering", () => { - it('renders the page heading "Firewall Rules"', () => { - renderPage(); - expect( - screen.getByRole("heading", { name: "Firewall Rules" }), - ).toBeInTheDocument(); - }); - - it("renders the search input with correct aria-label", () => { - renderPage(); - expect( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - ).toBeInTheDocument(); - }); - }); - describe("loading state", () => { it('renders the loading spinner with "Loading firewall rules..." text', () => { server.use( @@ -116,52 +84,36 @@ describe("AdminFirewallRules", () => { expect(screen.getAllByText("2")[0]).toBeInTheDocument(); }); - it('shows "Allow" with accent-green for an allow rule', async () => { - setRules([mockFirewallRule({ action: "allow" })]); + it("renders the rule's own action, IP, username and state", async () => { + setRules([ + mockFirewallRule({ + action: "allow", + source_ip: "192.168.1.0/24", + username: "alice", + active: true, + }), + ]); renderPage(); expect(await screen.findByText("Allow")).toBeInTheDocument(); - }); - - it('shows "Deny" for a deny rule', async () => { - setRules([mockFirewallRule({ action: "deny" })]); + expect(screen.getByText("192.168.1.0/24")).toBeInTheDocument(); + expect(screen.getByText("alice")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("renders wildcard source_ip and username as 'Any IP' and 'All users'", async () => { + setRules([ + mockFirewallRule({ + action: "deny", + source_ip: ".*", + username: ".*", + active: false, + }), + ]); renderPage(); expect(await screen.findByText("Deny")).toBeInTheDocument(); - }); - - it('shows "Any IP" when source_ip is ".*"', async () => { - setRules([mockFirewallRule({ source_ip: ".*" })]); - renderPage(); - expect(await screen.findByText("Any IP")).toBeInTheDocument(); - }); - - it("shows specific IP when source_ip is not wildcard", async () => { - setRules([mockFirewallRule({ source_ip: "192.168.1.0/24" })]); - renderPage(); - expect(await screen.findByText("192.168.1.0/24")).toBeInTheDocument(); - }); - - it('shows "All users" when username is ".*"', async () => { - setRules([mockFirewallRule({ username: ".*" })]); - renderPage(); - expect(await screen.findByText("All users")).toBeInTheDocument(); - }); - - it("shows specific username when not wildcard", async () => { - setRules([mockFirewallRule({ username: "alice" })]); - renderPage(); - expect(await screen.findByText("alice")).toBeInTheDocument(); - }); - - it("renders an Active badge for an active rule", async () => { - setRules([mockFirewallRule({ active: true })]); - renderPage(); - expect(await screen.findByText("Active")).toBeInTheDocument(); - }); - - it("renders an Inactive badge for an inactive rule", async () => { - setRules([mockFirewallRule({ active: false })]); - renderPage(); - expect(await screen.findByText("Inactive")).toBeInTheDocument(); + expect(screen.getByText("Any IP")).toBeInTheDocument(); + expect(screen.getByText("All users")).toBeInTheDocument(); + expect(screen.getByText("Inactive")).toBeInTheDocument(); }); it("navigates to the detail page when a row is clicked", async () => { @@ -218,80 +170,23 @@ describe("AdminFirewallRules", () => { setRules([allowRule, denyRule]); }); - it("filters rules by action text", async () => { + it.each([ + ["deny", "Deny", "Allow"], + ["172.16.0.1", "172.16.0.1", "zara"], + ["zara", "zara", "172.16.0.1"], + ["777", "777", "Allow"], + ])("searching '%s' keeps '%s' and drops '%s'", async (term, kept, gone) => { const user = userEvent.setup(); renderPage(); - await screen.findByText("Allow"); + await screen.findByText(kept); - await user.type( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - "deny", - ); + await user.type(searchbox(), term); await waitFor(() => - expect(screen.queryByText("Allow")).not.toBeInTheDocument(), + expect(screen.queryByText(gone)).not.toBeInTheDocument(), ); - expect(screen.getByText("Deny")).toBeInTheDocument(); - }); - - it("filters rules by source IP text", async () => { - const user = userEvent.setup(); - renderPage(); - - await screen.findByText("172.16.0.1"); - - await user.type( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - "172.16.0.1", - ); - - await waitFor(() => - expect(screen.queryByText("zara")).not.toBeInTheDocument(), - ); - expect(screen.getByText("172.16.0.1")).toBeInTheDocument(); - }); - - it("filters rules by username text", async () => { - const user = userEvent.setup(); - renderPage(); - - await screen.findByText("zara"); - - await user.type( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - "zara", - ); - - await waitFor(() => - expect(screen.queryByText("172.16.0.1")).not.toBeInTheDocument(), - ); - expect(screen.getByText("zara")).toBeInTheDocument(); - }); - - it("filters rules by priority number", async () => { - const user = userEvent.setup(); - renderPage(); - - await screen.findByText("777"); - - await user.type( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - "777", - ); - - await waitFor(() => - expect(screen.queryByText("Allow")).not.toBeInTheDocument(), - ); - expect(screen.getByText("777")).toBeInTheDocument(); + expect(screen.getByText(kept)).toBeInTheDocument(); }); it('shows "No rules matching" message when search has no results', async () => { @@ -300,102 +195,9 @@ describe("AdminFirewallRules", () => { await screen.findByText("Allow"); - await user.type( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - "zzz-no-match", - ); + await user.type(searchbox(), "zzz-no-match"); await screen.findByText(/No rules matching/); }); }); - - describe("pagination suppressed while searching", () => { - beforeEach(() => { - setRules( - [mockFirewallRule({ id: "r1", action: "allow", priority: 1 })], - 1, - ); - }); - - it("passes page/totalPages/onPageChange to DataTable when search is empty", async () => { - renderPage(); - await screen.findByText("Allow"); - const last = capturedDataTableProps.at(-1); - expect(last).toBeDefined(); - expect(last).toHaveProperty("page"); - expect(last).toHaveProperty("totalPages"); - expect(last).toHaveProperty("onPageChange"); - }); - - it("omits page/totalPages/onPageChange from DataTable while search is non-empty", async () => { - const user = userEvent.setup(); - renderPage(); - - await screen.findByText("Allow"); - - await user.type( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - "allow", - ); - - await waitFor(() => { - const last = capturedDataTableProps.at(-1); - expect(last).toBeDefined(); - expect(last).not.toHaveProperty("page"); - expect(last).not.toHaveProperty("totalPages"); - expect(last).not.toHaveProperty("onPageChange"); - }); - }); - }); - - describe("URL round-trips", () => { - it("hydrates search from URL on mount", async () => { - setRules([mockFirewallRule({ id: "r1", action: "allow" })]); - renderPage(["/?search=allow"]); - expect( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - ).toHaveValue("allow"); - }); - - it("hydrates page from URL and passes it to the API", async () => { - renderPage(["/?page=3"]); - await screen.findByText("No firewall rules found"); - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); - }); - - it("passes page=1 to the API when URL has no params", async () => { - renderPage(["/"]); - await screen.findByText("No firewall rules found"); - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - - it("setSearch resets page to 1 in the URL", async () => { - const user = userEvent.setup(); - renderPage(["/?page=3"]); - - await screen.findByText("No firewall rules found"); - - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); - - await user.type( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - "allow", - ); - - await waitFor(() => { - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - }); - }); }); diff --git a/ui/apps/console/src/pages/admin/namespaces/__tests__/AdminNamespaces.test.tsx b/ui/apps/console/src/pages/admin/namespaces/__tests__/AdminNamespaces.test.tsx index 55404699364..ad62295c0bf 100644 --- a/ui/apps/console/src/pages/admin/namespaces/__tests__/AdminNamespaces.test.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/__tests__/AdminNamespaces.test.tsx @@ -73,22 +73,6 @@ describe("AdminNamespaces", () => { setNamespaces([]); }); - describe("rendering", () => { - it("renders the page heading", () => { - renderPage(); - expect( - screen.getByRole("heading", { name: "Namespaces" }), - ).toBeInTheDocument(); - }); - - it("renders the search input with correct aria-label", () => { - renderPage(); - expect( - screen.getByRole("searchbox", { name: "Search namespaces by name" }), - ).toBeInTheDocument(); - }); - }); - describe("loading state", () => { it('renders the loading spinner with "Loading namespaces..." text', () => { server.use( @@ -146,83 +130,6 @@ describe("AdminNamespaces", () => { }); }); - describe("URL hydration — controls reflect URL params on mount", () => { - it("passes search and page hydrated from URL to the API", async () => { - renderPage(["/?search=myns&page=3"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); - }); - expect( - screen.getByRole("searchbox", { name: "Search namespaces by name" }), - ).toHaveValue("myns"); - }); - - it("passes page=1 and no filter to the API when URL has no params", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - }); - }); - - describe("URL writes — clearing search resets page to 1 and omits both params", () => { - it("omits search and page from the URL after clearing a prefilled search", async () => { - const user = userEvent.setup(); - renderPage(["/?search=myns&page=3"]); - - const searchbox = screen.getByRole("searchbox", { - name: "Search namespaces by name", - }); - expect(searchbox).toHaveValue("myns"); - - await user.clear(searchbox); - - await waitFor(() => { - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - }); - }); - - describe("URL hydration — ?page=2&search=dev hydrates controls", () => { - it("hydrates the search field to 'dev' and passes page=2 to the API", async () => { - renderPage(["/?page=2&search=dev"]); - - expect( - screen.getByRole("searchbox", { name: "Search namespaces by name" }), - ).toHaveValue("dev"); - - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); - }); - }); - }); - - describe("URL writes — typing in SearchField resets page to 1", () => { - it("resets page to 1 and reflects new search value after typing", async () => { - const user = userEvent.setup(); - renderPage(["/?page=2"]); - - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); - }); - - const searchbox = screen.getByRole("searchbox", { - name: "Search namespaces by name", - }); - - await user.type(searchbox, "dev"); - - await waitFor(() => { - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - expect(searchbox).toHaveValue("dev"); - }); - }); - describe("debounce — search is debounced before reaching the query hook", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); diff --git a/ui/apps/console/src/pages/admin/namespaces/__tests__/DeleteNamespaceDialog.test.tsx b/ui/apps/console/src/pages/admin/namespaces/__tests__/DeleteNamespaceDialog.test.tsx index 3a6f1c65c19..de5929665da 100644 --- a/ui/apps/console/src/pages/admin/namespaces/__tests__/DeleteNamespaceDialog.test.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/__tests__/DeleteNamespaceDialog.test.tsx @@ -57,51 +57,6 @@ function renderDialog( } describe("DeleteNamespaceDialog", () => { - describe("rendering — closed", () => { - it("renders nothing when open is false", () => { - renderDialog({ open: false }); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }); - - describe("rendering — open", () => { - it("renders the dialog when open is true", () => { - renderDialog(); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); - - it("renders the 'Delete Namespace' title", () => { - renderDialog(); - expect(screen.getByText("Delete Namespace")).toBeInTheDocument(); - }); - - it("renders the namespace name in the description", () => { - renderDialog(); - expect(screen.getByText("test-namespace")).toBeInTheDocument(); - }); - - it("renders the cascade warning about devices, sessions, public keys, and API keys", () => { - renderDialog(); - expect( - screen.getByText(/devices.*sessions.*public keys.*api keys/i), - ).toBeInTheDocument(); - }); - - it("renders the 'Delete' confirm button", () => { - renderDialog(); - expect( - screen.getByRole("button", { name: /^delete$/i }), - ).toBeInTheDocument(); - }); - - it("renders the Cancel button", () => { - renderDialog(); - expect( - screen.getByRole("button", { name: /cancel/i }), - ).toBeInTheDocument(); - }); - }); - describe("confirm — success", () => { it("calls deleteNamespaceAdmin with the correct tenant_id", async () => { renderDialog(); @@ -117,22 +72,6 @@ describe("DeleteNamespaceDialog", () => { }); }); - it("calls onDeleted callback after successful deletion", async () => { - const { onDeleted } = renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); - - await waitFor(() => expect(onDeleted).toHaveBeenCalledTimes(1)); - }); - - it("calls onClose after successful deletion", async () => { - const { onClose } = renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); - - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); - }); - it("calls onClose before onDeleted", async () => { const callOrder: string[] = []; const onClose = vi.fn(() => callOrder.push("onClose")); @@ -173,23 +112,6 @@ describe("DeleteNamespaceDialog", () => { }); }); - it("shows error for SDK errors", async () => { - server.use( - http.delete("*/admin/api/namespaces/:tenant", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); - - await waitFor(() => { - expect( - screen.getByText(/failed to delete namespace/i), - ).toBeInTheDocument(); - }); - }); - it("does not call onDeleted when deletion fails", async () => { server.use( http.delete("*/admin/api/namespaces/:tenant", () => @@ -219,32 +141,7 @@ describe("DeleteNamespaceDialog", () => { }); }); - describe("cancel", () => { - it("calls onClose when Cancel is clicked", async () => { - const { onClose } = renderDialog(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call deleteNamespaceAdmin when Cancel is clicked", async () => { - renderDialog(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(deleteSpy).not.toHaveBeenCalled(); - }); - - it("does not call onDeleted when Cancel is clicked", async () => { - const { onDeleted } = renderDialog(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onDeleted).not.toHaveBeenCalled(); - }); - }); - describe("null namespace", () => { - it("renders nothing meaningful in the description when namespace is null", () => { - renderDialog({ namespace: null }); - expect(screen.queryByText("test-namespace")).not.toBeInTheDocument(); - }); - it("does not call deleteNamespaceAdmin when confirmed with null namespace", async () => { renderDialog({ namespace: null }); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); diff --git a/ui/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx b/ui/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx index 3fe3ea21d1f..11f24f04d4e 100644 --- a/ui/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx @@ -54,89 +54,22 @@ describe("EditNamespaceDrawer", () => { vi.clearAllMocks(); editSpy.mockReset(); server.use( - http.put("*/admin/api/namespaces-update/:tenantID", async ({ request, params }) => { - let body = await request.json(); - if (typeof body === "string") body = JSON.parse(body); - editSpy({ - path: { tenantID: params.tenantID }, - body, - }); - return HttpResponse.json({}); - }), + http.put( + "*/admin/api/namespaces-update/:tenantID", + async ({ request, params }) => { + let body = await request.json(); + if (typeof body === "string") body = JSON.parse(body); + editSpy({ + path: { tenantID: params.tenantID }, + body, + }); + return HttpResponse.json({}); + }, + ), ); }); - describe("rendering — closed", () => { - it("renders nothing when open is false", () => { - renderDrawer({ open: false }); - expect(screen.queryByText("Edit Namespace")).not.toBeInTheDocument(); - }); - }); - - describe("rendering — open", () => { - it("renders the 'Edit Namespace' title", () => { - renderDrawer(); - expect(screen.getByText("Edit Namespace")).toBeInTheDocument(); - }); - - it("renders the Name input", () => { - renderDrawer(); - expect(screen.getByLabelText("Namespace Name")).toBeInTheDocument(); - }); - - it("renders the Max Devices input", () => { - renderDrawer(); - expect(screen.getByLabelText(/^max devices$/i)).toBeInTheDocument(); - }); - - it("renders the Session Recording checkbox", () => { - renderDrawer(); - expect(screen.getByLabelText(/session recording/i)).toBeInTheDocument(); - }); - - it("renders the 'Save Changes' submit button", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /save changes/i }), - ).toBeInTheDocument(); - }); - - it("renders the Cancel button", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /cancel/i }), - ).toBeInTheDocument(); - }); - }); - describe("form pre-filling", () => { - it("pre-fills the Name field with the namespace name", () => { - renderDrawer(); - expect(screen.getByLabelText("Namespace Name")).toHaveValue( - "my-namespace", - ); - }); - - it("pre-fills the Max Devices field with the namespace max_devices", () => { - renderDrawer(); - expect(screen.getByLabelText(/^max devices$/i)).toHaveValue("10"); - }); - - it("pre-fills Session Recording checkbox as checked when session_record is true", () => { - renderDrawer(); - expect(screen.getByLabelText(/session recording/i)).toBeChecked(); - }); - - it("pre-fills Session Recording checkbox as unchecked when session_record is false", () => { - renderDrawer({ - namespace: { - ...mockNamespace, - settings: { ...mockNamespace.settings!, session_record: false }, - }, - }); - expect(screen.getByLabelText(/session recording/i)).not.toBeChecked(); - }); - it("uses default max_devices of -1 when namespace has no max_devices", () => { renderDrawer({ namespace: { ...mockNamespace, max_devices: -1 }, @@ -240,16 +173,6 @@ describe("EditNamespaceDrawer", () => { ); }); }); - - it("calls onClose after successful submit", async () => { - const { onClose } = renderDrawer(); - - await userEvent.click( - screen.getByRole("button", { name: /save changes/i }), - ); - - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); - }); }); describe("submit — error handling", () => { @@ -309,53 +232,6 @@ describe("EditNamespaceDrawer", () => { ).toBeInTheDocument(); }); }); - - it("renders error with role='alert'", async () => { - server.use( - http.put("*/admin/api/namespaces-update/:tenantID", () => - HttpResponse.error(), - ), - ); - renderDrawer(); - - await userEvent.click( - screen.getByRole("button", { name: /save changes/i }), - ); - - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - }); - - it("does not call onClose when update fails", async () => { - server.use( - http.put("*/admin/api/namespaces-update/:tenantID", () => - HttpResponse.error(), - ), - ); - const { onClose } = renderDrawer(); - - await userEvent.click( - screen.getByRole("button", { name: /save changes/i }), - ); - - await waitFor(() => screen.getByRole("alert")); - expect(onClose).not.toHaveBeenCalled(); - }); - }); - - describe("cancel", () => { - it("calls onClose when Cancel is clicked", async () => { - const { onClose } = renderDrawer(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call editNamespaceAdmin when Cancel is clicked", async () => { - renderDrawer(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(editSpy).not.toHaveBeenCalled(); - }); }); describe("state reset on reopen", () => { @@ -417,21 +293,4 @@ describe("EditNamespaceDrawer", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); }); - - describe("null namespace", () => { - it("renders the drawer with empty name field when namespace is null", () => { - renderDrawer({ namespace: null }); - expect(screen.getByLabelText("Namespace Name")).toHaveValue(""); - }); - - it("renders the drawer with max_devices of -1 when namespace is null", () => { - renderDrawer({ namespace: null }); - expect(screen.getByLabelText(/^max devices$/i)).toHaveValue("-1"); - }); - - it("renders the Session Recording checkbox unchecked when namespace is null", () => { - renderDrawer({ namespace: null }); - expect(screen.getByLabelText(/session recording/i)).not.toBeChecked(); - }); - }); }); diff --git a/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx index 35b93d99523..ee2a9773448 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx @@ -85,22 +85,6 @@ describe("AdminUsers", () => { setUsers([]); }); - describe("rendering", () => { - it("renders the page heading", () => { - renderPage(); - expect( - screen.getByRole("heading", { name: "Users" }), - ).toBeInTheDocument(); - }); - - it("renders the search input with correct aria-label", () => { - renderPage(); - expect( - screen.getByRole("searchbox", { name: "Search users by username" }), - ).toBeInTheDocument(); - }); - }); - describe("loading state", () => { it('renders the loading spinner with "Loading users..." text', () => { server.use( @@ -156,83 +140,6 @@ describe("AdminUsers", () => { }); }); - describe("URL hydration — controls reflect URL params on mount", () => { - it("passes search and page hydrated from URL to the API", async () => { - renderPage(["/?search=foo&page=2"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); - }); - expect( - screen.getByRole("searchbox", { name: "Search users by username" }), - ).toHaveValue("foo"); - }); - - it("passes page=1 to the API when URL has no params", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - }); - }); - - describe("URL writes — clearing search resets page to 1 and omits both params", () => { - it("omits search and page from the URL after clearing a prefilled search", async () => { - const user = userEvent.setup(); - renderPage(["/?search=foo&page=2"]); - - const searchbox = screen.getByRole("searchbox", { - name: "Search users by username", - }); - expect(searchbox).toHaveValue("foo"); - - await user.clear(searchbox); - - await waitFor(() => { - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - }); - }); - - describe("URL hydration — ?page=3&search=alice hydrates controls", () => { - it("hydrates the search field to 'alice' and passes page=3 to the API", async () => { - renderPage(["/?page=3&search=alice"]); - - expect( - screen.getByRole("searchbox", { name: "Search users by username" }), - ).toHaveValue("alice"); - - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); - }); - }); - }); - - describe("URL writes — typing in SearchField resets page to 1", () => { - it("resets page to 1 and reflects new search value after typing", async () => { - const user = userEvent.setup(); - renderPage(["/?page=3"]); - - await waitFor(() => { - expect(lastRequestUrl).not.toBeNull(); - expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); - }); - - const searchbox = screen.getByRole("searchbox", { - name: "Search users by username", - }); - - await user.type(searchbox, "bob"); - - await waitFor(() => { - expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); - }); - expect(searchbox).toHaveValue("bob"); - }); - }); - describe("debounce — search is debounced before reaching the query hook", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); diff --git a/ui/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx index fd33e84c2a1..426a3e938d0 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx @@ -43,6 +43,16 @@ async function fillForm({ await userEvent.type(screen.getByLabelText(/^password$/i), password); } +function submitButton() { + return screen.getByRole("button", { name: /create user/i }); +} + +function setCreateError(status: number) { + server.use( + http.post("*/admin/api/users", () => HttpResponse.json({}, { status })), + ); +} + describe("CreateUserDrawer", () => { beforeEach(() => { vi.clearAllMocks(); @@ -55,149 +65,25 @@ describe("CreateUserDrawer", () => { ); }); - describe("rendering — closed", () => { - it("renders nothing when open is false", () => { - renderDrawer({ open: false }); - expect(screen.queryByText("Create User")).not.toBeInTheDocument(); - }); - }); - - describe("rendering — open", () => { - it("renders the 'Create User' title", () => { - renderDrawer(); - expect( - screen.getByRole("heading", { name: "Create User" }), - ).toBeInTheDocument(); - }); - - it("renders the Name input field", () => { - renderDrawer(); - expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); - }); - - it("renders the Username input field", () => { - renderDrawer(); - expect(screen.getByLabelText(/^username$/i)).toBeInTheDocument(); - }); - - it("renders the Email input field", () => { - renderDrawer(); - expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument(); - }); - - it("renders the Password input field", () => { - renderDrawer(); - expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); - }); - - it("renders the 'Create User' submit button", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /create user/i }), - ).toBeInTheDocument(); - }); - - it("renders the Cancel button", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /cancel/i }), - ).toBeInTheDocument(); - }); - - it("submit button is disabled when form is empty", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /create user/i }), - ).toBeDisabled(); - }); - - it("password field is of type password by default", () => { - renderDrawer(); - expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( - "type", - "password", - ); - }); - }); - describe("form enabling", () => { - it("enables submit button when all required fields are filled", async () => { + it("enables submit when all required fields are filled", async () => { renderDrawer(); await fillForm(); - expect( - screen.getByRole("button", { name: /create user/i }), - ).not.toBeDisabled(); + expect(submitButton()).not.toBeDisabled(); }); - it("keeps submit disabled when name is missing", async () => { - renderDrawer(); - await fillForm({ name: "" }); - expect( - screen.getByRole("button", { name: /create user/i }), - ).toBeDisabled(); - }); - - it("keeps submit disabled when username is missing", async () => { - renderDrawer(); - await fillForm({ username: "" }); - expect( - screen.getByRole("button", { name: /create user/i }), - ).toBeDisabled(); - }); - - it("keeps submit disabled when email is missing", async () => { - renderDrawer(); - await fillForm({ email: "" }); - expect( - screen.getByRole("button", { name: /create user/i }), - ).toBeDisabled(); - }); - - it("keeps submit disabled when password is missing", async () => { - renderDrawer(); - await fillForm({ password: "" }); - expect( - screen.getByRole("button", { name: /create user/i }), - ).toBeDisabled(); - }); - }); - - describe("password visibility toggle", () => { - it("shows password in plaintext when Show password button is clicked", async () => { - renderDrawer(); - await userEvent.click( - screen.getByRole("button", { name: /show password/i }), - ); - expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( - "type", - "text", - ); - }); - - it("hides password again when Hide password button is clicked", async () => { - renderDrawer(); - await userEvent.click( - screen.getByRole("button", { name: /show password/i }), - ); - await userEvent.click( - screen.getByRole("button", { name: /hide password/i }), - ); - expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( - "type", - "password", - ); - }); + it.each(["name", "username", "email", "password"] as const)( + "keeps submit disabled when %s is missing", + async (field) => { + renderDrawer(); + await fillForm({ [field]: "" }); + expect(submitButton()).toBeDisabled(); + }, + ); }); describe("namespace limit controls", () => { - it("does not show namespace limit sub-options by default", () => { - renderDrawer(); - expect( - screen.queryByLabelText(/disable namespace creation/i), - ).not.toBeInTheDocument(); - }); - - it("shows sub-options when 'Set namespace creation limit' is checked", async () => { + it("shows the disable toggle and the max namespaces input once the limit is enabled", async () => { renderDrawer(); await userEvent.click( screen.getByLabelText(/set namespace creation limit/i), @@ -205,13 +91,6 @@ describe("CreateUserDrawer", () => { expect( screen.getByLabelText(/disable namespace creation/i), ).toBeInTheDocument(); - }); - - it("shows max namespaces input when limit is enabled but disable is unchecked", async () => { - renderDrawer(); - await userEvent.click( - screen.getByLabelText(/set namespace creation limit/i), - ); expect(screen.getByLabelText(/max namespaces/i)).toBeInTheDocument(); }); @@ -229,21 +108,12 @@ describe("CreateUserDrawer", () => { }); }); - describe("admin checkbox", () => { - it("renders 'Admin user' checkbox unchecked by default", () => { - renderDrawer(); - expect(screen.getByLabelText(/admin user/i)).not.toBeChecked(); - }); - }); - describe("submit — success", () => { - it("calls createUserAdmin with the correct payload", async () => { - renderDrawer(); + it("posts the form values and closes the drawer", async () => { + const { onClose } = renderDrawer(); await fillForm(); - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); + await userEvent.click(submitButton()); await waitFor(() => { expect(createSpy).toHaveBeenCalledWith( @@ -258,31 +128,21 @@ describe("CreateUserDrawer", () => { }), ); }); - }); - - it("calls onClose after successful creation", async () => { - const { onClose } = renderDrawer(); - await fillForm(); - - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); }); - it("sends max_namespaces as undefined when limit is not enabled", async () => { + it("omits max_namespaces when the limit is not enabled", async () => { renderDrawer(); await fillForm(); - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); + await userEvent.click(submitButton()); await waitFor(() => { expect(createSpy).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.not.objectContaining({ max_namespaces: expect.anything() }), + body: expect.not.objectContaining({ + max_namespaces: expect.anything(), + }), }), ); }); @@ -298,9 +158,7 @@ describe("CreateUserDrawer", () => { await userEvent.click( screen.getByLabelText(/disable namespace creation/i), ); - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); + await userEvent.click(submitButton()); await waitFor(() => { expect(createSpy).toHaveBeenCalledWith( @@ -313,124 +171,57 @@ describe("CreateUserDrawer", () => { }); describe("submit — error handling", () => { - it("shows conflict error message for 409 responses", async () => { - server.use( - http.post("*/admin/api/users", () => - HttpResponse.json({}, { status: 409 }), - ), - ); + it.each([ + [409, /already exists/i], + [400, /failed to create user/i], + ])("a %i response reports '%s'", async (status, message) => { + setCreateError(status); renderDrawer(); await fillForm(); - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); - - await waitFor(() => { - expect(screen.getByText(/already exists/i)).toBeInTheDocument(); - }); - }); + await userEvent.click(submitButton()); - it("shows generic error for 400 responses", async () => { - server.use( - http.post("*/admin/api/users", () => - HttpResponse.json({}, { status: 400 }), - ), + await waitFor(() => + expect(screen.getByText(message)).toBeInTheDocument(), ); - renderDrawer(); - await fillForm(); - - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); - - await waitFor(() => { - expect(screen.getByText(/failed to create user/i)).toBeInTheDocument(); - }); }); it("shows generic error for unexpected failures", async () => { - server.use( - http.post("*/admin/api/users", () => HttpResponse.error()), - ); + server.use(http.post("*/admin/api/users", () => HttpResponse.error())); renderDrawer(); await fillForm(); - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); + await userEvent.click(submitButton()); - await waitFor(() => { - expect(screen.getByText(/failed to create user/i)).toBeInTheDocument(); - }); - }); - - it("renders error with role='alert'", async () => { - server.use( - http.post("*/admin/api/users", () => HttpResponse.error()), + await waitFor(() => + expect(screen.getByText(/failed to create user/i)).toBeInTheDocument(), ); - renderDrawer(); - await fillForm(); - - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); - - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - }); - - it("does not call onClose when creation fails", async () => { - server.use( - http.post("*/admin/api/users", () => HttpResponse.error()), - ); - const { onClose } = renderDrawer(); - await fillForm(); - - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); - - await waitFor(() => screen.getByRole("alert")); - expect(onClose).not.toHaveBeenCalled(); }); }); describe("client-side validation", () => { - it("marks username invalid on blur for an uppercase value", async () => { - renderDrawer(); - const usernameInput = screen.getByLabelText(/^username$/i); - await userEvent.type(usernameInput, "Alice"); - await userEvent.tab(); - expect(usernameInput).toHaveAttribute("aria-invalid", "true"); - }); - - it("marks email invalid on blur for a malformed value", async () => { - renderDrawer(); - const emailInput = screen.getByLabelText(/^email$/i); - await userEvent.type(emailInput, "not-an-email"); - await userEvent.tab(); - expect(emailInput).toHaveAttribute("aria-invalid", "true"); - expect( - await screen.findByText(/enter a valid email address/i), - ).toBeInTheDocument(); - }); - - it("marks password invalid on blur for a too-short value", async () => { - renderDrawer(); - const passwordInput = screen.getByLabelText(/^password$/i); - await userEvent.type(passwordInput, "abc"); - await userEvent.tab(); - expect(passwordInput).toHaveAttribute("aria-invalid", "true"); - expect(await screen.findByText(/5–32 characters/i)).toBeInTheDocument(); - }); + it.each([ + [/^username$/i, "Alice", null], + [/^email$/i, "not-an-email", /enter a valid email address/i], + [/^password$/i, "abc", /5–32 characters/i], + ] as const)( + "marks %s invalid on blur and reports %s", + async (label, value, message) => { + renderDrawer(); + const input = screen.getByLabelText(label); + await userEvent.type(input, value); + await userEvent.tab(); + expect(input).toHaveAttribute("aria-invalid", "true"); + if (message) + expect(await screen.findByText(message)).toBeInTheDocument(); + }, + ); it("blocks submit when fields are non-empty but format is invalid", async () => { renderDrawer(); await fillForm({ username: "Alice", email: "bad", password: "abc" }); - const submit = screen.getByRole("button", { name: /create user/i }); + const submit = submitButton(); expect(submit).toBeDisabled(); await userEvent.click(submit); @@ -462,20 +253,6 @@ describe("CreateUserDrawer", () => { }); }); - describe("cancel", () => { - it("calls onClose when Cancel is clicked", async () => { - const { onClose } = renderDrawer(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call createUserAdmin when Cancel is clicked", async () => { - renderDrawer(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(createSpy).not.toHaveBeenCalled(); - }); - }); - describe("state reset on reopen", () => { it("clears the name field when closed then reopened", async () => { const { rerender } = renderDrawer(); @@ -488,14 +265,10 @@ describe("CreateUserDrawer", () => { }); it("clears any error when closed then reopened", async () => { - server.use( - http.post("*/admin/api/users", () => HttpResponse.error()), - ); + server.use(http.post("*/admin/api/users", () => HttpResponse.error())); const { rerender } = renderDrawer(); await fillForm(); - await userEvent.click( - screen.getByRole("button", { name: /create user/i }), - ); + await userEvent.click(submitButton()); await waitFor(() => screen.getByRole("alert")); rerender(); diff --git a/ui/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx index f37ad8d7e8d..40ee411da15 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx @@ -63,88 +63,6 @@ describe("EditUserDrawer", () => { ); }); - describe("rendering — closed", () => { - it("renders nothing when open is false", () => { - renderDrawer({ open: false }); - expect(screen.queryByText("Edit User")).not.toBeInTheDocument(); - }); - }); - - describe("rendering — open", () => { - it("renders the 'Edit User' title", () => { - renderDrawer(); - expect(screen.getByText("Edit User")).toBeInTheDocument(); - }); - - it("renders the Name input", () => { - renderDrawer(); - expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); - }); - - it("renders the Username input", () => { - renderDrawer(); - expect(screen.getByLabelText(/^username$/i)).toBeInTheDocument(); - }); - - it("renders the Email input", () => { - renderDrawer(); - expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument(); - }); - - it("renders the Password input", () => { - renderDrawer(); - expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); - }); - - it("renders the 'Save Changes' submit button", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /save changes/i }), - ).toBeInTheDocument(); - }); - - it("renders the Cancel button", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /cancel/i }), - ).toBeInTheDocument(); - }); - }); - - describe("form pre-filling", () => { - it("pre-fills the Name field with the user's name", () => { - renderDrawer(); - expect(screen.getByLabelText(/^name$/i)).toHaveValue("Alice Smith"); - }); - - it("pre-fills the Username field with the user's username", () => { - renderDrawer(); - expect(screen.getByLabelText(/^username$/i)).toHaveValue("alice"); - }); - - it("pre-fills the Email field with the user's email", () => { - renderDrawer(); - expect(screen.getByLabelText(/^email$/i)).toHaveValue( - "alice@example.com", - ); - }); - - it("leaves the Password field blank", () => { - renderDrawer(); - expect(screen.getByLabelText(/^password$/i)).toHaveValue(""); - }); - - it("pre-fills confirmed checkbox as unchecked when user is not confirmed", () => { - renderDrawer({ user: mockUser }); - expect(screen.getByLabelText(/^confirmed$/i)).not.toBeChecked(); - }); - - it("pre-fills admin checkbox as unchecked when user is not admin", () => { - renderDrawer({ user: mockUser }); - expect(screen.getByLabelText(/^admin user$/i)).not.toBeChecked(); - }); - }); - describe("form enabling", () => { it("submit button is enabled when all required fields are filled", async () => { renderDrawer(); @@ -225,41 +143,7 @@ describe("EditUserDrawer", () => { }); }); - describe("password visibility toggle", () => { - it("shows password in plaintext when Show password button is clicked", async () => { - renderDrawer(); - await userEvent.click( - screen.getByRole("button", { name: /show password/i }), - ); - expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( - "type", - "text", - ); - }); - - it("hides password again when Hide password is clicked", async () => { - renderDrawer(); - await userEvent.click( - screen.getByRole("button", { name: /show password/i }), - ); - await userEvent.click( - screen.getByRole("button", { name: /hide password/i }), - ); - expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( - "type", - "password", - ); - }); - }); - describe("namespace limit controls", () => { - it("does not show namespace sub-options by default when max_namespaces is undefined", () => { - renderDrawer({ user: { ...mockUser, max_namespaces: undefined } }); - expect( - screen.queryByLabelText(/disable namespace creation/i), - ).not.toBeInTheDocument(); - }); - it("pre-enables namespace limit when max_namespaces is set", () => { renderDrawer({ user: { ...mockUser, max_namespaces: 5 } }); expect(screen.getByLabelText(/max namespaces/i)).toBeInTheDocument(); @@ -385,35 +269,6 @@ describe("EditUserDrawer", () => { expect(screen.getByText(/failed to update user/i)).toBeInTheDocument(); }); }); - - it("renders error with role='alert'", async () => { - server.use( - http.put("*/admin/api/users/:id", () => HttpResponse.error()), - ); - renderDrawer(); - - await userEvent.click( - screen.getByRole("button", { name: /save changes/i }), - ); - - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - }); - - it("does not call onClose when update fails", async () => { - server.use( - http.put("*/admin/api/users/:id", () => HttpResponse.error()), - ); - const { onClose } = renderDrawer(); - - await userEvent.click( - screen.getByRole("button", { name: /save changes/i }), - ); - - await waitFor(() => screen.getByRole("alert")); - expect(onClose).not.toHaveBeenCalled(); - }); }); describe("client-side validation", () => { @@ -451,20 +306,6 @@ describe("EditUserDrawer", () => { }); }); - describe("cancel", () => { - it("calls onClose when Cancel is clicked", async () => { - const { onClose } = renderDrawer(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call adminUpdateUser when Cancel is clicked", async () => { - renderDrawer(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(updateSpy).not.toHaveBeenCalled(); - }); - }); - describe("state reset on reopen", () => { it("reloads user data when drawer is closed then reopened", async () => { const { rerender } = renderDrawer({ user: mockUser }); @@ -504,13 +345,4 @@ describe("EditUserDrawer", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); }); - - describe("null user", () => { - it("renders the drawer with empty fields when user is null", () => { - renderDrawer({ user: null }); - expect(screen.getByLabelText(/^name$/i)).toHaveValue(""); - expect(screen.getByLabelText(/^username$/i)).toHaveValue(""); - expect(screen.getByLabelText(/^email$/i)).toHaveValue(""); - }); - }); }); diff --git a/ui/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx index 5b3ccaba43f..f8fa9f15258 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx @@ -49,248 +49,54 @@ function renderDialog( } describe("ResetPasswordDialog", () => { - describe("rendering — closed", () => { - it("renders nothing when open is false", () => { - renderDialog({ open: false }); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }); - - describe("rendering — confirm step (initial)", () => { - it("renders the dialog when open is true", () => { - renderDialog(); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); - - it("renders the 'Enable Local Authentication' heading", () => { - renderDialog(); - expect( - screen.getByText("Enable Local Authentication"), - ).toBeInTheDocument(); - }); - - it("renders the explanatory description text", () => { - renderDialog(); - expect(screen.getByText(/temporary password/i)).toBeInTheDocument(); - }); - - it("renders the Enable button", () => { - renderDialog(); - expect( - screen.getByRole("button", { name: /enable/i }), - ).toBeInTheDocument(); - }); - - it("renders the Cancel button", () => { - renderDialog(); - expect( - screen.getByRole("button", { name: /cancel/i }), - ).toBeInTheDocument(); - }); - - it("does not render the password result step content initially", () => { - renderDialog(); - expect(screen.queryByText("Password Generated")).not.toBeInTheDocument(); - }); - }); - - describe("cancel", () => { - it("calls onClose when Cancel is clicked", async () => { - const { onClose } = renderDialog(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call adminResetUserPassword when Cancel is clicked", async () => { - renderDialog(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(resetSpy).not.toHaveBeenCalled(); - }); - }); - - describe("enable flow — success", () => { - it("calls adminResetUserPassword with the correct userId when Enable is clicked", async () => { - setResetResponse("gen-pass-123"); - renderDialog({ userId: "user-abc" }); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => - expect(resetSpy).toHaveBeenCalledWith( - expect.objectContaining({ - path: { id: "user-abc" }, - }), - ), - ); - }); - - it("transitions to the result step after successful reset", async () => { - setResetResponse("gen-pass-123"); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => { - expect(screen.getByText("Password Generated")).toBeInTheDocument(); - }); - }); - - it("displays the generated password in an input field", async () => { - setResetResponse("s3cr3t-pw"); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => { - expect(screen.getByDisplayValue("s3cr3t-pw")).toBeInTheDocument(); - }); - }); - - it("renders the 'Generated password' labelled input", async () => { - setResetResponse("abc"); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => { - expect( - screen.getByLabelText(/generated password/i), - ).toBeInTheDocument(); - }); - }); - - it("renders a Copy button on the result step", async () => { - setResetResponse("abc"); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: /copy/i }), - ).toBeInTheDocument(); - }); - }); - - it("renders a Close button on the result step", async () => { - setResetResponse("abc"); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "Close" }), - ).toBeInTheDocument(); - }); - }); - - it("calls onClose when Close is clicked on result step", async () => { - setResetResponse("abc"); - const { onClose } = renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - await waitFor(() => screen.getByText("Password Generated")); - await userEvent.click(screen.getByRole("button", { name: "Close" })); - - expect(onClose).toHaveBeenCalled(); - }); + it("resets the named user's password and moves to the result step", async () => { + setResetResponse("gen-pass-123"); + renderDialog({ userId: "user-abc" }); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => + expect(resetSpy).toHaveBeenCalledWith( + expect.objectContaining({ path: { id: "user-abc" } }), + ), + ); + expect(screen.getByText("Password Generated")).toBeInTheDocument(); }); describe("enable flow — error states", () => { - it("shows specific error message for status 400 (user already has password)", async () => { - server.use( - http.patch("*/admin/api/users/:id/password/reset", () => - HttpResponse.json({}, { status: 400 }), - ), - ); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => { - expect( - screen.getByText(/already has a local password/i), - ).toBeInTheDocument(); - }); - }); - - it("shows generic error message for non-400 errors", async () => { - server.use( - http.patch("*/admin/api/users/:id/password/reset", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => { - expect(screen.getByText(/failed to set password/i)).toBeInTheDocument(); - }); - }); - - it("shows generic error for non-SDK errors", async () => { + it.each([ + [ + "a 400 (user already has a password)", + () => HttpResponse.json({}, { status: 400 }), + /already has a local password/i, + ], + [ + "a non-400 status", + () => HttpResponse.json({}, { status: 500 }), + /failed to set password/i, + ], + [ + "a network failure", + () => HttpResponse.error(), + /failed to set password/i, + ], + ] as const)("%s reports '%s' and stays on the confirm step", async ( + _label, + resolver, + message, + ) => { server.use( - http.patch("*/admin/api/users/:id/password/reset", () => - HttpResponse.error(), - ), + http.patch("*/admin/api/users/:id/password/reset", resolver), ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); - await waitFor(() => { - expect(screen.getByText(/failed to set password/i)).toBeInTheDocument(); - }); - }); - - it("renders error with role='alert'", async () => { - server.use( - http.patch("*/admin/api/users/:id/password/reset", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - }); - - it("stays on the confirm step when there is an error", async () => { - server.use( - http.patch("*/admin/api/users/:id/password/reset", () => - HttpResponse.json({}, { status: 500 }), - ), + await waitFor(() => + expect(screen.getByText(message)).toBeInTheDocument(), ); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => { - expect(screen.getByText(/failed to set password/i)).toBeInTheDocument(); - }); expect(screen.queryByText("Password Generated")).not.toBeInTheDocument(); }); - - it("clears error and stays on confirm step — Enable button is still visible", async () => { - server.use( - http.patch("*/admin/api/users/:id/password/reset", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /enable/i })); - - await waitFor(() => screen.getByRole("alert")); - expect( - screen.getByRole("button", { name: /enable/i }), - ).toBeInTheDocument(); - }); }); describe("state reset on reopen", () => { diff --git a/ui/apps/console/src/pages/admin/users/__tests__/UserStatusChip.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/UserStatusChip.test.tsx index b17e173f2ce..9451c9b4c7e 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/UserStatusChip.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/UserStatusChip.test.tsx @@ -3,13 +3,11 @@ import { render, screen } from "@testing-library/react"; import UserStatusChip from "../UserStatusChip"; describe("UserStatusChip", () => { - it("renders 'Confirmed' for confirmed status", () => { - render(); - expect(screen.getByText("Confirmed")).toBeInTheDocument(); - }); - - it("renders 'Not Confirmed' for not-confirmed status", () => { - render(); - expect(screen.getByText("Not Confirmed")).toBeInTheDocument(); + it.each([ + ["confirmed", "Confirmed"], + ["not-confirmed", "Not Confirmed"], + ] as const)("renders '%s' status as '%s'", (status, label) => { + render(); + expect(screen.getByText(label)).toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/pages/containers/__tests__/ContainerDetails.test.tsx b/ui/apps/console/src/pages/containers/__tests__/ContainerDetails.test.tsx index 8673048a5c5..923d6dc3453 100644 --- a/ui/apps/console/src/pages/containers/__tests__/ContainerDetails.test.tsx +++ b/ui/apps/console/src/pages/containers/__tests__/ContainerDetails.test.tsx @@ -174,33 +174,17 @@ describe("ContainerDetails", () => { setContainer(); }); - it("renders the container name as a heading", async () => { + it("renders the container's fields, including the SSHID and online status", async () => { renderPage(); expect( await screen.findByRole("heading", { name: "my-container" }), ).toBeInTheDocument(); - }); - - it("renders the MAC address", async () => { - renderPage(); - expect(await screen.findByText("aa:bb:cc:dd:ee:ff")).toBeInTheDocument(); - }); - - it("renders the container image", async () => { - renderPage(); - expect(await screen.findByText("Alpine Linux 3.19")).toBeInTheDocument(); - }); - - it("renders the SSHID built from the namespace and container name", async () => { - renderPage(); + expect(screen.getByText("aa:bb:cc:dd:ee:ff")).toBeInTheDocument(); + expect(screen.getByText("Alpine Linux 3.19")).toBeInTheDocument(); expect( - await screen.findByText("my-namespace.my-container@localhost"), + screen.getByText("my-namespace.my-container@localhost"), ).toBeInTheDocument(); - }); - - it("marks an online container as Online", async () => { - renderPage(); - expect(await screen.findByText("Online")).toBeInTheDocument(); + expect(screen.getByText("Online")).toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/pages/containers/__tests__/Containers.test.tsx b/ui/apps/console/src/pages/containers/__tests__/Containers.test.tsx index c6b037ee9fc..3a80300e9bc 100644 --- a/ui/apps/console/src/pages/containers/__tests__/Containers.test.tsx +++ b/ui/apps/console/src/pages/containers/__tests__/Containers.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, act, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { http, HttpResponse } from "msw"; @@ -49,17 +49,8 @@ vi.mock("@/components/common/TagFilterDropdown", () => ({ default: () =>
, })); -const mockManageTagsDrawer = vi.fn(); vi.mock("@/components/ManageTagsDrawer", () => ({ - default: (props: { - open: boolean; - onClose: () => void; - onTagRenamed?: (oldName: string, newName: string) => void; - onTagDeleted?: (name: string) => void; - }) => { - mockManageTagsDrawer(props); - return
; - }, + default: () =>
, })); vi.mock("@/components/ConnectDrawer", () => ({ @@ -144,344 +135,67 @@ beforeEach(() => { ), ); mockNavigate.mockReset(); - mockManageTagsDrawer.mockReset(); mockRequestAction.mockReset(); }); describe("Containers list", () => { - describe("rendering", () => { - it("renders the page heading", async () => { - renderPage(); - expect( - await screen.findByRole("heading", { name: "Containers" }), - ).toBeInTheDocument(); - }); - - it("renders all status filter tabs", async () => { - renderPage(); - expect( - await screen.findByRole("tab", { name: "Accepted" }), - ).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Pending" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Rejected" })).toBeInTheDocument(); - }); - - it("renders the search input", async () => { - renderPage(); - expect( - await screen.findByPlaceholderText("Search by hostname..."), - ).toBeInTheDocument(); - }); - }); - - describe("loading state", () => { - it("renders the loading message", () => { - server.use( - http.get("*/api/containers", () => new Promise(() => {})), - ); - renderPage(); - expect(screen.getByText("Loading containers...")).toBeInTheDocument(); - }); - }); - - describe("empty state", () => { - it('renders "No containers found" when list is empty', async () => { - renderPage(); - expect( - await screen.findByText("No containers found"), - ).toBeInTheDocument(); - }); - }); - - describe("container rows", () => { - it("renders a row for each container", async () => { - setContainers( - [ - mockContainer({ uid: "uid-1", name: "alpha" }), - mockContainer({ uid: "uid-2", name: "beta" }), - ], - 2, - ); - renderPage(); - expect(await screen.findByText("alpha")).toBeInTheDocument(); - expect(screen.getByText("beta")).toBeInTheDocument(); - }); - - it("navigates to container detail on row click", async () => { - const user = userEvent.setup(); - setContainers( - [mockContainer({ uid: "uid-abc", name: "clickable" })], - 1, - ); - renderPage(); - await user.click(await screen.findByText("clickable")); - expect(mockNavigate).toHaveBeenCalledWith("/containers/uid-abc"); - }); - }); - - describe("error state", () => { - it("renders an error message when the query fails", async () => { - server.use( - http.get("*/api/containers", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - expect( - await screen.findByText("Something went wrong on our side. Try again."), - ).toBeInTheDocument(); - }); + it("navigates to container detail on row click", async () => { + const user = userEvent.setup(); + setContainers([mockContainer({ uid: "uid-abc", name: "clickable" })], 1); + renderPage(); + await user.click(await screen.findByText("clickable")); + expect(mockNavigate).toHaveBeenCalledWith("/containers/uid-abc"); }); - describe("sorting", () => { - it("requests last_seen/desc sort by default", async () => { - renderPage(); - await waitFor(() => { - expect(lastContainersUrl).not.toBeNull(); - expect(lastContainersUrl!.searchParams.get("sort_by")).toBe( - "last_seen", - ); - expect(lastContainersUrl!.searchParams.get("order_by")).toBe("desc"); - }); - }); - - it("toggles sort when the Hostname header is clicked", async () => { - const user = userEvent.setup(); - setContainers([mockContainer({ uid: "uid-1", name: "alpha" })], 1); - renderPage(); - await screen.findByText("alpha"); - - await user.click( - screen.getByRole("button", { name: "Sort by Hostname" }), - ); - await waitFor(() => { - expect(lastContainersUrl!.searchParams.get("sort_by")).toBe("name"); - expect(lastContainersUrl!.searchParams.get("order_by")).toBe("asc"); - }); - - await user.click( - screen.getByRole("button", { name: "Sort by Hostname" }), - ); - await waitFor(() => { - expect(lastContainersUrl!.searchParams.get("sort_by")).toBe("name"); - expect(lastContainersUrl!.searchParams.get("order_by")).toBe("desc"); - }); + it("honours a non-accepted status from the URL", async () => { + renderPage(["/?status=pending"]); + await waitFor(() => { + expect(lastContainersUrl?.searchParams.get("status")).toBe("pending"); }); }); - describe("URL hydration — URL params seed page state on mount", () => { - it("passes status=pending from URL to the SDK", async () => { - renderPage(["/?status=pending&tags=a&tags=b&page=2"]); - await waitFor(() => { - expect(lastContainersUrl).not.toBeNull(); - expect(lastContainersUrl!.searchParams.get("status")).toBe("pending"); - }); - }); - - it("passes tags from URL as a filter to the SDK", async () => { - renderPage(["/?status=pending&tags=a&tags=b&page=2"]); - await waitFor(() => { - expect(lastContainersUrl).not.toBeNull(); - const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).toContain('"a"'); - expect(decoded).toContain('"b"'); - }); - }); - - it("passes page=2 from URL to the SDK", async () => { - renderPage(["/?status=pending&tags=a&tags=b&page=2"]); - await waitFor(() => { - expect(lastContainersUrl).not.toBeNull(); - expect(lastContainersUrl!.searchParams.get("page")).toBe("2"); - }); - }); - - it("falls back to status=accepted and page=1 when URL has no params", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastContainersUrl).not.toBeNull(); - expect(lastContainersUrl!.searchParams.get("status")).toBe("accepted"); - expect(lastContainersUrl!.searchParams.get("page")).toBe("1"); - }); + it("resets page to 1 when a status tab is clicked while on page 2", async () => { + const user = userEvent.setup(); + renderPage(["/?page=2"]); + await waitFor(() => { + expect(lastContainersUrl?.searchParams.get("page")).toBe("2"); }); - it("falls back to status=accepted for an invalid status value", async () => { - renderPage(["/?status=invalid"]); - await waitFor(() => { - expect(lastContainersUrl).not.toBeNull(); - expect(lastContainersUrl!.searchParams.get("status")).toBe("accepted"); - }); - }); + await user.click(screen.getByRole("tab", { name: "Pending" })); - it("passes no tag filter when no tags param is present", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastContainersUrl).not.toBeNull(); - expect(lastContainersUrl!.searchParams.get("filter")).toBeNull(); - }); + await waitFor(() => { + expect(lastContainersUrl!.searchParams.get("page")).toBe("1"); + expect(lastContainersUrl!.searchParams.get("status")).toBe("pending"); }); }); - describe("search — whitespace is trimmed before passing to the SDK", () => { - it("passes trimmed search when input has surrounding spaces", async () => { - const user = userEvent.setup(); - renderPage(); - await screen.findByPlaceholderText("Search by hostname..."); - await user.type( - screen.getByPlaceholderText("Search by hostname..."), - " myhost ", - ); - await waitFor(() => { - const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).toContain("myhost"); - }); - }); - }); - - describe("tag mutation — onTagRenamed/onTagDeleted update URL tags array", () => { - it("renames a tag in filter when onTagRenamed is called from ManageTagsDrawer", async () => { - renderPage(["/?tags=a&tags=b"]); - await waitFor(() => expect(lastContainersUrl).not.toBeNull()); - - const lastCall = mockManageTagsDrawer.mock.calls.at(-1)?.[0] as { - onTagRenamed?: (oldName: string, newName: string) => void; - }; - expect(lastCall?.onTagRenamed).toBeDefined(); - - await act(async () => { - lastCall.onTagRenamed!("a", "alpha"); - }); - - await waitFor(() => { - const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).toContain("alpha"); - expect(decoded).toContain('"b"'); - }); - }); - - it("removes a tag from filter when onTagDeleted is called from ManageTagsDrawer", async () => { - renderPage(["/?tags=a&tags=b"]); - await waitFor(() => expect(lastContainersUrl).not.toBeNull()); - - const lastCall = mockManageTagsDrawer.mock.calls.at(-1)?.[0] as { - onTagDeleted?: (name: string) => void; - }; - expect(lastCall?.onTagDeleted).toBeDefined(); - - await act(async () => { - lastCall.onTagDeleted!("a"); - }); - - await waitFor(() => { - const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).not.toContain('"a"'); - expect(decoded).toContain('"b"'); - }); - }); - - it("hydrates tags from URL into the SDK filter", async () => { - renderPage(["/?tags=existing"]); - await waitFor(() => { - const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).toContain("existing"); - }); - expect( - screen.getByPlaceholderText("Search by hostname..."), - ).toBeInTheDocument(); - }); - }); - - describe("status change — clicking a status tab resets page to 1", () => { - it("resets page to 1 when status tab is clicked while on page 2", async () => { - const user = userEvent.setup(); - renderPage(["/?page=2"]); - await waitFor(() => { - expect(lastContainersUrl).not.toBeNull(); - expect(lastContainersUrl!.searchParams.get("page")).toBe("2"); - }); - - await user.click(screen.getByRole("tab", { name: "Pending" })); - - await waitFor(() => { - expect(lastContainersUrl!.searchParams.get("page")).toBe("1"); - expect(lastContainersUrl!.searchParams.get("status")).toBe("pending"); - }); - }); - }); - - describe("action delegation — action buttons use useContainerActions", () => { - it("calls requestAction(container, 'accept') when Accept is clicked in pending view", async () => { + it.each([ + ["pending", "Accept", "accept"], + ["pending", "Reject", "reject"], + ["rejected", "Remove", "remove"], + ] as const)( + "%s view: clicking %s requests that action for the container", + async (status, buttonName, expectedAction) => { const user = userEvent.setup(); setContainers( [ mockContainer({ - uid: "uid-pending", - name: "pending-box", - status: "pending", + uid: "uid-1", + name: "a-container", + status, online: false, }), ], 1, ); - renderPage(["/?status=pending"]); - await user.click(await screen.findByRole("button", { name: "Accept" })); - expect(mockRequestAction).toHaveBeenCalledWith( - expect.objectContaining({ uid: "uid-pending", name: "pending-box" }), - "accept", - ); - }); + renderPage([`/?status=${status}`]); - it("calls requestAction(container, 'reject') when Reject is clicked in pending view", async () => { - const user = userEvent.setup(); - setContainers( - [ - mockContainer({ - uid: "uid-pending-2", - name: "pending-box-2", - status: "pending", - online: false, - }), - ], - 1, - ); - renderPage(["/?status=pending"]); - await user.click(await screen.findByRole("button", { name: "Reject" })); - expect(mockRequestAction).toHaveBeenCalledWith( - expect.objectContaining({ - uid: "uid-pending-2", - name: "pending-box-2", - }), - "reject", - ); - }); + await user.click(await screen.findByRole("button", { name: buttonName })); - it("calls requestAction(container, 'remove') when Remove is clicked in rejected view", async () => { - const user = userEvent.setup(); - setContainers( - [ - mockContainer({ - uid: "uid-rejected", - name: "rejected-box", - status: "rejected", - online: false, - }), - ], - 1, - ); - renderPage(["/?status=rejected"]); - await user.click(await screen.findByRole("button", { name: "Remove" })); expect(mockRequestAction).toHaveBeenCalledWith( - expect.objectContaining({ - uid: "uid-rejected", - name: "rejected-box", - }), - "remove", + expect.objectContaining({ uid: "uid-1", name: "a-container" }), + expectedAction, ); - }); - }); + }, + ); }); diff --git a/ui/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx b/ui/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx index 50087a6c47f..dcb6c14f212 100644 --- a/ui/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx +++ b/ui/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx @@ -169,34 +169,18 @@ beforeEach(() => { }); describe("DeviceDetails", () => { - describe("loading state", () => { - it("renders a spinner while loading", () => { - server.use(http.get("*/api/devices/:uid", () => new Promise(() => {}))); - renderPage(); - expect(document.querySelector(".animate-spin")).toBeInTheDocument(); - }); - }); - describe("device data", () => { beforeEach(() => { setDevice(makeDevice()); }); - it("renders the device name as a heading", async () => { + it("renders the device's fields", async () => { renderPage(); expect( await screen.findByRole("heading", { name: "my-device" }), ).toBeInTheDocument(); - }); - - it("renders the MAC address", async () => { - renderPage(); - expect(await screen.findByText("aa:bb:cc:dd:ee:ff")).toBeInTheDocument(); - }); - - it("renders the operating system", async () => { - renderPage(); - expect(await screen.findByText("Ubuntu 22.04 LTS")).toBeInTheDocument(); + expect(screen.getByText("aa:bb:cc:dd:ee:ff")).toBeInTheDocument(); + expect(screen.getByText("Ubuntu 22.04 LTS")).toBeInTheDocument(); }); it("names the source a keyless device registered through, linked to its activity", async () => { @@ -219,11 +203,6 @@ describe("DeviceDetails", () => { await screen.findByRole("link", { name: "Tenant-only registration" }), ).toHaveAttribute("href", "/install-keys/legacy-digest/activity"); }); - - it('renders the "Custom Fields" section label', async () => { - renderPage(); - expect(await screen.findByText("Custom Fields")).toBeInTheDocument(); - }); }); describe("custom fields section", () => { @@ -238,44 +217,6 @@ describe("DeviceDetails", () => { expect(screen.getByText("team-a")).toBeInTheDocument(); }); - it("renders the add form inputs", async () => { - setDevice(makeDevice({ custom_fields: {} })); - renderPage(); - expect(await screen.findByPlaceholderText("key")).toBeInTheDocument(); - expect(screen.getByPlaceholderText("value")).toBeInTheDocument(); - }); - - it("shows delete confirmation when the remove button is clicked", async () => { - const user = userEvent.setup(); - setDevice(makeDevice({ custom_fields: { env: "production" } })); - renderPage(); - await screen.findByText("env:"); - - const keyEl = screen.getByText("env:"); - const fieldRow = keyEl.closest("div")!.parentElement!; - const xBtn = within(fieldRow).getByRole("button"); - await user.click(xBtn); - - expect(screen.getByText("Remove?")).toBeInTheDocument(); - expect(screen.getByText("Yes")).toBeInTheDocument(); - expect(screen.getByText("No")).toBeInTheDocument(); - }); - - it("hides the confirmation when 'No' is clicked", async () => { - const user = userEvent.setup(); - setDevice(makeDevice({ custom_fields: { env: "production" } })); - renderPage(); - await screen.findByText("env:"); - - const keyEl = screen.getByText("env:"); - const fieldRow = keyEl.closest("div")!.parentElement!; - const xBtn = within(fieldRow).getByRole("button"); - await user.click(xBtn); - await user.click(screen.getByText("No")); - - expect(screen.queryByText("Remove?")).not.toBeInTheDocument(); - }); - it("calls deleteDeviceCustomField when 'Yes' is clicked", async () => { const user = userEvent.setup(); setDevice( @@ -331,59 +272,28 @@ describe("DeviceDetails", () => { }); describe("action buttons delegate to useDeviceActions", () => { - it("calls requestAction('accept') when Accept is clicked on a pending device", async () => { - const user = userEvent.setup(); - setDevice(makeDevice({ status: "pending", online: false })); - renderPage(); - - await user.click(await screen.findByRole("button", { name: /Accept/i })); - - expect(mockRequestAction).toHaveBeenCalledWith( - expect.objectContaining({ uid: "test-uid" }), - "accept", - ); - }); - - it("calls requestAction('reject') when Reject is clicked on a pending device", async () => { - const user = userEvent.setup(); - setDevice(makeDevice({ status: "pending", online: false })); - renderPage(); - - await user.click(await screen.findByRole("button", { name: /Reject/i })); - - expect(mockRequestAction).toHaveBeenCalledWith( - expect.objectContaining({ uid: "test-uid" }), - "reject", - ); - }); - - it("calls requestAction('remove') when Remove is clicked on a rejected device", async () => { - const user = userEvent.setup(); - setDevice(makeDevice({ status: "rejected", online: false })); - renderPage(); - - await user.click(await screen.findByRole("button", { name: /Remove/i })); - - expect(mockRequestAction).toHaveBeenCalledWith( - expect.objectContaining({ uid: "test-uid" }), - "remove", - ); - }); - - it("calls requestAction('remove') when the Delete device trash button is clicked on an accepted device", async () => { - const user = userEvent.setup(); - setDevice(makeDevice({ status: "accepted", online: true })); - renderPage(); - - await user.click( - await screen.findByRole("button", { name: "Delete device" }), - ); - - expect(mockRequestAction).toHaveBeenCalledWith( - expect.objectContaining({ uid: "test-uid" }), - "remove", - ); - }); + it.each([ + ["pending", /Accept/i, "accept"], + ["pending", /Reject/i, "reject"], + ["rejected", /Remove/i, "remove"], + ["accepted", "Delete device", "remove"], + ] as const)( + "a %s device's %s button requests '%s'", + async (status, buttonName, expectedAction) => { + const user = userEvent.setup(); + setDevice(makeDevice({ status, online: status === "accepted" })); + renderPage(); + + await user.click( + await screen.findByRole("button", { name: buttonName }), + ); + + expect(mockRequestAction).toHaveBeenCalledWith( + expect.objectContaining({ uid: "test-uid" }), + expectedAction, + ); + }, + ); }); describe("onSuccess callback wiring", () => { diff --git a/ui/apps/console/src/pages/devices/__tests__/Devices.test.tsx b/ui/apps/console/src/pages/devices/__tests__/Devices.test.tsx index d473ad4bf8c..2ec875639ef 100644 --- a/ui/apps/console/src/pages/devices/__tests__/Devices.test.tsx +++ b/ui/apps/console/src/pages/devices/__tests__/Devices.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, act, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { http, HttpResponse } from "msw"; @@ -54,17 +54,8 @@ vi.mock("@/components/common/TagFilterDropdown", () => ({ default: () =>
, })); -const mockManageTagsDrawer = vi.fn(); vi.mock("@/components/ManageTagsDrawer", () => ({ - default: (props: { - open: boolean; - onClose: () => void; - onTagRenamed?: (oldName: string, newName: string) => void; - onTagDeleted?: (name: string) => void; - }) => { - mockManageTagsDrawer(props); - return
; - }, + default: () =>
, })); vi.mock("@/components/ConnectDrawer", () => ({ @@ -95,10 +86,7 @@ function renderPage(initialEntries: string[] = ["/"]) { let lastDevicesUrl: URL | null; -function setDevices( - devices: ReturnType[], - total?: number, -) { +function setDevices(devices: ReturnType[], total?: number) { server.use( http.get("*/api/devices", ({ request }) => { lastDevicesUrl = new URL(request.url); @@ -130,256 +118,49 @@ beforeEach(() => { ), ); mockNavigate.mockReset(); - mockManageTagsDrawer.mockReset(); }); describe("Devices list", () => { - describe("rendering", () => { - it("renders the page heading", async () => { - renderPage(); - expect( - await screen.findByRole("heading", { name: "Devices" }), - ).toBeInTheDocument(); - }); - - it("shows the list is the accepted devices, with no status to switch", async () => { - renderPage(); - - expect(await screen.findByText("Accepted")).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Rejected" }), - ).not.toBeInTheDocument(); - }); - - it("links out to the install keys", async () => { - renderPage(); - - expect( - await screen.findByRole("link", { name: "Install Keys" }), - ).toHaveAttribute("href", "/install-keys"); - }); - - it("renders the search input", async () => { - renderPage(); - expect( - await screen.findByPlaceholderText("Search by hostname..."), - ).toBeInTheDocument(); - }); + it("shows the accepted label and the install keys link, not the pending/rejected tabs", async () => { + renderPage(); + expect(await screen.findByText("Accepted")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /Install Keys/ }), + ).toBeInTheDocument(); + expect(screen.queryByText("Pending")).not.toBeInTheDocument(); + expect(screen.queryByText("Rejected")).not.toBeInTheDocument(); }); - describe("loading state", () => { - it("renders the loading message", () => { - server.use( - http.get("*/api/devices", () => new Promise(() => {})), - ); - renderPage(); - expect(screen.getByText("Loading devices...")).toBeInTheDocument(); - }); + it("navigates to device detail on row click", async () => { + const user = userEvent.setup(); + setDevices([mockDevice({ uid: "uid-abc", name: "clickable" })], 1); + renderPage(); + await user.click(await screen.findByText("clickable")); + expect(mockNavigate).toHaveBeenCalledWith("/devices/uid-abc"); }); - describe("empty state", () => { - it('renders "No devices found" when list is empty', async () => { - renderPage(); - expect(await screen.findByText("No devices found")).toBeInTheDocument(); + it("forces status=accepted even when the URL asks for another status", async () => { + renderPage(["/?status=pending"]); + await waitFor(() => { + expect(lastDevicesUrl?.searchParams.get("status")).toBe("accepted"); }); }); - describe("device rows", () => { - it("renders a row for each device", async () => { - setDevices( - [ - mockDevice({ uid: "uid-1", name: "alpha" }), - mockDevice({ uid: "uid-2", name: "beta" }), - ], - 2, - ); - renderPage(); - expect(await screen.findByText("alpha")).toBeInTheDocument(); - expect(screen.getByText("beta")).toBeInTheDocument(); - }); - - it("navigates to device detail on row click", async () => { - const user = userEvent.setup(); - setDevices( - [mockDevice({ uid: "uid-abc", name: "clickable" })], - 1, - ); - renderPage(); - await user.click(await screen.findByText("clickable")); - expect(mockNavigate).toHaveBeenCalledWith("/devices/uid-abc"); - }); - }); - - describe("error state", () => { - it("renders an error message when the query fails", async () => { - server.use( - http.get("*/api/devices", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - expect( - await screen.findByText("Something went wrong on our side. Try again."), - ).toBeInTheDocument(); - }); - }); - - describe("sorting", () => { - it("requests last_seen/desc sort by default", async () => { - renderPage(); - await waitFor(() => { - expect(lastDevicesUrl).not.toBeNull(); - expect(lastDevicesUrl!.searchParams.get("sort_by")).toBe("last_seen"); - expect(lastDevicesUrl!.searchParams.get("order_by")).toBe("desc"); - }); - }); - - it("toggles sort when the Hostname header is clicked", async () => { - const user = userEvent.setup(); - setDevices([mockDevice({ uid: "uid-1", name: "alpha" })], 1); - renderPage(); - await screen.findByText("alpha"); - - await user.click( - screen.getByRole("button", { name: "Sort by Hostname" }), - ); - await waitFor(() => { - expect(lastDevicesUrl!.searchParams.get("sort_by")).toBe("name"); - expect(lastDevicesUrl!.searchParams.get("order_by")).toBe("asc"); - }); - - await user.click( - screen.getByRole("button", { name: "Sort by Hostname" }), - ); - await waitFor(() => { - expect(lastDevicesUrl!.searchParams.get("sort_by")).toBe("name"); - expect(lastDevicesUrl!.searchParams.get("order_by")).toBe("desc"); - }); - }); - }); - - describe("URL hydration — URL params seed page state on mount", () => { - it("enforces status=accepted regardless of URL", async () => { - renderPage(["/?status=pending&tags=a&tags=b&page=2"]); - await waitFor(() => { - expect(lastDevicesUrl?.searchParams.get("status")).toBe("accepted"); - }); - }); - - it("passes tags from URL as a filter to the SDK", async () => { - renderPage(["/?tags=a&tags=b"]); - await waitFor(() => { - expect(lastDevicesUrl).not.toBeNull(); - const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).toContain('"a"'); - expect(decoded).toContain('"b"'); - }); - }); - - it("passes page from URL to the SDK", async () => { - renderPage(["/?page=2"]); - await waitFor(() => { - expect(lastDevicesUrl?.searchParams.get("page")).toBe("2"); - }); - }); - - it("defaults to accepted devices and page 1 when the URL has no params", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastDevicesUrl).not.toBeNull(); - expect(lastDevicesUrl!.searchParams.get("status")).toBe("accepted"); - expect(lastDevicesUrl!.searchParams.get("page")).toBe("1"); - }); - }); - - it("falls back to status=accepted for an invalid status value", async () => { - renderPage(["/?status=invalid"]); - await waitFor(() => { - expect(lastDevicesUrl).not.toBeNull(); - expect(lastDevicesUrl!.searchParams.get("status")).toBe("accepted"); - }); - }); - - it("passes no tag filter when no tags param is present", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastDevicesUrl).not.toBeNull(); - expect(lastDevicesUrl!.searchParams.get("filter")).toBeNull(); - }); - }); - }); - - describe("search — whitespace is trimmed before passing to the SDK", () => { - it("passes trimmed search when input has surrounding spaces", async () => { - const user = userEvent.setup(); - renderPage(); - await screen.findByPlaceholderText("Search by hostname..."); - await user.type( - screen.getByPlaceholderText("Search by hostname..."), - " myhost ", - ); - await waitFor(() => { - const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).toContain("myhost"); - }); - }); - }); - - describe("tag mutation — onTagRenamed/onTagDeleted update URL tags array", () => { - it("renames a tag in filter when onTagRenamed is called from ManageTagsDrawer", async () => { - renderPage(["/?tags=a&tags=b"]); - await waitFor(() => expect(lastDevicesUrl).not.toBeNull()); - - const lastCall = mockManageTagsDrawer.mock.calls.at(-1)?.[0] as { - onTagRenamed?: (oldName: string, newName: string) => void; - }; - expect(lastCall?.onTagRenamed).toBeDefined(); - - await act(async () => { - lastCall.onTagRenamed!("a", "alpha"); - }); - - await waitFor(() => { - const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).toContain("alpha"); - expect(decoded).toContain('"b"'); - }); - }); - - it("removes a tag from filter when onTagDeleted is called from ManageTagsDrawer", async () => { - renderPage(["/?tags=a&tags=b"]); - await waitFor(() => expect(lastDevicesUrl).not.toBeNull()); - - const lastCall = mockManageTagsDrawer.mock.calls.at(-1)?.[0] as { - onTagDeleted?: (name: string) => void; - }; - expect(lastCall?.onTagDeleted).toBeDefined(); - - await act(async () => { - lastCall.onTagDeleted!("a"); - }); + it("pages forward, and sorting a column re-requests it from the first page", async () => { + const user = userEvent.setup(); + setDevices([mockDevice({ uid: "uid-abc", name: "paged" })], 25); + renderPage(); - await waitFor(() => { - const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).not.toContain('"a"'); - expect(decoded).toContain('"b"'); - }); + await user.click(await screen.findByRole("button", { name: "Next page" })); + await waitFor(() => { + expect(lastDevicesUrl?.searchParams.get("page")).toBe("2"); }); - it("hydrates tags from URL into the SDK filter", async () => { - renderPage(["/?tags=existing"]); - await waitFor(() => { - const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; - const decoded = atob(filter); - expect(decoded).toContain("existing"); - }); - expect( - screen.getByPlaceholderText("Search by hostname..."), - ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Sort by Hostname" })); + await waitFor(() => { + expect(lastDevicesUrl?.searchParams.get("sort_by")).toBe("name"); }); + expect(lastDevicesUrl?.searchParams.get("order_by")).toBe("asc"); + expect(lastDevicesUrl?.searchParams.get("page")).toBe("1"); }); }); diff --git a/ui/apps/console/src/pages/firewall-rules/__tests__/FirewallRules.test.tsx b/ui/apps/console/src/pages/firewall-rules/__tests__/FirewallRules.test.tsx index 12d144c6ff1..2f4f04c2e15 100644 --- a/ui/apps/console/src/pages/firewall-rules/__tests__/FirewallRules.test.tsx +++ b/ui/apps/console/src/pages/firewall-rules/__tests__/FirewallRules.test.tsx @@ -17,23 +17,6 @@ vi.mock("@/components/common/ConfirmDialog", async () => ({ default: (await import("@/tests/mocks")).MockConfirmDialog, })); -const capturedDataTableProps: Record[] = []; -vi.mock("@/components/common/DataTable", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - default: (props: Record) => { - capturedDataTableProps.push({ ...props }); - return actual.default( - props as unknown as Parameters[0], - ); - }, - }; -}); - -let lastRulesUrl: URL | null; - function renderPage(initialEntries: string[] = ["/"]) { return render( @@ -45,14 +28,11 @@ function renderPage(initialEntries: string[] = ["/"]) { beforeEach(() => { vi.clearAllMocks(); - capturedDataTableProps.length = 0; - lastRulesUrl = null; useAuthStore.setState({ role: "owner" }); server.use( - http.get("*/api/firewall/rules", ({ request }) => { - lastRulesUrl = new URL(request.url); - return jsonWithTotal([mockFirewallRule({ priority: 42 })]); - }), + http.get("*/api/firewall/rules", () => + jsonWithTotal([mockFirewallRule({ priority: 42 })]), + ), http.delete( "*/api/firewall/rules/:id", () => new HttpResponse(null, { status: 204 }), @@ -78,10 +58,7 @@ describe("FirewallRules — delete error handling", () => { it("shows the mutation error message inside the dialog when deletion fails", async () => { server.use( http.delete("*/api/firewall/rules/:id", () => - HttpResponse.json( - { message: "Permission denied" }, - { status: 403 }, - ), + HttpResponse.json({ message: "Permission denied" }, { status: 403 }), ), ); const user = await openDeleteDialog(); @@ -133,10 +110,7 @@ describe("FirewallRules — delete error handling", () => { http.delete("*/api/firewall/rules/:id", () => { callCount++; if (callCount === 1) - return HttpResponse.json( - { message: "Transient" }, - { status: 500 }, - ); + return HttpResponse.json({ message: "Transient" }, { status: 500 }); return new HttpResponse(null, { status: 204 }); }), ); @@ -161,113 +135,38 @@ describe("FirewallRules — delete error handling", () => { }); }); -describe("FirewallRules — URL hydration", () => { - it("hydrates search from URL on mount", async () => { - renderPage(["/?search=allow"]); - await screen.findByText("42"); - expect( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - ).toHaveValue("allow"); - }); - - it("hydrates page from URL and passes it to the API", async () => { - renderPage(["/?page=3"]); - await waitFor(() => { - expect(lastRulesUrl).not.toBeNull(); - expect(lastRulesUrl!.searchParams.get("page")).toBe("3"); - }); - }); - - it("passes page=1 when URL has no params", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastRulesUrl).not.toBeNull(); - expect(lastRulesUrl!.searchParams.get("page")).toBe("1"); - }); - }); - - it("setSearch resets page to 1 in the URL", async () => { - const user = userEvent.setup(); - renderPage(["/?page=3"]); - - await waitFor(() => { - expect(lastRulesUrl).not.toBeNull(); - expect(lastRulesUrl!.searchParams.get("page")).toBe("3"); - }); - - await user.type( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - "allow", - ); - - await waitFor(() => { - expect(lastRulesUrl!.searchParams.get("page")).toBe("1"); - }); - }); -}); - describe("FirewallRules — pagination suppressed while searching", () => { - it("passes page/totalPages/onPageChange to DataTable when search is empty", async () => { - renderPage(); - await screen.findByText("42"); - const last = capturedDataTableProps.at(-1); - expect(last).toBeDefined(); - expect(last).toHaveProperty("page"); - expect(last).toHaveProperty("totalPages"); - expect(last).toHaveProperty("onPageChange"); - }); - - it("omits page/totalPages/onPageChange from DataTable while search is non-empty", async () => { + it("hides the pagination controls while a search is active and restores them when cleared", async () => { const user = userEvent.setup(); - renderPage(); - - await screen.findByText("42"); - - await user.type( - screen.getByRole("searchbox", { - name: "Search firewall rules by action, priority, IP, or username", - }), - "allow", + server.use( + http.get("*/api/firewall/rules", () => + jsonWithTotal([mockFirewallRule({ priority: 42 })], 30), + ), ); - - await waitFor(() => { - const last = capturedDataTableProps.at(-1); - expect(last).toBeDefined(); - expect(last).not.toHaveProperty("page"); - expect(last).not.toHaveProperty("totalPages"); - expect(last).not.toHaveProperty("onPageChange"); - }); - }); - - it("re-enables pagination props after search is cleared", async () => { - const user = userEvent.setup(); renderPage(); - await screen.findByText("42"); + expect( + screen.getByRole("button", { name: /next page/i }), + ).toBeInTheDocument(); + const searchbox = screen.getByRole("searchbox", { name: "Search firewall rules by action, priority, IP, or username", }); - await user.type(searchbox, "allow"); - await waitFor(() => { - const last = capturedDataTableProps.at(-1); - expect(last).not.toHaveProperty("page"); - }); + await waitFor(() => + expect( + screen.queryByRole("button", { name: /next page/i }), + ).not.toBeInTheDocument(), + ); await user.clear(searchbox); - await waitFor(() => { - const last = capturedDataTableProps.at(-1); - expect(last).toBeDefined(); - expect(last).toHaveProperty("page"); - expect(last).toHaveProperty("totalPages"); - expect(last).toHaveProperty("onPageChange"); - }); + await waitFor(() => + expect( + screen.getByRole("button", { name: /next page/i }), + ).toBeInTheDocument(), + ); }); }); diff --git a/ui/apps/console/src/pages/public-keys/__tests__/KeyDrawer.test.tsx b/ui/apps/console/src/pages/public-keys/__tests__/KeyDrawer.test.tsx index 680b16c58ff..b36aa44f041 100644 --- a/ui/apps/console/src/pages/public-keys/__tests__/KeyDrawer.test.tsx +++ b/ui/apps/console/src/pages/public-keys/__tests__/KeyDrawer.test.tsx @@ -123,13 +123,6 @@ describe("KeyDrawer", () => { ).toBeInTheDocument(); }); - it("shows 'Create Key' on the submit button", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /create key/i }), - ).toBeInTheDocument(); - }); - it("submit is disabled when form is empty", () => { renderDrawer(); expect(getSubmitButton()).toBeDisabled(); @@ -149,13 +142,6 @@ describe("KeyDrawer", () => { ).toBeInTheDocument(); }); - it("shows 'Save Changes' on the submit button", () => { - renderDrawer({ editKey: mockPublicKey() }); - expect( - screen.getByRole("button", { name: /save changes/i }), - ).toBeInTheDocument(); - }); - it("pre-fills the name field from editKey", () => { renderDrawer({ editKey: mockPublicKey({ name: "my-server-key" }) }); expect(screen.getByPlaceholderText(/name used to identify/i)).toHaveValue( diff --git a/ui/apps/console/src/pages/public-keys/__tests__/PublicKeys.test.tsx b/ui/apps/console/src/pages/public-keys/__tests__/PublicKeys.test.tsx index 979dcfcfda1..d13665a226c 100644 --- a/ui/apps/console/src/pages/public-keys/__tests__/PublicKeys.test.tsx +++ b/ui/apps/console/src/pages/public-keys/__tests__/PublicKeys.test.tsx @@ -25,17 +25,14 @@ vi.mock("@/hooks/useDebouncedValue", () => ({ useDebouncedValue: (value: T) => value, })); -let lastKeysUrl: URL | null; - function setKeys( keys: ReturnType[], total?: number, ) { server.use( - http.get("*/api/sshkeys/public-keys", ({ request }) => { - lastKeysUrl = new URL(request.url); - return jsonWithTotal(keys, total ?? keys.length); - }), + http.get("*/api/sshkeys/public-keys", () => + jsonWithTotal(keys, total ?? keys.length), + ), ); } @@ -50,7 +47,6 @@ function renderPage(initialEntries: string[] = ["/"]) { beforeEach(() => { vi.clearAllMocks(); - lastKeysUrl = null; useAuthStore.setState({ role: "owner" }); setKeys([mockPublicKey()]); server.use( @@ -125,75 +121,3 @@ describe("PublicKeys — delete error handling", () => { ); }); }); - -describe("PublicKeys — URL hydration", () => { - it("passes page=3 when URL has ?page=3", async () => { - renderPage(["/?page=3"]); - await waitFor(() => { - expect(lastKeysUrl).not.toBeNull(); - expect(lastKeysUrl!.searchParams.get("page")).toBe("3"); - }); - }); - - it("passes page=1 when URL has no page param", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastKeysUrl).not.toBeNull(); - expect(lastKeysUrl!.searchParams.get("page")).toBe("1"); - }); - }); - - it("passes a filter containing the search term when URL has ?search=mykey", async () => { - renderPage(["/?search=mykey"]); - await waitFor(() => { - expect(lastKeysUrl).not.toBeNull(); - const filter = lastKeysUrl!.searchParams.get("filter") ?? ""; - expect(atob(filter)).toContain("mykey"); - }); - }); - - it("passes no filter when URL has no search param", async () => { - renderPage(["/"]); - await waitFor(() => { - expect(lastKeysUrl).not.toBeNull(); - expect(lastKeysUrl!.searchParams.get("filter")).toBeNull(); - }); - }); -}); - -describe("PublicKeys — URL writes", () => { - it("passes page=2 when the user navigates to page 2", async () => { - const user = userEvent.setup(); - setKeys( - Array.from({ length: 10 }, (_, i) => - mockPublicKey({ fingerprint: `fp-${i}`, name: `key-${i}` }), - ), - 25, - ); - renderPage(); - - await screen.findByText("key-0"); - - await user.click(screen.getByRole("button", { name: "Next page" })); - - await waitFor(() => { - expect(lastKeysUrl!.searchParams.get("page")).toBe("2"); - }); - }); - - it("resets page to 1 when the user types in the search field", async () => { - const user = userEvent.setup(); - renderPage(["/?page=2"]); - - await screen.findByText("my-key"); - - const searchInput = screen.getByPlaceholderText( - /search by name or fingerprint/i, - ); - await user.type(searchInput, "a"); - - await waitFor(() => { - expect(lastKeysUrl!.searchParams.get("page")).toBe("1"); - }); - }); -}); diff --git a/ui/apps/console/src/pages/secure-vault/__tests__/KeyDeleteDialog.test.tsx b/ui/apps/console/src/pages/secure-vault/__tests__/KeyDeleteDialog.test.tsx index 0b62bd48e0e..1113ec1024d 100644 --- a/ui/apps/console/src/pages/secure-vault/__tests__/KeyDeleteDialog.test.tsx +++ b/ui/apps/console/src/pages/secure-vault/__tests__/KeyDeleteDialog.test.tsx @@ -47,20 +47,6 @@ describe("KeyDeleteDialog", () => { expect(screen.getByText("Production Server")).toBeInTheDocument(); }); - it("renders the Delete confirm button", () => { - render(); - expect( - screen.getByRole("button", { name: /delete/i }), - ).toBeInTheDocument(); - }); - - it("renders the Cancel button", () => { - render(); - expect( - screen.getByRole("button", { name: /cancel/i }), - ).toBeInTheDocument(); - }); - it("renders nothing when entry is null", () => { render(); expect(screen.queryByText("Production Server")).not.toBeInTheDocument(); @@ -68,24 +54,18 @@ describe("KeyDeleteDialog", () => { }); describe("cancel", () => { - it("calls onClose when Cancel is clicked", async () => { + it("closes without deleting when Cancel is clicked", async () => { const onClose = vi.fn(); render(); await userEvent.click(screen.getByRole("button", { name: /cancel/i })); expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call removeKey when Cancel is clicked", async () => { - render(); - - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); expect(mockRemoveKey).not.toHaveBeenCalled(); }); }); describe("confirm delete", () => { - it("calls removeKey with the entry id when Delete is confirmed", async () => { + it("removes the entry by id and closes when Delete is confirmed", async () => { mockRemoveKey.mockResolvedValue(undefined); const onClose = vi.fn(); render(); @@ -95,15 +75,6 @@ describe("KeyDeleteDialog", () => { await waitFor(() => { expect(mockRemoveKey).toHaveBeenCalledWith("key-1"); }); - }); - - it("calls onClose after successful deletion", async () => { - mockRemoveKey.mockResolvedValue(undefined); - const onClose = vi.fn(); - render(); - - await userEvent.click(screen.getByRole("button", { name: /delete/i })); - await waitFor(() => { expect(onClose).toHaveBeenCalledTimes(1); }); @@ -119,15 +90,5 @@ describe("KeyDeleteDialog", () => { expect(screen.getByText("Storage full")).toBeInTheDocument(); }); }); - - it("does nothing when entry is null and Delete is clicked", async () => { - render(); - - const deleteBtn = screen.queryByRole("button", { name: /delete/i }); - if (deleteBtn) { - await userEvent.click(deleteBtn); - } - expect(mockRemoveKey).not.toHaveBeenCalled(); - }); }); }); diff --git a/ui/apps/console/src/pages/secure-vault/__tests__/KeyDrawer.test.tsx b/ui/apps/console/src/pages/secure-vault/__tests__/KeyDrawer.test.tsx index 37c6d8b72b1..f68acf208de 100644 --- a/ui/apps/console/src/pages/secure-vault/__tests__/KeyDrawer.test.tsx +++ b/ui/apps/console/src/pages/secure-vault/__tests__/KeyDrawer.test.tsx @@ -127,27 +127,10 @@ describe("KeyDrawer", () => { expect(screen.getByText("Add Private Key")).toBeInTheDocument(); }); - it("renders empty name field initially", () => { - renderDrawer(); - expect(screen.getByLabelText(/^name$/i)).toHaveValue(""); - }); - - it("renders 'Add Key' submit button", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /add key/i }), - ).toBeInTheDocument(); - }); - it("submit button is disabled when form is empty", () => { renderDrawer(); expect(screen.getByRole("button", { name: /add key/i })).toBeDisabled(); }); - - it("does not render passphrase field initially", () => { - renderDrawer(); - expect(screen.queryByLabelText(/passphrase/i)).not.toBeInTheDocument(); - }); }); describe("rendering — edit mode", () => { @@ -368,49 +351,30 @@ describe("KeyDrawer", () => { }); describe("error states — duplicate key", () => { - it("shows name error when DuplicateKeyError with field 'name'", async () => { - mockAddKey.mockRejectedValue(new DuplicateKeyError("name")); - renderDrawer(); - - await fillKey(VALID_KEY); - await fillName("My Key"); - await userEvent.click(screen.getByRole("button", { name: /add key/i })); - - await waitFor(() => { - expect(screen.getByText(/name is already used/i)).toBeInTheDocument(); - }); - }); - - it("shows key error when DuplicateKeyError with field 'private_key'", async () => { - mockAddKey.mockRejectedValue(new DuplicateKeyError("private_key")); - renderDrawer(); - - await fillKey(VALID_KEY); - await fillName("My Key"); - await userEvent.click(screen.getByRole("button", { name: /add key/i })); - - await waitFor(() => { - expect( - screen.getByText(/private key is already stored/i), - ).toBeInTheDocument(); - }); - }); - - it("shows both name and key errors when DuplicateKeyError with field 'both'", async () => { - mockAddKey.mockRejectedValue(new DuplicateKeyError("both")); - renderDrawer(); - - await fillKey(VALID_KEY); - await fillName("My Key"); - await userEvent.click(screen.getByRole("button", { name: /add key/i })); - - await waitFor(() => { - expect(screen.getByText(/name is already used/i)).toBeInTheDocument(); - expect( - screen.getByText(/private key is already stored/i), - ).toBeInTheDocument(); - }); - }); + const NAME_TAKEN = /name is already used/i; + const KEY_TAKEN = /private key is already stored/i; + + it.each([ + ["name", [NAME_TAKEN]], + ["private_key", [KEY_TAKEN]], + ["both", [NAME_TAKEN, KEY_TAKEN]], + ] as const)( + "DuplicateKeyError on '%s' marks the matching field", + async (field, messages) => { + mockAddKey.mockRejectedValue(new DuplicateKeyError(field)); + renderDrawer(); + + await fillKey(VALID_KEY); + await fillName("My Key"); + await userEvent.click(screen.getByRole("button", { name: /add key/i })); + + await waitFor(() => { + messages.forEach((message) => { + expect(screen.getByText(message)).toBeInTheDocument(); + }); + }); + }, + ); }); describe("error states — generic error", () => { @@ -427,64 +391,37 @@ describe("KeyDrawer", () => { }); }); - it("shows passphrase error when getFingerprint throws KeyParseError for encrypted key", async () => { - vi.mocked(validatePrivateKey).mockReturnValue({ - valid: true, - encrypted: true, - }); - const err = new Error("Bad key"); - (err as { name?: string }).name = "KeyParseError"; - vi.mocked(getFingerprint).mockImplementation(() => { - throw err; - }); - - renderDrawer(); - await fillKey(VALID_KEY); - await fillName("My Key"); - await userEvent.type(screen.getByLabelText(/passphrase/i), "wrongpass"); - await userEvent.click(screen.getByRole("button", { name: /add key/i })); - - await waitFor(() => { - expect(screen.getByText(/incorrect passphrase/i)).toBeInTheDocument(); - }); - }); - - it("shows passphrase error when getFingerprint throws generic error for encrypted key", async () => { - vi.mocked(validatePrivateKey).mockReturnValue({ - valid: true, - encrypted: true, - }); - vi.mocked(getFingerprint).mockImplementation(() => { - throw new Error("Decryption failed"); - }); - - renderDrawer(); - await fillKey(VALID_KEY); - await fillName("My Key"); - await userEvent.type(screen.getByLabelText(/passphrase/i), "wrongpass"); - await userEvent.click(screen.getByRole("button", { name: /add key/i })); - - await waitFor(() => { - expect(screen.getByText(/could not decrypt key/i)).toBeInTheDocument(); - }); - }); - - it("shows key error when getFingerprint throws for unencrypted key", async () => { - vi.mocked(getFingerprint).mockImplementation(() => { - throw new Error("Unreadable"); - }); - - renderDrawer(); - await fillKey(VALID_KEY); - await fillName("My Key"); - await userEvent.click(screen.getByRole("button", { name: /add key/i })); + it.each([ + [true, "KeyParseError", /incorrect passphrase/i], + [true, "Error", /could not decrypt key/i], + [false, "Error", /failed to read private key/i], + ] as const)( + "encrypted=%s with a %s from getFingerprint reports '%s'", + async (encrypted, errorName, message) => { + vi.mocked(validatePrivateKey).mockReturnValue({ + valid: true, + encrypted, + }); + vi.mocked(getFingerprint).mockImplementation(() => { + throw Object.assign(new Error("Unreadable"), { name: errorName }); + }); - await waitFor(() => { - expect( - screen.getByText(/failed to read private key/i), - ).toBeInTheDocument(); - }); - }); + renderDrawer(); + await fillKey(VALID_KEY); + await fillName("My Key"); + if (encrypted) { + await userEvent.type( + screen.getByLabelText(/passphrase/i), + "wrongpass", + ); + } + await userEvent.click(screen.getByRole("button", { name: /add key/i })); + + await waitFor(() => { + expect(screen.getByText(message)).toBeInTheDocument(); + }); + }, + ); }); describe("cancel", () => { diff --git a/ui/apps/console/src/pages/secure-vault/__tests__/SecureVault.test.tsx b/ui/apps/console/src/pages/secure-vault/__tests__/SecureVault.test.tsx index cee6c891154..9b5b5002cc8 100644 --- a/ui/apps/console/src/pages/secure-vault/__tests__/SecureVault.test.tsx +++ b/ui/apps/console/src/pages/secure-vault/__tests__/SecureVault.test.tsx @@ -303,57 +303,6 @@ beforeEach(() => { describe("SecureVault", () => { describe("uninitialized state", () => { - it("renders the Secure Vault setup landing page", () => { - setupStore("uninitialized"); - render(); - expect(screen.getByText("Secure Vault")).toBeInTheDocument(); - }); - - it("shows the 'Set Up Secure Vault' button", () => { - setupStore("uninitialized"); - render(); - expect( - screen.getByRole("button", { name: /set up secure vault/i }), - ).toBeInTheDocument(); - }); - - it("shows feature highlights: AES-256 Encryption, Zero Knowledge, Quick Connect", () => { - setupStore("uninitialized"); - render(); - expect(screen.getByText("AES-256 Encryption")).toBeInTheDocument(); - expect(screen.getByText("Zero Knowledge")).toBeInTheDocument(); - expect(screen.getByText("Quick Connect")).toBeInTheDocument(); - }); - - it("opens the setup dialog when 'Set Up Secure Vault' is clicked", async () => { - setupStore("uninitialized"); - render(); - - await userEvent.click( - screen.getByRole("button", { name: /set up secure vault/i }), - ); - - expect( - screen.getByRole("dialog", { name: /setup vault/i }), - ).toBeInTheDocument(); - }); - - it("closes the setup dialog when onClose is triggered", async () => { - setupStore("uninitialized"); - render(); - - await userEvent.click( - screen.getByRole("button", { name: /set up secure vault/i }), - ); - await userEvent.click( - screen.getByRole("button", { name: /close setup/i }), - ); - - expect( - screen.queryByRole("dialog", { name: /setup vault/i }), - ).not.toBeInTheDocument(); - }); - it("calls refreshStatus on mount", () => { setupStore("uninitialized"); render(); @@ -362,46 +311,6 @@ describe("SecureVault", () => { }); describe("locked state", () => { - it("renders the full-screen locked page with heading and highlights", () => { - setupStore("locked"); - render(); - expect( - screen.getByRole("heading", { name: /your vault is locked/i }), - ).toBeInTheDocument(); - expect(screen.getByText("AES-256 Encryption")).toBeInTheDocument(); - expect(screen.getByText("Zero Knowledge")).toBeInTheDocument(); - expect(screen.getByText("Quick Connect")).toBeInTheDocument(); - }); - - it("opens the unlock dialog when 'Unlock Vault' is clicked", async () => { - setupStore("locked"); - render(); - - await userEvent.click( - screen.getByRole("button", { name: /unlock vault/i }), - ); - - expect( - screen.getByRole("dialog", { name: /unlock vault/i }), - ).toBeInTheDocument(); - }); - - it("closes the unlock dialog when onClose is triggered", async () => { - setupStore("locked"); - render(); - - await userEvent.click( - screen.getByRole("button", { name: /unlock vault/i }), - ); - await userEvent.click( - screen.getByRole("button", { name: /close unlock/i }), - ); - - expect( - screen.queryByRole("dialog", { name: /unlock vault/i }), - ).not.toBeInTheDocument(); - }); - it("does not render the keys table", () => { setupStore("locked"); render(); @@ -409,51 +318,6 @@ describe("SecureVault", () => { }); }); - describe("unlocked state — empty vault", () => { - it("renders the empty state message", () => { - setupStore("unlocked", []); - render(); - expect(screen.getByText(/no keys yet/i)).toBeInTheDocument(); - }); - - it("renders 'Add Private Key' button in empty state", () => { - setupStore("unlocked", []); - render(); - expect( - screen.getByRole("button", { name: /add private key/i }), - ).toBeInTheDocument(); - }); - - it("opens the Add Key drawer when 'Add Private Key' is clicked", async () => { - setupStore("unlocked", []); - render(); - - await userEvent.click( - screen.getByRole("button", { name: /add private key/i }), - ); - - expect( - screen.getByRole("dialog", { name: /add private key/i }), - ).toBeInTheDocument(); - }); - - it("closes the Add Key drawer when onClose is triggered", async () => { - setupStore("unlocked", []); - render(); - - await userEvent.click( - screen.getByRole("button", { name: /add private key/i }), - ); - await userEvent.click( - screen.getByRole("button", { name: /close drawer/i }), - ); - - expect( - screen.queryByRole("dialog", { name: /add private key/i }), - ).not.toBeInTheDocument(); - }); - }); - describe("unlocked state — with keys", () => { const keys = [ makeKey({ @@ -469,14 +333,6 @@ describe("SecureVault", () => { }), ]; - it("renders a table with a row per key", () => { - setupStore("unlocked", keys); - render(); - expect(screen.getByRole("table")).toBeInTheDocument(); - expect(screen.getByText("Production Server")).toBeInTheDocument(); - expect(screen.getByText("Staging Server")).toBeInTheDocument(); - }); - it("shows lock icon for keys with passphrase", () => { setupStore("unlocked", keys); render(); @@ -496,54 +352,6 @@ describe("SecureVault", () => { expect(screen.queryByTitle("Encrypted")).not.toBeInTheDocument(); }); - it("renders 'Add Private Key' button in header", () => { - setupStore("unlocked", keys); - render(); - expect( - screen.getByRole("button", { name: /add private key/i }), - ).toBeInTheDocument(); - }); - - it("opens the Add Key drawer from the header button", async () => { - setupStore("unlocked", keys); - render(); - - await userEvent.click( - screen.getByRole("button", { name: /add private key/i }), - ); - - expect( - screen.getByRole("dialog", { name: /add private key/i }), - ).toBeInTheDocument(); - }); - - it("opens the Edit Key drawer when the Edit button is clicked", async () => { - setupStore("unlocked", keys); - render(); - - const editButtons = screen.getAllByTitle(/edit/i); - await userEvent.click(editButtons[0]); - - expect( - screen.getByRole("dialog", { name: /edit private key/i }), - ).toBeInTheDocument(); - }); - - it("closes the Edit Key drawer after onClose", async () => { - setupStore("unlocked", keys); - render(); - - const editButtons = screen.getAllByTitle(/edit/i); - await userEvent.click(editButtons[0]); - await userEvent.click( - screen.getByRole("button", { name: /close drawer/i }), - ); - - expect( - screen.queryByRole("dialog", { name: /edit private key/i }), - ).not.toBeInTheDocument(); - }); - it("opens the Delete dialog with the correct entry when Delete is clicked", async () => { setupStore("unlocked", keys); render(); @@ -555,21 +363,6 @@ describe("SecureVault", () => { expect(dialog).toBeInTheDocument(); expect(dialog).toHaveTextContent("Production Server"); }); - - it("closes the Delete dialog when onClose is triggered", async () => { - setupStore("unlocked", keys); - render(); - - const deleteButtons = screen.getAllByTitle(/delete/i); - await userEvent.click(deleteButtons[0]); - await userEvent.click( - screen.getByRole("button", { name: /close delete/i }), - ); - - expect( - screen.queryByRole("dialog", { name: /delete key dialog/i }), - ).not.toBeInTheDocument(); - }); }); describe("search filtering", () => { @@ -586,13 +379,6 @@ describe("SecureVault", () => { }), ]; - it("shows all rows when search is empty", () => { - setupStore("unlocked", keys); - render(); - expect(screen.getByText("Production Server")).toBeInTheDocument(); - expect(screen.getByText("Staging Server")).toBeInTheDocument(); - }); - it("filters rows by name", async () => { setupStore("unlocked", keys); render(); @@ -644,30 +430,6 @@ describe("SecureVault", () => { }); }); - describe("VaultSettingsSection", () => { - it("renders the settings section when unlocked", () => { - setupStore("unlocked", []); - render(); - expect(screen.getByTestId("vault-settings-section")).toBeInTheDocument(); - }); - - it("does not render settings section in uninitialized state", () => { - setupStore("uninitialized"); - render(); - expect( - screen.queryByTestId("vault-settings-section"), - ).not.toBeInTheDocument(); - }); - - it("does not render settings section in locked state", () => { - setupStore("locked"); - render(); - expect( - screen.queryByTestId("vault-settings-section"), - ).not.toBeInTheDocument(); - }); - }); - describe("auto-lock nonce — auto-open unlock dialog", () => { it("opens VaultUnlockDialog when autoLockNonce bumps while mounted (unlocked → locked)", () => { setupStore("unlocked", [], 0); diff --git a/ui/apps/console/src/pages/sessions/__tests__/SessionTypeBadge.test.tsx b/ui/apps/console/src/pages/sessions/__tests__/SessionTypeBadge.test.tsx index ce84c938104..f8f87d935f6 100644 --- a/ui/apps/console/src/pages/sessions/__tests__/SessionTypeBadge.test.tsx +++ b/ui/apps/console/src/pages/sessions/__tests__/SessionTypeBadge.test.tsx @@ -3,35 +3,23 @@ import { render, screen } from "@testing-library/react"; import SessionTypeBadge from "../SessionTypeBadge"; describe("SessionTypeBadge", () => { - it("renders 'sftp' for subsystem type", () => { - render(); - expect(screen.getByText("sftp")).toBeInTheDocument(); - }); - - it("renders 'exec' for exec type", () => { - render(); - expect(screen.getByText("exec")).toBeInTheDocument(); - }); - - it("renders 'shell' for shell type", () => { - render(); - expect(screen.getByText("shell")).toBeInTheDocument(); + it.each([ + [["subsystem"], "sftp"], + [["exec"], "exec"], + [["shell"], "shell"], + [["pty-req"], "shell"], + ])("renders %j as '%s'", (types, label) => { + render(); + expect(screen.getByText(label)).toBeInTheDocument(); }); - it("renders 'shell' for pty-req type", () => { - render(); - expect(screen.getByText("shell")).toBeInTheDocument(); - }); - - it("renders nothing for unknown types", () => { - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); - - it("renders nothing for empty types array", () => { - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); + it.each([[["unknown"]], [[] as string[]]])( + "renders nothing for %j", + (types) => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }, + ); it("prefers 'sftp' when subsystem type is present alongside others", () => { render(); diff --git a/ui/apps/console/src/pages/sessions/__tests__/Sessions.test.tsx b/ui/apps/console/src/pages/sessions/__tests__/Sessions.test.tsx index 9cdf6eb39fc..3e9acf080df 100644 --- a/ui/apps/console/src/pages/sessions/__tests__/Sessions.test.tsx +++ b/ui/apps/console/src/pages/sessions/__tests__/Sessions.test.tsx @@ -7,7 +7,6 @@ import { server, jsonWithTotal } from "@/tests/msw"; import Sessions from "../index"; import { createTestWrapper } from "@/tests/wrapper"; import { mockSession } from "@/tests/factories"; -import { LocationProbe } from "@/tests/LocationProbe"; const mockNavigate = vi.hoisted(() => vi.fn()); @@ -27,39 +26,28 @@ vi.mock("../SessionPlayerDialog", () => ({ ) : null, })); -let lastSessionsUrl: URL | null; - function setSessions( sessions: ReturnType[], total?: number, ) { server.use( - http.get("*/api/sessions", ({ request }) => { - lastSessionsUrl = new URL(request.url); - return jsonWithTotal(sessions, total ?? sessions.length); - }), + http.get("*/api/sessions", () => + jsonWithTotal(sessions, total ?? sessions.length), + ), ); } function renderSessions(initialEntries: string[] = ["/"]) { - let lastSearch = ""; - const result = render( + return render( - { - lastSearch = s; - }} - /> , { wrapper: createTestWrapper() }, ); - return { ...result, getSearch: () => lastSearch }; } beforeEach(() => { vi.clearAllMocks(); - lastSessionsUrl = null; setSessions([]); server.use( http.post( @@ -112,32 +100,9 @@ describe("Sessions", () => { await screen.findByText("Failed to load recording"), ).toBeInTheDocument(); }); - - it("does not show the error banner when error is null", async () => { - renderSessions(); - await screen.findByText("No sessions found"); - expect( - screen.queryByText("Failed to load recording"), - ).not.toBeInTheDocument(); - }); }); describe("play recording", () => { - it("fetches the recording when Play is clicked", async () => { - const user = userEvent.setup(); - server.use( - http.get("*/api/sessions/:uid/records/:seat", () => - HttpResponse.json("asciicast-data"), - ), - ); - setSessions([mockSession({ uid: "session-1", recorded: true })]); - renderSessions(); - - await user.click(await screen.findByTitle("Play recording")); - - expect(await screen.findByTestId("player-dialog")).toBeInTheDocument(); - }); - it("disables the play button while the recording is loading", async () => { const user = userEvent.setup(); server.use( @@ -183,49 +148,4 @@ describe("Sessions", () => { ); }); }); - - describe("URL hydration", () => { - it("passes page=3 when URL has ?page=3", async () => { - renderSessions(["/?page=3"]); - await waitFor(() => { - expect(lastSessionsUrl).not.toBeNull(); - expect(lastSessionsUrl!.searchParams.get("page")).toBe("3"); - }); - }); - - it("passes page=1 when URL has no page param", async () => { - renderSessions(["/"]); - await waitFor(() => { - expect(lastSessionsUrl).not.toBeNull(); - expect(lastSessionsUrl!.searchParams.get("page")).toBe("1"); - }); - }); - }); - - describe("URL writes", () => { - it("writes ?page=2 to the URL when the user navigates to page 2", async () => { - const user = userEvent.setup(); - setSessions( - Array.from({ length: 10 }, (_, i) => - mockSession({ uid: `s-${i}`, username: `u-${i}` }), - ), - 30, - ); - renderSessions(); - - await screen.findByText("u-0"); - - await user.click(screen.getByRole("button", { name: "Next page" })); - - await waitFor(() => { - expect(lastSessionsUrl!.searchParams.get("page")).toBe("2"); - }); - }); - - it("omits ?page from the URL when on the default page 1", () => { - const { getSearch } = renderSessions(["/"]); - const sp = new URLSearchParams(getSearch()); - expect(sp.get("page")).toBeNull(); - }); - }); }); diff --git a/ui/apps/console/src/pages/team/__tests__/AddMemberDrawer.test.tsx b/ui/apps/console/src/pages/team/__tests__/AddMemberDrawer.test.tsx index 7becab1ee9b..ddac0dd1e51 100644 --- a/ui/apps/console/src/pages/team/__tests__/AddMemberDrawer.test.tsx +++ b/ui/apps/console/src/pages/team/__tests__/AddMemberDrawer.test.tsx @@ -24,6 +24,8 @@ vi.mock("@/utils/styles", () => ({ INPUT_MONO_ERROR: "input-mono-error", })); +const INVITE_LINK = "https://shellhub.example.com/invite/abc123"; + const mockGetConfig = vi.mocked(getConfig); function renderDrawer(open = true, onClose = vi.fn(), tenantId = "t1") { @@ -64,85 +66,35 @@ beforeEach(() => { }); describe("AddMemberDrawer", () => { - describe("rendering", () => { - it("renders the Add Member title when open", () => { - renderDrawer(); - expect( - screen.getByRole("heading", { name: /add member/i }), - ).toBeInTheDocument(); - }); - - it("renders nothing when closed", () => { - const { container } = renderDrawer(false); - expect(container).toBeEmptyDOMElement(); - }); - - it("renders the email input", () => { - renderDrawer(); - expect( - screen.getByPlaceholderText(/user@example.com/i), - ).toBeInTheDocument(); - }); - - it("has no delivery-choice checkbox — the flow always both emails and returns a link", () => { - renderDrawer(); - expect( - screen.queryByRole("checkbox", { name: /link instead/i }), - ).not.toBeInTheDocument(); - }); + it("has no delivery-choice checkbox — the flow always both emails and returns a link", () => { + renderDrawer(); + expect( + screen.queryByRole("checkbox", { name: /link instead/i }), + ).not.toBeInTheDocument(); }); describe("submit", () => { - it("always generates the invitation link (single channel)", async () => { - setInviteResponse("https://shellhub.example/accept-invite?invite=abc"); - const user = userEvent.setup(); - renderDrawer(true, vi.fn(), "t1"); - await submit(user, "bob@example.com"); - - await waitFor(() => - expect( - screen.getByText( - "https://shellhub.example/accept-invite?invite=abc", - ), - ).toBeInTheDocument(), - ); - }); - it("shows the invitation link and copy button after generation", async () => { - const generatedLink = "https://shellhub.example.com/invite/abc123"; - setInviteResponse(generatedLink); + setInviteResponse(INVITE_LINK); const user = userEvent.setup(); renderDrawer(); await submit(user); await waitFor(() => - expect(screen.getByText(generatedLink)).toBeInTheDocument(), + expect(screen.getByText(INVITE_LINK)).toBeInTheDocument(), ); expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument(); - expect( - screen.getByRole("heading", { name: /invitation link/i }), - ).toBeInTheDocument(); }); - it("mentions the email on cloud", async () => { - setInviteResponse("https://shellhub.example.com/invite/abc123"); - const user = userEvent.setup(); - renderDrawer(); - await submit(user); - - await waitFor(() => - expect( - screen.getByText(/we emailed the invitation/i), - ).toBeInTheDocument(), - ); - }); - - it("does not mention email on a non-cloud edition (link-only)", async () => { - mockGetConfig.mockReturnValue({ - ...defaultConfig, - edition: "enterprise", - }); - setInviteResponse("https://shellhub.example.com/invite/abc123"); + it.each([ + ["cloud", true], + ["enterprise", false], + ] as const)("the %s result screen mentions the email: %s", async ( + edition, + mentionsEmail, + ) => { + mockGetConfig.mockReturnValue({ ...defaultConfig, edition }); + setInviteResponse(INVITE_LINK); const user = userEvent.setup(); renderDrawer(); await submit(user); @@ -152,9 +104,9 @@ describe("AddMemberDrawer", () => { screen.getByRole("heading", { name: /invitation link/i }), ).toBeInTheDocument(), ); - expect( - screen.queryByText(/we emailed the invitation/i), - ).not.toBeInTheDocument(); + const email = screen.queryByText(/we emailed the invitation/i); + if (mentionsEmail) expect(email).toBeInTheDocument(); + else expect(email).not.toBeInTheDocument(); }); it("shows 'Member Added' when an existing account is added directly (no link)", async () => { @@ -173,7 +125,7 @@ describe("AddMemberDrawer", () => { }); it("does not close on success — the result screen stays until 'Done'", async () => { - setInviteResponse("https://shellhub.example.com/invite/abc123"); + setInviteResponse(INVITE_LINK); const onClose = vi.fn(); const user = userEvent.setup(); renderDrawer(true, onClose, "t1"); @@ -204,20 +156,14 @@ describe("AddMemberDrawer", () => { expect(apiCalled).not.toHaveBeenCalled(); }); - it("disables the submit button when email field is empty", () => { - renderDrawer(); - expect( - screen.getByRole("button", { name: /add member/i }), - ).toBeDisabled(); - }); - - it("disables the submit button when email is invalid (non-empty)", async () => { + it.each([ + ["empty", ""], + ["invalid", "not-an-email"], + ])("disables the submit button when email is %s", async (_label, email) => { const user = userEvent.setup(); renderDrawer(); - await user.type( - screen.getByPlaceholderText(/user@example.com/i), - "not-an-email", - ); + if (email) + await user.type(screen.getByPlaceholderText(/user@example.com/i), email); expect( screen.getByRole("button", { name: /add member/i }), ).toBeDisabled(); @@ -225,66 +171,20 @@ describe("AddMemberDrawer", () => { }); describe("error handling", () => { - it("shows 400 error as invalid email/role message", async () => { - setInviteError(400); - const user = userEvent.setup(); - renderDrawer(); - await submit(user); - - await waitFor(() => - expect(screen.getByText(/invalid email or role/i)).toBeInTheDocument(), - ); - }); - - it("shows 403 error as permission denied message", async () => { - setInviteError(403); - const user = userEvent.setup(); - renderDrawer(); - await submit(user); - - await waitFor(() => - expect( - screen.getByText(/don't have permission to invite/i), - ).toBeInTheDocument(), - ); - }); - - it("shows 404 error as no account message", async () => { - setInviteError(404); - const user = userEvent.setup(); - renderDrawer(); - await submit(user); - - await waitFor(() => - expect( - screen.getByText(/no account exists for this email/i), - ).toBeInTheDocument(), - ); - }); - - it("shows 409 error as already member message", async () => { - setInviteError(409); + it.each([ + [400, /invalid email or role/i], + [403, /don't have permission to invite/i], + [404, /no account exists for this email/i], + [409, /already a member or has a pending invitation/i], + [500, /failed to send invitation/i], + ])("a %i response reports '%s'", async (status, message) => { + setInviteError(status); const user = userEvent.setup(); renderDrawer(); await submit(user); await waitFor(() => - expect( - screen.getByText(/already a member or has a pending invitation/i), - ).toBeInTheDocument(), - ); - }); - - it("shows generic error for unexpected status codes", async () => { - setInviteError(500); - const user = userEvent.setup(); - renderDrawer(); - await submit(user); - - await waitFor(() => - expect( - screen.getByText(/failed to send invitation/i), - ).toBeInTheDocument(), + expect(screen.getByText(message)).toBeInTheDocument(), ); }); diff --git a/ui/apps/console/src/pages/team/__tests__/ApiKeysTab.test.tsx b/ui/apps/console/src/pages/team/__tests__/ApiKeysTab.test.tsx index 0f081461a3a..8f71b433acc 100644 --- a/ui/apps/console/src/pages/team/__tests__/ApiKeysTab.test.tsx +++ b/ui/apps/console/src/pages/team/__tests__/ApiKeysTab.test.tsx @@ -99,36 +99,6 @@ describe("ApiKeysTab — pagination count display", () => { }); }); -describe("ApiKeysTab — sorting", () => { - it("requests created_at/desc sort by default", async () => { - renderTab(); - await screen.findByText("prod-key"); - await waitFor(() => { - expect(lastApiKeysUrl).not.toBeNull(); - expect(lastApiKeysUrl!.searchParams.get("sort_by")).toBe("created_at"); - expect(lastApiKeysUrl!.searchParams.get("order_by")).toBe("desc"); - }); - }); - - it("toggles sort when the Name header is clicked", async () => { - const user = userEvent.setup(); - renderTab(); - await screen.findByText("prod-key"); - - await user.click(screen.getByRole("button", { name: "Sort by Name" })); - await waitFor(() => { - expect(lastApiKeysUrl!.searchParams.get("sort_by")).toBe("name"); - expect(lastApiKeysUrl!.searchParams.get("order_by")).toBe("asc"); - }); - - await user.click(screen.getByRole("button", { name: "Sort by Name" })); - await waitFor(() => { - expect(lastApiKeysUrl!.searchParams.get("sort_by")).toBe("name"); - expect(lastApiKeysUrl!.searchParams.get("order_by")).toBe("desc"); - }); - }); -}); - describe("ApiKeysTab — delete error handling", () => { async function openDeleteDialog() { const user = userEvent.setup(); @@ -193,35 +163,6 @@ describe("ApiKeysTab — delete error handling", () => { }); describe("ApiKeysTab — URL sync with prefix 'key'", () => { - it("hydrates page from ?key.page=3 — API receives page 3", async () => { - renderTab(["/?key.page=3"]); - await waitFor(() => { - expect(lastApiKeysUrl).not.toBeNull(); - expect(lastApiKeysUrl!.searchParams.get("page")).toBe("3"); - }); - }); - - it("clicking Next writes key.page=2 to the URL (not bare page=2)", async () => { - const user = userEvent.setup(); - setApiKeys( - Array.from({ length: 10 }, (_, i) => - mockApiKey({ name: `key-${i}`, created_by: `user-${i}` }), - ), - 25, - ); - const { getSearch } = renderTab(); - - await screen.findByText("key-0"); - - await user.click(screen.getByRole("button", { name: /next/i })); - - await waitFor(() => { - const sp = new URLSearchParams(getSearch()); - expect(sp.get("key.page")).toBe("2"); - expect(sp.get("page")).toBeNull(); - }); - }); - it("does not consume a bare ?page=5 param as key.page — API receives page 1", async () => { renderTab(["/?page=5"]); await waitFor(() => {