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();
+ });
+ });
+});