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
- 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>
472 lines
19 KiB
TypeScript
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('');
|
|
});
|
|
});
|