diff --git a/dashboard/frontend/src/components/OnboardingWizard.test.tsx b/dashboard/frontend/src/components/OnboardingWizard.test.tsx
new file mode 100644
index 0000000..ae7dcbc
--- /dev/null
+++ b/dashboard/frontend/src/components/OnboardingWizard.test.tsx
@@ -0,0 +1,255 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "react-query";
+import type { AxiosResponse } from "axios";
+import { MemoryRouter } from "react-router-dom";
+import OnboardingWizard from "./OnboardingWizard";
+import { onboardingAPI, suitesAPI } from "../api/client";
+import toast from "react-hot-toast";
+
+// localStorage is not populated as a global by this vitest+jsdom combo, so
+// the real authStore (zustand persist) cannot run in tests; mock the module.
+const { mockSetNeedsOnboarding } = vi.hoisted(() => ({
+ mockSetNeedsOnboarding: vi.fn(),
+}));
+
+vi.mock("../stores/authStore", () => ({
+ default: () => ({ setNeedsOnboarding: mockSetNeedsOnboarding }),
+}));
+
+vi.mock("../api/client", () => ({
+ onboardingAPI: {
+ getState: vi.fn(),
+ updateStep: vi.fn(),
+ complete: vi.fn(),
+ skip: vi.fn(),
+ },
+ suitesAPI: {
+ create: vi.fn(),
+ },
+ executionsAPI: {},
+}));
+
+vi.mock("react-hot-toast", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+const mockGetState = vi.mocked(onboardingAPI.getState);
+const mockUpdateStep = vi.mocked(onboardingAPI.updateStep);
+const mockComplete = vi.mocked(onboardingAPI.complete);
+const mockSkip = vi.mocked(onboardingAPI.skip);
+const mockCreateSuite = vi.mocked(suitesAPI.create);
+
+const renderWizard = (onComplete: () => void = vi.fn()) => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+
+
+ ,
+ );
+};
+
+const pendingState = () => {
+ mockGetState.mockImplementation(() => new Promise(() => {}));
+};
+
+const freshState = () => {
+ mockGetState.mockResolvedValue({
+ data: { completed: false, current_step: 0, steps: {} },
+ } as any);
+};
+
+describe("OnboardingWizard", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ freshState();
+ mockUpdateStep.mockResolvedValue({ data: {} } as any);
+ mockComplete.mockResolvedValue({ data: {} } as any);
+ mockSkip.mockResolvedValue({ data: {} } as any);
+ mockCreateSuite.mockResolvedValue({ data: {} } as any);
+ });
+
+ it("renders without crash and shows all five steps after loading", async () => {
+ renderWizard();
+
+ expect(
+ await screen.findByText(/Welcome to QA-FRAMEWORK/),
+ ).toBeInTheDocument();
+ // step labels live in the Stepper; "Notifications" also appears in the
+ // welcome step content, so scope the query to the StepLabel spans
+ const stepLabel = { selector: "span.MuiStepLabel-label" };
+ expect(screen.getByText("Welcome", stepLabel)).toBeInTheDocument();
+ expect(screen.getByText("Connect Repo", stepLabel)).toBeInTheDocument();
+ expect(screen.getByText("Create Suite", stepLabel)).toBeInTheDocument();
+ expect(screen.getByText("Run Test", stepLabel)).toBeInTheDocument();
+ expect(screen.getByText("Notifications", stepLabel)).toBeInTheDocument();
+ });
+
+ it("shows a loading spinner while the onboarding state is being fetched", () => {
+ pendingState();
+ renderWizard();
+
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
+ });
+
+ it("calls onComplete immediately when onboarding is already completed", async () => {
+ const onComplete = vi.fn();
+ mockGetState.mockResolvedValue({
+ data: { completed: true },
+ } as any);
+
+ renderWizard(onComplete);
+
+ await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
+ });
+
+ it("restores server-side progress: step and completed steps", async () => {
+ mockGetState.mockResolvedValue({
+ data: {
+ completed: false,
+ current_step: 2,
+ steps: { welcome: true, connect_repo: true },
+ },
+ } as any);
+
+ renderWizard();
+
+ expect(
+ await screen.findByText("Create Your First Test Suite"),
+ ).toBeInTheDocument();
+ // the suite creation form is prefilled with sensible defaults
+ const nameInput = screen.getByPlaceholderText(
+ "Suite name",
+ ) as HTMLInputElement;
+ expect(nameInput.value).toBe("My First Test Suite");
+ expect(screen.getByRole("button", { name: /create suite/i })).toBeEnabled();
+ });
+
+ it("advances to the next step via Continue and persists progress", async () => {
+ const user = userEvent.setup();
+ renderWizard();
+
+ expect(
+ await screen.findByText(/Let's get you started/),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /continue/i }));
+
+ await waitFor(() =>
+ expect(mockUpdateStep).toHaveBeenCalledWith("welcome", true),
+ );
+ expect(
+ await screen.findByText("Connect Your Repository"),
+ ).toBeInTheDocument();
+ });
+
+ it("creates a suite from the Create Suite step", async () => {
+ mockGetState.mockResolvedValue({
+ data: {
+ completed: false,
+ current_step: 2,
+ steps: { welcome: true, connect_repo: true },
+ },
+ } as any);
+
+ const user = userEvent.setup();
+ renderWizard();
+
+ await screen.findByText("Create Your First Test Suite");
+ await user.click(screen.getByRole("button", { name: /create suite/i }));
+
+ await waitFor(() => expect(mockCreateSuite).toHaveBeenCalledTimes(1));
+ expect(mockCreateSuite.mock.calls[0][0]).toMatchObject({
+ name: "My First Test Suite",
+ framework_type: "pytest",
+ });
+ // completing the suite step persists server-side
+ await waitFor(() =>
+ expect(mockUpdateStep).toHaveBeenCalledWith("create_suite", true),
+ );
+ });
+
+ it("marks the run_test step complete from the Run Test step", async () => {
+ mockGetState.mockResolvedValue({
+ data: {
+ completed: false,
+ current_step: 3,
+ steps: { welcome: true, connect_repo: true, create_suite: true },
+ },
+ } as any);
+
+ const user = userEvent.setup();
+ renderWizard();
+
+ expect(await screen.findByText("Run Your First Test")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: /mark as complete/i }));
+
+ await waitFor(() =>
+ expect(mockUpdateStep).toHaveBeenCalledWith("run_test", true),
+ );
+ });
+
+ it("finishing the last step completes onboarding and clears the onboarding flag", async () => {
+ const onComplete = vi.fn();
+ mockGetState.mockResolvedValue({
+ data: {
+ completed: false,
+ current_step: 4,
+ steps: {
+ welcome: true,
+ connect_repo: true,
+ create_suite: true,
+ run_test: true,
+ },
+ },
+ } as any);
+
+ const user = userEvent.setup();
+ renderWizard(onComplete);
+
+ expect(
+ await screen.findByText("Configure Notifications"),
+ ).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: /finish/i }));
+
+ await waitFor(() =>
+ expect(mockUpdateStep).toHaveBeenCalledWith("setup_notifications", true),
+ );
+ await waitFor(() => expect(mockComplete).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
+ expect(mockSetNeedsOnboarding).toHaveBeenCalledWith(false);
+ });
+
+ it("skipping onboarding calls the skip API and completes the flow", async () => {
+ const onComplete = vi.fn();
+ const user = userEvent.setup();
+ renderWizard(onComplete);
+
+ await screen.findByText(/Welcome to QA-FRAMEWORK/);
+ await user.click(screen.getByRole("button", { name: /skip setup/i }));
+
+ await waitFor(() => expect(mockSkip).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
+ expect(mockSetNeedsOnboarding).toHaveBeenCalledWith(false);
+ });
+
+ it("notifies an error when the onboarding state fails to load", async () => {
+ mockGetState.mockRejectedValue(new Error("boom"));
+
+ renderWizard();
+
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith(
+ "Failed to load onboarding state",
+ ),
+ );
+ });
+});
diff --git a/dashboard/frontend/src/components/billing/InvoiceList.test.tsx b/dashboard/frontend/src/components/billing/InvoiceList.test.tsx
new file mode 100644
index 0000000..fd69880
--- /dev/null
+++ b/dashboard/frontend/src/components/billing/InvoiceList.test.tsx
@@ -0,0 +1,76 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import InvoiceList from "./InvoiceList";
+
+const invoices = [
+ {
+ id: "inv_1",
+ number: "INV-001",
+ amount: 4900,
+ currency: "usd",
+ status: "paid" as const,
+ created_at: "2025-01-15T10:00:00Z",
+ invoice_url: "https://billing.example.com/invoices/inv_1.pdf",
+ },
+ {
+ id: "inv_2",
+ number: "INV-002",
+ amount: 1299,
+ currency: "eur",
+ status: "open" as const,
+ created_at: "2025-02-15T10:00:00Z",
+ },
+];
+
+describe("InvoiceList", () => {
+ it("renders without crash and shows the table heading with invoices", () => {
+ render();
+ expect(
+ screen.getByRole("heading", { name: "Invoice History" }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows a loading spinner while isLoading", () => {
+ render();
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
+ expect(screen.queryByText("Invoice History")).not.toBeInTheDocument();
+ });
+
+ it("shows the empty state when there are no invoices", () => {
+ render();
+ expect(screen.getByText("No invoices yet")).toBeInTheDocument();
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
+ });
+
+ it("renders one row per invoice with number, formatted amount, status and date", () => {
+ render();
+
+ expect(screen.getByText("#INV-001")).toBeInTheDocument();
+ expect(screen.getByText("#INV-002")).toBeInTheDocument();
+
+ // amount is in cents: 4900 usd -> $49.00, 1299 eur -> €12.99
+ expect(screen.getByText("$49.00")).toBeInTheDocument();
+ expect(screen.getByText("€12.99")).toBeInTheDocument();
+
+ expect(screen.getByText("paid")).toBeInTheDocument();
+ expect(screen.getByText("open")).toBeInTheDocument();
+
+ expect(screen.getByText("Jan 15, 2025")).toBeInTheDocument();
+ expect(screen.getByText("Feb 15, 2025")).toBeInTheDocument();
+ });
+
+ it("renders a download link only for invoices with an invoice_url", () => {
+ render();
+
+ // the download action is an icon-only IconButton rendered as an anchor;
+ // it has no accessible name, so query all links in the table
+ const links = screen.getAllByRole("link");
+ expect(links).toHaveLength(1);
+ expect(links[0]).toHaveAttribute(
+ "href",
+ "https://billing.example.com/invoices/inv_1.pdf",
+ );
+ expect(links[0]).toHaveAttribute("target", "_blank");
+ expect(links[0]).toHaveAttribute("rel", "noopener noreferrer");
+ });
+});
diff --git a/dashboard/frontend/src/components/billing/PaymentMethodForm.test.tsx b/dashboard/frontend/src/components/billing/PaymentMethodForm.test.tsx
new file mode 100644
index 0000000..80f58a6
--- /dev/null
+++ b/dashboard/frontend/src/components/billing/PaymentMethodForm.test.tsx
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import PaymentMethodForm from "./PaymentMethodForm";
+
+const fillCardFields = async (user: ReturnType) => {
+ await user.type(screen.getByLabelText(/card number/i), "4242424242424242");
+ await user.type(screen.getByLabelText(/expiry/i), "1230");
+ await user.type(screen.getByLabelText(/^cvc/i), "123");
+};
+
+describe("PaymentMethodForm", () => {
+ it("renders without crash and shows the dialog title when open", () => {
+ render( {}} onSubmit={vi.fn()} />);
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByText("Add Payment Method")).toBeInTheDocument();
+ });
+
+ it("disables the Add Card button until all fields are filled", async () => {
+ const user = userEvent.setup();
+ render( {}} onSubmit={vi.fn()} />);
+
+ const addButton = screen.getByRole("button", { name: /add card/i });
+ expect(addButton).toBeDisabled();
+
+ await user.type(screen.getByLabelText(/card number/i), "4242424242424242");
+ expect(addButton).toBeDisabled();
+
+ await user.type(screen.getByLabelText(/expiry/i), "1230");
+ expect(addButton).toBeDisabled();
+
+ await user.type(screen.getByLabelText(/^cvc/i), "123");
+ expect(addButton).toBeEnabled();
+ });
+
+ it("formats the card number into groups of 4 digits while typing", async () => {
+ const user = userEvent.setup();
+ render( {}} onSubmit={vi.fn()} />);
+
+ await user.type(screen.getByLabelText(/card number/i), "4242424242424242");
+ expect(screen.getByLabelText(/card number/i)).toHaveValue(
+ "4242 4242 4242 4242",
+ );
+ });
+
+ it("formats expiry as MM/YY while typing", async () => {
+ const user = userEvent.setup();
+ render( {}} onSubmit={vi.fn()} />);
+
+ await user.type(screen.getByLabelText(/expiry/i), "1230");
+ expect(screen.getByLabelText(/expiry/i)).toHaveValue("12/30");
+ });
+
+ it("strips non-digit characters from CVC", async () => {
+ const user = userEvent.setup();
+ render( {}} onSubmit={vi.fn()} />);
+
+ await user.type(screen.getByLabelText(/^cvc/i), "12a3");
+ expect(screen.getByLabelText(/^cvc/i)).toHaveValue("123");
+ });
+
+ it("submits a payment method id and closes the dialog on success", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+ const onClose = vi.fn();
+ render();
+
+ await fillCardFields(user);
+ await user.click(screen.getByRole("button", { name: /add card/i }));
+
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit.mock.calls[0][0]).toMatch(/^pm_/);
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
+ });
+
+ it("shows an error alert and keeps the dialog open when onSubmit fails", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ const onSubmit = vi.fn().mockRejectedValue({
+ response: { data: { detail: "Card declined" } },
+ });
+ render();
+
+ await fillCardFields(user);
+ await user.click(screen.getByRole("button", { name: /add card/i }));
+
+ expect(await screen.findByText("Card declined")).toBeInTheDocument();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("falls back to a generic error message when the failure has no detail", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn().mockRejectedValue(new Error("network down"));
+ render( {}} onSubmit={onSubmit} />);
+
+ await fillCardFields(user);
+ await user.click(screen.getByRole("button", { name: /add card/i }));
+
+ expect(
+ await screen.findByText("Failed to add payment method"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows a loading state on the action buttons while submitting", async () => {
+ const user = userEvent.setup();
+ let resolveSubmit: () => void = () => {};
+ const onSubmit = vi.fn().mockReturnValue(
+ new Promise((resolve) => {
+ resolveSubmit = resolve;
+ }),
+ );
+ const onClose = vi.fn();
+ render();
+
+ await fillCardFields(user);
+ await user.click(screen.getByRole("button", { name: /add card/i }));
+
+ // while the submit promise is pending the dialog is in loading state
+ await waitFor(() =>
+ expect(screen.getByRole("progressbar")).toBeInTheDocument(),
+ );
+ expect(screen.getByRole("button", { name: /cancel/i })).toBeDisabled();
+
+ resolveSubmit();
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
+ });
+
+ it("closes the dialog via the Cancel button", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render();
+
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/dashboard/frontend/src/components/billing/PlanCard.test.tsx b/dashboard/frontend/src/components/billing/PlanCard.test.tsx
new file mode 100644
index 0000000..322c1c1
--- /dev/null
+++ b/dashboard/frontend/src/components/billing/PlanCard.test.tsx
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import PlanCard from "./PlanCard";
+
+const freePlan = {
+ id: "free",
+ name: "Free",
+ price: 0,
+ interval: "month" as const,
+ features: [
+ { name: "3 test suites", included: true },
+ { name: "AI healing", included: false },
+ { name: "Priority support", included: false },
+ ],
+};
+
+const proPlan = {
+ id: "pro",
+ name: "Pro",
+ price: 29,
+ interval: "month" as const,
+ popular: true,
+ features: [
+ { name: "Unlimited suites", included: true },
+ { name: "AI healing", included: true },
+ { name: "Priority support", included: true },
+ ],
+};
+
+describe("PlanCard", () => {
+ it("renders without crash and shows the plan name", () => {
+ render( {}} />);
+ expect(screen.getByRole("heading", { name: "Pro" })).toBeInTheDocument();
+ });
+
+ it("formats a paid plan price as $/", () => {
+ render( {}} />);
+ expect(screen.getByText("$29/month")).toBeInTheDocument();
+ });
+
+ it("renders 'Free' without interval suffix for a zero-price plan", () => {
+ render( {}} />);
+ // the price (not the plan name heading) renders as "Free"
+ expect(screen.getByText("Free", { selector: "span" })).toBeInTheDocument();
+ expect(screen.queryByText(/Free\/month/i)).not.toBeInTheDocument();
+ });
+
+ it("renders all feature names", () => {
+ render( {}} />);
+ expect(screen.getByText("3 test suites")).toBeInTheDocument();
+ expect(screen.getByText("AI healing")).toBeInTheDocument();
+ expect(screen.getByText("Priority support")).toBeInTheDocument();
+ });
+
+ it("shows the 'Most Popular' badge only for popular plans", () => {
+ const { rerender } = render(
+ {}} />,
+ );
+ expect(screen.getByText("Most Popular")).toBeInTheDocument();
+
+ rerender(
+ {}} />,
+ );
+ expect(screen.queryByText("Most Popular")).not.toBeInTheDocument();
+ });
+
+ it("calls onSelect with the plan id when the select button is clicked", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Select Plan" }));
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("pro");
+ });
+
+ it("labels the action 'Downgrade' for a free plan and still selects it", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Downgrade" }));
+ expect(onSelect).toHaveBeenCalledWith("free");
+ });
+
+ it("disables the action and shows 'Current Plan' when the plan is current", async () => {
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button", { name: "Current Plan" });
+ // a disabled MUI button has pointer-events: none, so a real user
+ // cannot interact with it at all; onSelect can never fire
+ expect(button).toBeDisabled();
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("disables the action while isLoading", () => {
+ const onSelect = vi.fn();
+ render();
+
+ expect(screen.getByRole("button", { name: "Select Plan" })).toBeDisabled();
+ });
+});
diff --git a/dashboard/frontend/src/components/billing/SubscriptionStatus.test.tsx b/dashboard/frontend/src/components/billing/SubscriptionStatus.test.tsx
new file mode 100644
index 0000000..85d9f64
--- /dev/null
+++ b/dashboard/frontend/src/components/billing/SubscriptionStatus.test.tsx
@@ -0,0 +1,157 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import SubscriptionStatus from "./SubscriptionStatus";
+
+const daysAgo = (n: number) =>
+ new Date(Date.now() - n * 24 * 60 * 60 * 1000).toISOString();
+const daysAhead = (n: number) =>
+ new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
+
+const activeSubscription = {
+ id: "sub_1",
+ plan_id: "pro",
+ plan_name: "Pro",
+ status: "active" as const,
+ current_period_start: daysAgo(10),
+ current_period_end: daysAhead(20),
+ cancel_at_period_end: false,
+ features: {
+ max_suites: 10,
+ max_cases: 100,
+ ai_healing: true,
+ priority_support: true,
+ },
+ usage: { suites_used: 3, cases_used: 25 },
+};
+
+describe("SubscriptionStatus", () => {
+ it("renders without crash with an active subscription", () => {
+ render(
+ {}}
+ onUpgrade={() => {}}
+ />,
+ );
+ expect(screen.getByText("Pro")).toBeInTheDocument();
+ });
+
+ it("shows the free-plan empty state when subscription is null", () => {
+ render(
+ {}}
+ onUpgrade={() => {}}
+ />,
+ );
+ expect(screen.getByText("No Active Subscription")).toBeInTheDocument();
+ expect(screen.getByText(/free plan/i)).toBeInTheDocument();
+ });
+
+ it("calls onUpgrade from the empty state", async () => {
+ const onUpgrade = vi.fn();
+ const user = userEvent.setup();
+ render(
+ {}}
+ onUpgrade={onUpgrade}
+ />,
+ );
+
+ await user.click(screen.getByRole("button", { name: /upgrade plan/i }));
+ expect(onUpgrade).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows the status chip and the billing period for an active subscription", () => {
+ render(
+ {}}
+ onUpgrade={() => {}}
+ />,
+ );
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ expect(screen.getByText("Current billing period")).toBeInTheDocument();
+ // both period dates are rendered as a single " - " string
+ expect(
+ screen.getByText(
+ /^[A-Z][a-z]{2} \d{1,2}, \d{4} - [A-Z][a-z]{2} \d{1,2}, \d{4}$/,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("renders usage counters for suites and cases", () => {
+ render(
+ {}}
+ onUpgrade={() => {}}
+ />,
+ );
+ expect(screen.getByText("Usage This Period")).toBeInTheDocument();
+ expect(screen.getByText("Test Suites")).toBeInTheDocument();
+ expect(screen.getByText("3/10")).toBeInTheDocument();
+ expect(screen.getByText("Test Cases")).toBeInTheDocument();
+ expect(screen.getByText("25/100")).toBeInTheDocument();
+ });
+
+ it("calls onUpgrade from Change Plan and onCancel from Cancel Subscription", async () => {
+ const onCancel = vi.fn();
+ const onUpgrade = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /change plan/i }));
+ expect(onUpgrade).toHaveBeenCalledTimes(1);
+
+ await user.click(
+ screen.getByRole("button", { name: /cancel subscription/i }),
+ );
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows the cancellation warning and hides the cancel action when cancel_at_period_end is true", () => {
+ render(
+ {}}
+ onUpgrade={() => {}}
+ />,
+ );
+
+ expect(
+ screen.getByText(/canceled at the end of the current billing period/i),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /cancel subscription/i }),
+ ).not.toBeInTheDocument();
+ // Change Plan remains available
+ expect(screen.getByRole("button", { name: /change plan/i })).toBeEnabled();
+ });
+
+ it("disables the actions while isLoading", () => {
+ render(
+ {}}
+ onUpgrade={() => {}}
+ isLoading
+ />,
+ );
+ expect(screen.getByRole("button", { name: /change plan/i })).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: /cancel subscription/i }),
+ ).toBeDisabled();
+ });
+});
diff --git a/dashboard/frontend/src/components/common/KeyboardShortcutsDialog.test.tsx b/dashboard/frontend/src/components/common/KeyboardShortcutsDialog.test.tsx
new file mode 100644
index 0000000..8a9f2e3
--- /dev/null
+++ b/dashboard/frontend/src/components/common/KeyboardShortcutsDialog.test.tsx
@@ -0,0 +1,75 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import KeyboardShortcutsDialog from "./KeyboardShortcutsDialog";
+import { DEFAULT_SHORTCUTS } from "../../hooks/useKeyboardShortcuts";
+
+describe("KeyboardShortcutsDialog", () => {
+ it("renders the dialog title when open", () => {
+ render( {}} />);
+ expect(screen.getByText("Keyboard Shortcuts")).toBeInTheDocument();
+ });
+
+ it("renders nothing when closed", () => {
+ render( {}} />);
+ expect(screen.queryByText("Keyboard Shortcuts")).not.toBeInTheDocument();
+ });
+
+ it("groups shortcuts by category with one heading per category", () => {
+ render( {}} />);
+
+ const expectedCategories = [
+ ...new Set(DEFAULT_SHORTCUTS.map((s) => s.category)),
+ ];
+ for (const category of expectedCategories) {
+ expect(screen.getByText(category)).toBeInTheDocument();
+ }
+ });
+
+ it("renders every shortcut description", () => {
+ render( {}} />);
+
+ for (const shortcut of DEFAULT_SHORTCUTS) {
+ expect(
+ screen.getByText(shortcut.description),
+ ).toBeInTheDocument();
+ }
+ });
+
+ it("renders each shortcut key as an uppercase chip", () => {
+ render( {}} />);
+
+ for (const shortcut of DEFAULT_SHORTCUTS) {
+ expect(
+ screen.getByText(shortcut.key.toUpperCase()),
+ ).toBeInTheDocument();
+ }
+ });
+
+ it("lists the Navigation category shortcuts under their heading", () => {
+ render( {}} />);
+
+ const navigation = screen.getByText("Navigation", { exact: true });
+ const section = navigation.closest("div");
+ expect(section).not.toBeNull();
+ expect(section).toHaveTextContent("Focus search");
+ expect(section).toHaveTextContent("Go to home");
+ expect(section).toHaveTextContent("Close dialog/modal");
+ });
+
+ it("calls onClose when the close icon is clicked", async () => {
+ const onClose = vi.fn();
+ render();
+
+ // the close IconButton is the only button rendered by the dialog
+ fireEvent.click(screen.getByRole("button"));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose on Escape key (MUI dialog contract)", () => {
+ const onClose = vi.fn();
+ render();
+
+ fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/dashboard/frontend/src/pages/DashboardEnhanced.test.tsx b/dashboard/frontend/src/pages/DashboardEnhanced.test.tsx
new file mode 100644
index 0000000..e2e773f
--- /dev/null
+++ b/dashboard/frontend/src/pages/DashboardEnhanced.test.tsx
@@ -0,0 +1,197 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "react-query";
+import type { AxiosResponse } from "axios";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import DashboardEnhanced from "./DashboardEnhanced";
+import { dashboardAPI } from "../api/client";
+import { useRealTimeUpdates } from "../hooks/useRealTimeUpdates";
+
+vi.mock("../api/client", () => ({
+ dashboardAPI: {
+ getStats: vi.fn(),
+ getTrends: vi.fn(),
+ getRecentExecutions: vi.fn(),
+ },
+}));
+
+vi.mock("../hooks/useRealTimeUpdates", () => ({
+ useRealTimeUpdates: vi.fn(),
+}));
+
+// chart.js renders on