Two issues surfaced only when plans 19-04 (login UI) and 19-05 (Option C bypass + login.spec) were merged together and run against the real stack — neither executor could catch them in isolation: 1. LOCAL_SESSION_SECRET was added to the CI harness (ci.yml) but not to the local dev stack (docker-compose.dev.yml). Without it the real-login success path (POST /api/auth/local/login) 503s when signing the session cookie, so the e2e round-trip failed. Add the same fixed dev-only value to the dev compose override (dev-only target; never a production secret). 2. login.spec test 1 assumed clearing the local-session cookie yields a logged-out state, but under the always-on DEV_AUTH_BYPASS devAuthBypass() injects DEV_USER into /api/me regardless of any cookie — a logged-out state is architecturally unreachable in this bypass-only harness. Reframe the test to drive /login directly (validating the real-browser render of all brand + form surfaces) and move the unauthenticated root->/login redirect-gate coverage to a unit test in App.test.tsx where meQuery.isError is controllable. Result: API 446/446, PWA 265/265 (+2 gate tests), e2e desktop 42 passed / 3 skipped (all login specs green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
/**
|
|
* App.test.tsx — tests for the App.tsx setup-status gate (Phase 12 Plan 04 Task 3).
|
|
*
|
|
* Tests cover:
|
|
* - setupComplete: false → app redirects to /setup and renders SetupPage (no AppNav)
|
|
* - setupComplete: true → normal app boot proceeds (calendar route renders)
|
|
* - While setupQuery is loading → renders nothing (no flash)
|
|
*
|
|
* Mocking strategy:
|
|
* - /api/setup/status is mocked via vi.mock on client.ts
|
|
* - /api/me is also mocked (avoid auth-related network calls)
|
|
* - Heavy components (CalendarShell, SetupPage, AppNav etc.) are mocked to avoid
|
|
* DOM/schedule-x complexity — we're testing the gate logic only
|
|
*/
|
|
|
|
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';
|
|
|
|
// ── Module mocks (must be hoisted above imports) ─────────────────────────────
|
|
|
|
// Mock heavy components to avoid schedule-x / DOM complexity
|
|
vi.mock('./components/CalendarShell.js', () => ({
|
|
CalendarShell: () => <div data-testid="calendar-shell">CalendarShell</div>,
|
|
}));
|
|
|
|
// SetupPage mock respects the `alreadyLocked` prop so the reverse-gate test (gap 5)
|
|
// can distinguish the active wizard (Step 1 "Welcome to FamilySync Setup") from the
|
|
// Surface 8 "Setup already complete" terminal surface. The real SetupPage renders
|
|
// these two surfaces based on this exact prop — see routes/SetupPage.tsx.
|
|
vi.mock('./routes/SetupPage.js', () => ({
|
|
SetupPage: ({ alreadyLocked }: { alreadyLocked?: boolean }) =>
|
|
alreadyLocked ? (
|
|
<div data-testid="setup-page">Setup already complete</div>
|
|
) : (
|
|
<div data-testid="setup-page">Welcome to FamilySync Setup</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('./components/AppNav.js', () => ({
|
|
AppNav: () => <nav data-testid="app-nav">AppNav</nav>,
|
|
}));
|
|
|
|
vi.mock('./components/BottomTabBar.js', () => ({
|
|
BottomTabBar: () => <div data-testid="bottom-tab-bar">BottomTabBar</div>,
|
|
}));
|
|
|
|
vi.mock('./components/PushPermissionPrompt.js', () => ({
|
|
PushPermissionPrompt: () => null,
|
|
}));
|
|
|
|
vi.mock('./components/PermissionDeniedBanner.js', () => ({
|
|
PermissionDeniedBanner: () => null,
|
|
}));
|
|
|
|
vi.mock('./components/SetupBanner.js', () => ({
|
|
SetupBanner: () => null,
|
|
}));
|
|
|
|
vi.mock('./components/SettingsSheet.js', () => ({
|
|
SettingsSheet: () => null,
|
|
}));
|
|
|
|
vi.mock('./routes/AdminPage.js', () => ({
|
|
AdminPage: () => <div data-testid="admin-page">AdminPage</div>,
|
|
}));
|
|
|
|
vi.mock('./routes/ListsIndex.js', () => ({
|
|
ListsIndex: () => <div data-testid="lists-index">ListsIndex</div>,
|
|
}));
|
|
|
|
vi.mock('./routes/ListDetail.js', () => ({
|
|
ListDetail: () => <div data-testid="list-detail">ListDetail</div>,
|
|
}));
|
|
|
|
vi.mock('./routes/LoginPage.js', () => ({
|
|
LoginPage: () => <div data-testid="login-page">LoginPage</div>,
|
|
}));
|
|
|
|
// Mock the API client — this is the key mock for the gate
|
|
vi.mock('./api/client.js', () => ({
|
|
fetchSetupStatus: vi.fn(),
|
|
fetchMe: vi.fn(),
|
|
// Phase 19: fetchAuthMode is queried in App.tsx for the /login gate
|
|
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
|
|
SetupAlreadyLockedError: class SetupAlreadyLockedError extends Error {
|
|
readonly name = 'SetupAlreadyLockedError';
|
|
},
|
|
SessionExpiredError: class SessionExpiredError extends Error {
|
|
readonly name = 'SessionExpiredError';
|
|
},
|
|
LoginError: class LoginError extends Error {
|
|
readonly name = 'LoginError';
|
|
constructor(public readonly code: string) {
|
|
super(`Login failed: ${code}`);
|
|
}
|
|
},
|
|
}));
|
|
|
|
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
|
|
|
import { fetchSetupStatus, fetchMe, fetchAuthMode } from './api/client.js';
|
|
import type { Mock } from 'vitest';
|
|
import App from './App.js';
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function makeQueryClient() {
|
|
return new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderApp(queryClient: QueryClient) {
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<App />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
const mockFetchSetupStatus = fetchSetupStatus as Mock;
|
|
const mockFetchMe = fetchMe as Mock;
|
|
const _mockFetchAuthMode = fetchAuthMode as Mock;
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe('App — setup-status gate', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset URL to root before each test so BrowserRouter starts at /
|
|
window.history.pushState({}, '', '/');
|
|
// Default: fetchMe returns a valid user (shouldn't be called when setup incomplete)
|
|
mockFetchMe.mockResolvedValue({
|
|
user: {
|
|
id: 1,
|
|
displayName: 'Test User',
|
|
color: '#4a90d9',
|
|
isAdmin: false,
|
|
needsProviderSetup: false,
|
|
hasLocalCredential: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('renders SetupPage (no AppNav) when setupComplete is false', async () => {
|
|
mockFetchSetupStatus.mockResolvedValue({ setupComplete: false });
|
|
|
|
const queryClient = makeQueryClient();
|
|
renderApp(queryClient);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('setup-page')).toBeInTheDocument();
|
|
});
|
|
|
|
// AppNav must NOT be rendered when wizard is active
|
|
expect(screen.queryByTestId('app-nav')).toBeNull();
|
|
});
|
|
|
|
it('renders calendar route (with AppNav) when setupComplete is true', async () => {
|
|
mockFetchSetupStatus.mockResolvedValue({ setupComplete: true });
|
|
|
|
const queryClient = makeQueryClient();
|
|
renderApp(queryClient);
|
|
|
|
await waitFor(() => {
|
|
// CalendarShell (default / → /calendar redirect) should render
|
|
expect(screen.getByTestId('calendar-shell')).toBeInTheDocument();
|
|
});
|
|
|
|
// AppNav IS rendered in the normal app shell
|
|
expect(screen.getByTestId('app-nav')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not render calendar-shell while setupQuery is loading', async () => {
|
|
// Never resolve — simulates loading state
|
|
mockFetchSetupStatus.mockReturnValue(new Promise(() => undefined));
|
|
|
|
const queryClient = makeQueryClient();
|
|
renderApp(queryClient);
|
|
|
|
// Wait a tick for any async resolution
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
// CalendarShell must NOT be shown during loading (the loading gate hides it)
|
|
expect(screen.queryByTestId('calendar-shell')).toBeNull();
|
|
});
|
|
|
|
it('navigates to /setup when visiting / with setupComplete false', async () => {
|
|
mockFetchSetupStatus.mockResolvedValue({ setupComplete: false });
|
|
|
|
const queryClient = makeQueryClient();
|
|
renderApp(queryClient);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('setup-page')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// gap 5 (T-12-04): manually visiting /setup AFTER setup is complete must show the
|
|
// "Setup already complete" surface (alreadyLocked), NOT re-mount the active wizard.
|
|
it('renders the "already complete" surface (not the wizard) on /setup when setupComplete is true', async () => {
|
|
mockFetchSetupStatus.mockResolvedValue({ setupComplete: true });
|
|
// Navigate directly to /setup (URL-isolation pattern — beforeEach reset to /)
|
|
window.history.pushState({}, '', '/setup');
|
|
|
|
const queryClient = makeQueryClient();
|
|
renderApp(queryClient);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('setup-page')).toHaveTextContent('Setup already complete');
|
|
});
|
|
|
|
// The active wizard's Step 1 heading must NOT render when setup is complete
|
|
expect(screen.queryByText('Welcome to FamilySync Setup')).toBeNull();
|
|
// Standalone wizard surface — no AppNav shell on /setup
|
|
expect(screen.queryByTestId('app-nav')).toBeNull();
|
|
});
|
|
|
|
// gap 5 counterpart: /setup with setupComplete false still mounts the active wizard.
|
|
it('renders the active wizard on /setup when setupComplete is false', async () => {
|
|
mockFetchSetupStatus.mockResolvedValue({ setupComplete: false });
|
|
window.history.pushState({}, '', '/setup');
|
|
|
|
const queryClient = makeQueryClient();
|
|
renderApp(queryClient);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('setup-page')).toHaveTextContent('Welcome to FamilySync Setup');
|
|
});
|
|
|
|
expect(screen.queryByText('Setup already complete')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// Phase 19 (AUTH-LOCAL-15): the unauthenticated → /login redirect gate. This lives
|
|
// here at the unit level because the e2e harness runs DEV_AUTH_BYPASS-only (global-setup
|
|
// refuses a non-bypass DB), and under the always-on bypass /api/me is authed via DEV_USER
|
|
// injection regardless of any cookie — so a logged-out state (meQuery.isError) is
|
|
// architecturally unreachable in the browser harness. The redirect logic is controllable
|
|
// here by rejecting fetchMe.
|
|
describe('App — auth gate (Phase 19)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
window.history.pushState({}, '', '/');
|
|
mockFetchSetupStatus.mockResolvedValue({ setupComplete: true });
|
|
_mockFetchAuthMode.mockResolvedValue({ localEnabled: true, oidcEnabled: false });
|
|
});
|
|
|
|
it('redirects to /login when fetchMe errors (unauthenticated) and localEnabled', async () => {
|
|
mockFetchMe.mockRejectedValue(new Error('401 Unauthorized'));
|
|
|
|
const queryClient = makeQueryClient();
|
|
renderApp(queryClient);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('login-page')).toBeInTheDocument();
|
|
});
|
|
|
|
// The authenticated app shell must NOT render for an unauthenticated user.
|
|
expect(screen.queryByTestId('calendar-shell')).toBeNull();
|
|
expect(screen.queryByTestId('app-nav')).toBeNull();
|
|
});
|
|
|
|
it('renders the app shell (not /login) when fetchMe succeeds', async () => {
|
|
mockFetchMe.mockResolvedValue({
|
|
user: {
|
|
id: 1,
|
|
displayName: 'Test User',
|
|
color: '#4a90d9',
|
|
isAdmin: false,
|
|
needsProviderSetup: false,
|
|
hasLocalCredential: false,
|
|
},
|
|
});
|
|
|
|
const queryClient = makeQueryClient();
|
|
renderApp(queryClient);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('calendar-shell')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.queryByTestId('login-page')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('App — setupStatus and route presence', () => {
|
|
it('App.tsx references setupStatus queryKey', () => {
|
|
// This test verifies the source-level contract via module inspection.
|
|
// The setupQuery with queryKey ['setupStatus'] is in App.tsx.
|
|
// Since the component works correctly in the gate tests above, this is satisfied.
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it('App.tsx imports SetupPage', () => {
|
|
// SetupPage mock is used in rendering, confirming the import resolves.
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|