diff --git a/apps/pwa/src/routes/SetupPage.test.tsx b/apps/pwa/src/routes/SetupPage.test.tsx new file mode 100644 index 0000000..b3efeb0 --- /dev/null +++ b/apps/pwa/src/routes/SetupPage.test.tsx @@ -0,0 +1,221 @@ +/** + * SetupPage — unit tests (TDD RED gate, Task 2, Plan 12-04) + * + * Tests cover: + * - API client functions exported from client.ts (fetchSetupStatus, postSetupConfig, + * validateSetupDb, validateSetupOidc, validateSetupVapid, postSetupCredential, postSetupComplete) + * - SetupPage renders wizard steps (Welcome, step indicator, step headings) + * - SetupPage renders "Already Locked" screen when status returns 423 + * - SetupPage has role="main", aria-live + * - SetupPage does NOT import AppNav or BottomTabBar + * - SetupPage is standalone (no AppNav/BottomTabBar in render output) + * + * All API calls are mocked via vi.mock('../api/client.js'). + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; + +// ── Module mocks ──────────────────────────────────────────────────────────── + +vi.mock('../api/client.js', () => ({ + fetchSetupStatus: vi.fn(), + postSetupConfig: vi.fn(), + validateSetupDb: vi.fn(), + validateSetupOidc: vi.fn(), + validateSetupVapid: vi.fn(), + postSetupCredential: vi.fn(), + postSetupComplete: vi.fn(), + // Keep other exports the app uses + fetchMe: vi.fn(), + SessionExpiredError: class SessionExpiredError extends Error { + readonly name = 'SessionExpiredError'; + }, +})); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function renderSetupPage(queryClient: QueryClient) { + // Inline import to ensure mocks are set up first + const { SetupPage } = require('./SetupPage.js') as typeof import('./SetupPage.js'); + return render( + + + + + , + ); +} + +// ── Tests: API client exports ──────────────────────────────────────────────── + +describe('Setup API client functions', () => { + it('client.ts exports fetchSetupStatus', async () => { + const client = await import('../api/client.js'); + expect(client).toHaveProperty('fetchSetupStatus'); + }); + + it('client.ts exports postSetupConfig', async () => { + const client = await import('../api/client.js'); + expect(client).toHaveProperty('postSetupConfig'); + }); + + it('client.ts exports validateSetupDb', async () => { + const client = await import('../api/client.js'); + expect(client).toHaveProperty('validateSetupDb'); + }); + + it('client.ts exports validateSetupOidc', async () => { + const client = await import('../api/client.js'); + expect(client).toHaveProperty('validateSetupOidc'); + }); + + it('client.ts exports validateSetupVapid', async () => { + const client = await import('../api/client.js'); + expect(client).toHaveProperty('validateSetupVapid'); + }); + + it('client.ts exports postSetupCredential', async () => { + const client = await import('../api/client.js'); + expect(client).toHaveProperty('postSetupCredential'); + }); + + it('client.ts exports postSetupComplete', async () => { + const client = await import('../api/client.js'); + expect(client).toHaveProperty('postSetupComplete'); + }); +}); + +// ── Tests: SetupPage rendering ─────────────────────────────────────────────── + +describe('SetupPage — Welcome step', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = makeQueryClient(); + }); + + it('renders the page title "FamilySync Setup"', async () => { + renderSetupPage(queryClient); + await waitFor(() => { + expect(screen.getByText('FamilySync Setup')).toBeInTheDocument(); + }); + }); + + it('renders the Welcome step heading', async () => { + renderSetupPage(queryClient); + await waitFor(() => { + expect(screen.getByText('Welcome to FamilySync Setup')).toBeInTheDocument(); + }); + }); + + it('renders step indicator with 4 steps', async () => { + renderSetupPage(queryClient); + await waitFor(() => { + // Step labels: Welcome, Instance, Calendar, Complete + expect(screen.getByText('Welcome')).toBeInTheDocument(); + expect(screen.getByText('Instance')).toBeInTheDocument(); + expect(screen.getByText('Calendar')).toBeInTheDocument(); + expect(screen.getByText('Complete')).toBeInTheDocument(); + }); + }); + + it('renders the Continue button on the Welcome step', async () => { + renderSetupPage(queryClient); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument(); + }); + }); + + it('has role="main" on the wizard content', async () => { + renderSetupPage(queryClient); + await waitFor(() => { + expect(screen.getByRole('main')).toBeInTheDocument(); + }); + }); + + it('has aria-live region for validation status', async () => { + const { container } = renderSetupPage(queryClient); + await waitFor(() => { + const liveRegion = container.querySelector('[aria-live]'); + expect(liveRegion).toBeInTheDocument(); + }); + }); + + it('does NOT render AppNav', async () => { + const { container } = renderSetupPage(queryClient); + // AppNav has data-testid or we check for nav text unique to AppNav + // We just ensure the SetupPage module itself does not import AppNav + await waitFor(() => { + // The wizard should not contain nav links like "Calendar" or "Lists" from AppNav + // (those are nav items in AppNav/BottomTabBar) + const navEl = container.querySelector('nav'); + // If there is no nav element, AppNav is not rendered + expect(navEl).toBeNull(); + }); + }); + + it('does NOT render BottomTabBar', async () => { + const { container } = renderSetupPage(queryClient); + await waitFor(() => { + // BottomTabBar uses role="navigation" or a specific class + // The wizard page has no bottom tab bar + const tabs = container.querySelectorAll('[role="tablist"]'); + expect(tabs.length).toBe(0); + }); + }); +}); + +// ── Tests: SetupPage Already Locked screen ─────────────────────────────────── + +describe('SetupPage — Already Locked screen', () => { + it('renders "Setup already complete" when alreadyLocked state is true', async () => { + // SetupPage renders the locked screen when it encounters a 423 from the API + // We test this by using a special prop or by simulating the locked state + // The component should accept an optional prop for testing, or + // we render with a query that returns 423 + // For now, we check the component can render the locked screen text + // We'll test this via the component's internal state management + const queryClient = makeQueryClient(); + const { SetupPage } = require('./SetupPage.js') as typeof import('./SetupPage.js'); + render( + + + {/* Pass alreadyLocked prop to render locked screen directly */} + + + , + ); + await waitFor(() => { + expect(screen.getByText('Setup already complete')).toBeInTheDocument(); + }); + }); + + it('locked screen has "Sign in" link to /', async () => { + const queryClient = makeQueryClient(); + const { SetupPage } = require('./SetupPage.js') as typeof import('./SetupPage.js'); + render( + + + + + , + ); + await waitFor(() => { + const link = screen.getByText('Sign in'); + expect(link).toBeInTheDocument(); + }); + }); +});