Files
familysync/apps/pwa/src/routes/SetupPage.test.tsx
T
Lucas BergerandClaude Opus 4.8 a193bc8236
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 2m16s
CI / api (pull_request) Failing after 1m37s
CI / harness (pull_request) Failing after 1h3m45s
CI / security (pull_request) Failing after 11s
CI / gate (pull_request) Failing after 1s
fix(12): satisfy CI fast-checks — lint unused vars, typed contract-test body, prettier
- Remove unused 'res'/'container' assignments (no-unused-vars)
- setupClient.contract.test.ts: typed parseSentBody helper + non-async json mock
  (no-unsafe-*/require-await)
- Prettier format 7 setup files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:53:14 -04:00

472 lines
19 KiB
TypeScript

/**
* 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, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
import { SetupPage } from './SetupPage.js';
// ── 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(),
SetupAlreadyLockedError: class SetupAlreadyLockedError extends Error {
readonly name = 'SetupAlreadyLockedError';
},
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,
props: React.ComponentProps<typeof SetupPage> = {},
) {
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<SetupPage {...props} />
</MemoryRouter>
</QueryClientProvider>,
);
}
// ── 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 () => {
renderSetupPage(queryClient);
// The step 2 config form has aria-live; step 1 welcome step does not yet show validation
// but the structure has it via the general error block. We advance to step 2 to test.
// For now we verify that once rendered, the component tree has the correct aria-live
// attribute when the user is on the welcome step — the overall page structure includes
// at least the page title (main) so the test ensures no crash.
await waitFor(() => {
expect(screen.getByRole('main')).toBeInTheDocument();
});
});
it('does NOT render AppNav', async () => {
const { container } = renderSetupPage(queryClient);
await waitFor(() => {
// AppNav renders a <nav> element; SetupPage is standalone and must not include it
const navEl = container.querySelector('nav');
expect(navEl).toBeNull();
});
});
it('does NOT render BottomTabBar', async () => {
const { container } = renderSetupPage(queryClient);
await waitFor(() => {
// BottomTabBar would render a tablist; SetupPage has none
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 prop is true', async () => {
const queryClient = makeQueryClient();
renderSetupPage(queryClient, { alreadyLocked: true });
await waitFor(() => {
expect(screen.getByText('Setup already complete')).toBeInTheDocument();
});
});
it('locked screen has "Sign in" link', async () => {
const queryClient = makeQueryClient();
renderSetupPage(queryClient, { alreadyLocked: true });
await waitFor(() => {
const link = screen.getByText('Sign in');
expect(link).toBeInTheDocument();
});
});
});
// ── Tests: Step 2 VAPID validation (CR-01 / SETUP-02 gap closure) ────────────
//
// RED gate: these tests must FAIL against the current SetupPage.tsx because:
// - validateSetupVapid is not imported in SetupPage.tsx
// - No VAPID ValidationRow is rendered
// - bothPassed is gated on db+oidc only (not vapid)
describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => {
let queryClient: QueryClient;
beforeEach(async () => {
queryClient = makeQueryClient();
vi.resetAllMocks();
const { fetchSetupStatus } = await import('../api/client.js');
(fetchSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
setupComplete: false,
dbName: 'familysync',
});
});
/**
* Helper: advance from Welcome (step 1) to Instance Configuration (step 2).
*/
async function advanceToStep2() {
renderSetupPage(queryClient);
const continueBtn = await screen.findByRole('button', { name: 'Continue' });
fireEvent.click(continueBtn);
// Step 2 heading appears
await screen.findByText('Instance Configuration');
}
/**
* Helper: fill all 4 fields in step 2 and click "Save & Validate".
* Mocks must be set up by the caller.
*/
async function fillAndSubmitStep2() {
const { postSetupConfig } = await import('../api/client.js');
(postSetupConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
fireEvent.change(screen.getByLabelText('App URL'), {
target: { value: 'https://app.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), {
target: { value: 'https://auth.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } });
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
}
it('calls validateSetupVapid after DB and OIDC pass in step 2', async () => {
const { validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
await advanceToStep2();
await fillAndSubmitStep2();
await waitFor(() => {
expect(validateSetupVapid as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
});
});
it('renders a VAPID validation row after step 2 validation completes', async () => {
const { validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
await advanceToStep2();
await fillAndSubmitStep2();
// After all three resolve, the VAPID success text should appear in the ValidationRow
await waitFor(() => {
expect(screen.getByText(/VAPID keys verified/i)).toBeInTheDocument();
});
});
it('does NOT show Continue when VAPID validation fails', async () => {
const { validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error(
'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.',
),
);
await advanceToStep2();
await fillAndSubmitStep2();
// Wait for the VAPID failure to land in the UI
await waitFor(() => {
expect(screen.getByText(/VAPID validation failed/i)).toBeInTheDocument();
});
// Continue button must NOT be present when VAPID fails
expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull();
});
it('shows Continue only when db, oidc, AND vapid all pass', async () => {
const { validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
await advanceToStep2();
await fillAndSubmitStep2();
// Continue appears only after all three validations pass
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument();
});
});
});
// ── Tests: Step 2 Instance copy + read-only DB-name field (gaps 1, 3-frontend) ──
describe('SetupPage — Step 2 Instance copy + DB-name field (gaps 1, 3)', () => {
let queryClient: QueryClient;
beforeEach(async () => {
queryClient = makeQueryClient();
vi.resetAllMocks();
const { fetchSetupStatus } = await import('../api/client.js');
(fetchSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
setupComplete: false,
dbName: 'familysync',
});
});
async function advanceToStep2() {
renderSetupPage(queryClient);
const continueBtn = await screen.findByRole('button', { name: 'Continue' });
fireEvent.click(continueBtn);
await screen.findByText('Instance Configuration');
}
it('Instance step intro no longer contains the DB-vs-env-file aside (gap 1)', async () => {
await advanceToStep2();
expect(screen.queryByText(/not your environment file/i)).toBeNull();
// First sentence is preserved
expect(screen.getByText(/Enter your instance/i)).toBeInTheDocument();
});
it('renders a read-only, disabled DB-name field populated from status dbName (gap 3)', async () => {
await advanceToStep2();
const dbField = await screen.findByLabelText('Database');
await waitFor(() => {
expect(dbField).toHaveValue('familysync');
});
expect(dbField).toHaveAttribute('readonly');
expect(dbField).toBeDisabled();
expect(dbField).toHaveAttribute('aria-readonly', 'true');
});
it('keeps the existing "Database connection verified" validation row available', async () => {
const { postSetupConfig, validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(postSetupConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
await advanceToStep2();
fireEvent.change(screen.getByLabelText('App URL'), {
target: { value: 'https://app.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), {
target: { value: 'https://auth.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } });
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
await waitFor(() => {
expect(screen.getByText('Database connection verified.')).toBeInTheDocument();
});
});
});
// ── Tests: Back navigation preserves Instance fields (gap 4) ──────────────────
describe('SetupPage — Back navigation preserves Instance fields (gap 4)', () => {
let queryClient: QueryClient;
beforeEach(async () => {
queryClient = makeQueryClient();
vi.resetAllMocks();
const { fetchSetupStatus } = await import('../api/client.js');
(fetchSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
setupComplete: false,
dbName: 'familysync',
});
});
/**
* Helper: fill the four Instance fields, run validation to GREEN, advance to
* the Calendar step (step 3).
*/
async function fillStep2AndAdvance() {
const { postSetupConfig, validateSetupDb, validateSetupOidc, validateSetupVapid } =
await import('../api/client.js');
(postSetupConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
renderSetupPage(queryClient);
fireEvent.click(await screen.findByRole('button', { name: 'Continue' }));
await screen.findByText('Instance Configuration');
fireEvent.change(screen.getByLabelText('App URL'), {
target: { value: 'https://app.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), {
target: { value: 'https://auth.example.com' },
});
fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } });
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
// After all validations pass, the Continue button appears
fireEvent.click(await screen.findByRole('button', { name: 'Continue' }));
await screen.findByText('Fastmail Credential');
}
it('restores all four Instance field values after navigating Back from Calendar step', async () => {
await fillStep2AndAdvance();
// Now on step 3 (Calendar). Navigate Back.
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
await screen.findByText('Instance Configuration');
expect(screen.getByLabelText('App URL')).toHaveValue('https://app.example.com');
expect(screen.getByLabelText('OIDC issuer URL')).toHaveValue('https://auth.example.com');
expect(screen.getByLabelText('OIDC client ID')).toHaveValue('familysync');
expect(screen.getByLabelText('VAPID public key')).toHaveValue('BHtest123');
});
it('does NOT persist the Fastmail app password across Back/forward navigation (T-12-15)', async () => {
await fillStep2AndAdvance();
// On step 3: type a password into the app password field.
const pwField = screen.getByLabelText('App password');
fireEvent.change(pwField, { target: { value: 'super-secret-pw' } });
expect(pwField).toHaveValue('super-secret-pw');
// Back to step 2 — field values are preserved, but validation state is not
// lifted, so re-run Save & Validate to surface Continue, then advance to step 3.
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
await screen.findByText('Instance Configuration');
// Fields are still populated (gap 4), so just re-validate.
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
fireEvent.click(await screen.findByRole('button', { name: 'Continue' }));
await screen.findByText('Fastmail Credential');
// Step 3 re-mounted with fresh local state — the app password is NOT persisted.
expect(screen.getByLabelText('App password')).toHaveValue('');
});
});