From 703f523f9b3f5a411fcd51314d19a9c74e05e657 Mon Sep 17 00:00:00 2001 From: Joker Date: Fri, 4 Sep 2026 19:28:06 +0000 Subject: [PATCH] test(frontend): behavior tests for components migrated in #212 (card 17ca16b0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compensación del waiver de diff-coverage de PR #212: tests de comportamiento (Vitest + Testing Library) para los componentes de mayor lógica/riesgo migrados por el codemod MUI 9/RR7/zustand 5. - billing/*: InvoiceList, PaymentMethodForm, PlanCard, SubscriptionStatus - pages: DashboardEnhanced, OnboardingWizard - components: AdvancedFilter, KeyboardShortcutsDialog Suite: 83/83 (11 files). Diff-cov vs 7dadb7d^: billing 100% x4, KeyboardShortcutsDialog 100%, OnboardingWizard 95%, DashboardEnhanced 85%, AdvancedFilter 73% (gate >=70%). Global 52.3% (era 21% al momento del waiver). --- .../src/components/OnboardingWizard.test.tsx | 255 ++++++++++++++++++ .../components/billing/InvoiceList.test.tsx | 76 ++++++ .../billing/PaymentMethodForm.test.tsx | 136 ++++++++++ .../src/components/billing/PlanCard.test.tsx | 106 ++++++++ .../billing/SubscriptionStatus.test.tsx | 157 +++++++++++ .../common/KeyboardShortcutsDialog.test.tsx | 75 ++++++ .../src/pages/DashboardEnhanced.test.tsx | 197 ++++++++++++++ 7 files changed, 1002 insertions(+) create mode 100644 dashboard/frontend/src/components/OnboardingWizard.test.tsx create mode 100644 dashboard/frontend/src/components/billing/InvoiceList.test.tsx create mode 100644 dashboard/frontend/src/components/billing/PaymentMethodForm.test.tsx create mode 100644 dashboard/frontend/src/components/billing/PlanCard.test.tsx create mode 100644 dashboard/frontend/src/components/billing/SubscriptionStatus.test.tsx create mode 100644 dashboard/frontend/src/components/common/KeyboardShortcutsDialog.test.tsx create mode 100644 dashboard/frontend/src/pages/DashboardEnhanced.test.tsx 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 , which jsdom does not provide; stub the +// react-chartjs-2 wrappers so the surrounding layout stays under test. +vi.mock("react-chartjs-2", () => ({ + Line: () => null, + Bar: () => null, + Doughnut: () => null, +})); + +const mockGetStats = vi.mocked(dashboardAPI.getStats); +const mockGetTrends = vi.mocked(dashboardAPI.getTrends); +const mockGetRecent = vi.mocked(dashboardAPI.getRecentExecutions); +const mockToggleLive = vi.fn(); +const mockUseRealTimeUpdates = vi.mocked(useRealTimeUpdates); + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + +const renderDashboard = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + + + + , + ); +}; + +const seedResolvedData = () => { + mockGetStats.mockResolvedValue({ + data: { + total_executions: 42, + total_test_suites: 7, + success_rate: 92, + flaky_tests: 3, + }, + } as any); + mockGetTrends.mockResolvedValue({ + data: [ + { date: "2026-09-01", total: 10, passed: 8, failed: 2 }, + { date: "2026-09-02", total: 12, passed: 11, failed: 1 }, + ], + } as any); + mockGetRecent.mockResolvedValue({ + data: [ + { + id: 1, + suite_name: "Smoke Suite", + environment: "staging", + started_at: "2026-09-03 10:00", + passed: 5, + total_tests: 5, + status: "completed", + }, + { + id: 2, + suite_name: "Regression Pack", + environment: "prod", + started_at: "2026-09-03 11:00", + passed: 3, + total_tests: 4, + status: "running", + }, + ], + } as any); +}; + +describe("DashboardEnhanced", () => { + beforeEach(() => { + vi.clearAllMocks(); + seedResolvedData(); + mockUseRealTimeUpdates.mockReturnValue({ + isLive: true, + lastUpdate: new Date("2026-09-04T08:00:00"), + toggleLive: mockToggleLive, + } as any); + }); + + it("renders without crash and shows the page heading", async () => { + renderDashboard(); + + expect( + await screen.findByRole("heading", { name: "Dashboard" }), + ).toBeInTheDocument(); + }); + + it("shows a loading spinner while dashboard queries are pending", () => { + mockGetStats.mockImplementation(() => new Promise(() => {})); + mockGetTrends.mockImplementation(() => new Promise(() => {})); + mockGetRecent.mockImplementation(() => new Promise(() => {})); + + renderDashboard(); + + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + it("renders the AI disclosure banner required by EU AI Act Art. 50(1)", async () => { + renderDashboard(); + + await screen.findByRole("heading", { name: "Dashboard" }); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("renders stat cards with the values returned by the API", async () => { + renderDashboard(); + + expect(await screen.findByText("Total Executions")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("Test Suites")).toBeInTheDocument(); + expect(screen.getByText("7")).toBeInTheDocument(); + expect(screen.getByText("Success Rate")).toBeInTheDocument(); + expect(screen.getByText("92%")).toBeInTheDocument(); + }); + + it("renders chart section headers for trends and distribution", async () => { + renderDashboard(); + + expect( + await screen.findByText("Execution Trends (Last 30 Days)"), + ).toBeInTheDocument(); + expect(screen.getByText("Test Types Distribution")).toBeInTheDocument(); + }); + + it("renders recent executions with pass ratio and status chips", async () => { + renderDashboard(); + + expect(await screen.findByText("Smoke Suite")).toBeInTheDocument(); + expect(screen.getByText("5/5 passed")).toBeInTheDocument(); + expect(screen.getAllByText("completed")).toHaveLength(1); + + expect(screen.getByText("Regression Pack")).toBeInTheDocument(); + expect(screen.getByText("3/4 passed")).toBeInTheDocument(); + expect(screen.getByText("running")).toBeInTheDocument(); + }); + + it("refetches recent executions when Refresh is clicked", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await screen.findByText("Smoke Suite"); + expect(mockGetRecent).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: /refresh/i })); + + await waitFor(() => expect(mockGetRecent).toHaveBeenCalledTimes(2)); + }); + + it("shows the live indicator and toggles polling on click", async () => { + const user = userEvent.setup(); + renderDashboard(); + + const liveChip = await screen.findByText("Live"); + await user.click(liveChip); + + expect(mockToggleLive).toHaveBeenCalledTimes(1); + }); + + it("navigates from quick action buttons", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await screen.findByText("Smoke Suite"); + + await user.click(screen.getByRole("button", { name: /new test suite/i })); + expect(screen.getByTestId("location")).toHaveTextContent("/suites"); + + await user.click(screen.getByRole("button", { name: /run tests/i })); + expect(screen.getByTestId("location")).toHaveTextContent("/executions"); + }); +});