import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// UX-001 Phase 4 — DashboardPage onboarding wizard entry/exit contract
//
// DashboardPage is the arbiter of when the OnboardingWizard is visible. Two
// triggers must both work:
//
// 1. First-run detection: no certificates + no user-configured issuers +
// no dismissal flag → auto-open the wizard.
//
// 2. Re-entry via URL: `?onboarding=1` query param forces the wizard open
// even for users who already have certs or have previously dismissed it.
// This is the other end of the Layout "Setup guide" contract tested in
// Layout.test.tsx.
//
// And the close path must clean up after itself:
//
// 3. On dismiss, DashboardPage must (a) set
// `certctl:onboarding-dismissed=true` in localStorage so it doesn't
// auto-reopen on refresh, and (b) strip the `?onboarding=1` query param
// via setSearchParams(..., { replace: true }) so a subsequent refresh
// doesn't relaunch the wizard either.
// -----------------------------------------------------------------------------
// Mock the entire API client. vi.mock factories are hoisted above the imports
// that follow, so these stubs are in effect when DashboardPage's module graph
// resolves.
vi.mock('../api/client', () => ({
getCertificates: vi.fn(),
getAgents: vi.fn(),
getJobs: vi.fn(),
getNotifications: vi.fn(),
getHealth: vi.fn(),
getDashboardSummary: vi.fn(),
getCertificatesByStatus: vi.fn(),
getExpirationTimeline: vi.fn(),
getJobTrends: vi.fn(),
getIssuanceRate: vi.fn(),
previewDigest: vi.fn(),
sendDigest: vi.fn(),
getIssuers: vi.fn(),
}));
// Replace OnboardingWizard with a paper-thin stub so these tests exercise the
// entry/exit contract without pulling OnboardingWizard's (heavy) query tree
// and step machinery into the fixture. OnboardingWizard's own behaviour is
// covered in OnboardingWizard.test.tsx.
vi.mock('./OnboardingWizard', () => ({
default: ({ onDismiss }: { onDismiss: () => void }) => (
),
}));
import DashboardPage from './DashboardPage';
import * as client from '../api/client';
// Location probe: renders current pathname+search into the DOM so we can
// assert that setSearchParams(next, { replace: true }) actually stripped the
// `?onboarding=1` query param without relying on router internals.
function LocationProbe() {
const loc = useLocation();
return (
{loc.pathname}
{loc.search}
);
}
function renderDashboard(initialEntry = '/') {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
},
});
return render(
} />
,
);
}
// Canonical "empty but well-formed" stubs for every query DashboardPage calls
// unconditionally. Returning data (rather than leaving queries pending) lets
// the component pass its `summary !== undefined && issuersData !== undefined`
// gate and compute isFirstRun in a single render pass.
function stubAllQueriesEmpty() {
vi.mocked(client.getHealth).mockResolvedValue({ status: 'healthy' } as never);
vi.mocked(client.getDashboardSummary).mockResolvedValue({
total_certificates: 0,
expiring_certificates: 0,
expired_certificates: 0,
revoked_certificates: 0,
active_agents: 0,
pending_jobs: 0,
completed_jobs: 0,
failed_jobs: 0,
notifications_dead: 0,
} as never);
vi.mocked(client.getIssuers).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 100,
} as never);
vi.mocked(client.getCertificatesByStatus).mockResolvedValue([] as never);
vi.mocked(client.getExpirationTimeline).mockResolvedValue([] as never);
vi.mocked(client.getJobTrends).mockResolvedValue([] as never);
vi.mocked(client.getIssuanceRate).mockResolvedValue([] as never);
vi.mocked(client.getCertificates).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 100,
} as never);
vi.mocked(client.getJobs).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 100,
} as never);
}
describe('DashboardPage — UX-001 onboarding wizard entry/exit', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
localStorage.clear();
stubAllQueriesEmpty();
});
afterEach(() => {
localStorage.clear();
});
it('auto-opens the wizard on first run (no certs, no user-configured issuers, no dismissal)', async () => {
renderDashboard('/');
// First-run detection runs after the summary + issuers queries resolve.
// The wizard is gated behind a setTimeout(..., 0) in DashboardPage to
// avoid setState-during-render, so we waitFor the stub to appear.
await waitFor(() => {
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
});
});
it('opens the wizard when URL has ?onboarding=1, even with dismissal flag set', async () => {
// Simulate a user who dismissed the wizard previously and is now clicking
// the sidebar "Setup guide" button, which navigates to /?onboarding=1.
localStorage.setItem('certctl:onboarding-dismissed', 'true');
// Give the fixture non-zero counts so first-run detection would *not*
// fire on its own. Only the query-param override should open the wizard.
vi.mocked(client.getDashboardSummary).mockResolvedValue({
total_certificates: 42,
expiring_certificates: 0,
expired_certificates: 0,
revoked_certificates: 0,
active_agents: 3,
pending_jobs: 0,
completed_jobs: 0,
failed_jobs: 0,
notifications_dead: 0,
} as never);
vi.mocked(client.getIssuers).mockResolvedValue({
data: [{ id: 'iss-prod', name: 'Prod ACME', type: 'ACME', source: 'database' }],
total: 1,
page: 1,
per_page: 100,
} as never);
renderDashboard('/?onboarding=1');
await waitFor(() => {
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
});
});
it('onDismiss sets localStorage flag and strips ?onboarding=1 from the URL', async () => {
renderDashboard('/?onboarding=1');
// Wait for the wizard to open.
await waitFor(() => {
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
});
// Sanity: URL still carries the re-entry signal before dismiss.
expect(screen.getByTestId('location-probe').textContent).toContain('onboarding=1');
fireEvent.click(screen.getByTestId('wizard-dismiss-btn'));
// After dismiss: localStorage is set, so a future refresh won't re-open.
await waitFor(() => {
expect(localStorage.getItem('certctl:onboarding-dismissed')).toBe('true');
});
// The wizard is torn down.
await waitFor(() => {
expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument();
});
// And the `?onboarding=1` query param is stripped via replace: true so
// refreshing the page won't reopen the wizard via the URL path either.
await waitFor(() => {
const probe = screen.getByTestId('location-probe').textContent ?? '';
expect(probe).not.toContain('onboarding=1');
});
});
it('does not open the wizard when dismissed and URL has no onboarding param', async () => {
localStorage.setItem('certctl:onboarding-dismissed', 'true');
renderDashboard('/');
// Give queries a tick to settle.
await waitFor(() => {
expect(screen.getByText(/System healthy|Checking system status/i)).toBeInTheDocument();
});
// The wizard should NOT have auto-opened — dismissal flag is respected
// when the URL doesn't carry the override signal.
expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument();
});
});