From fdcb4dc4426a0abf12f1f1d3a81e8553497349ba Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 21:21:29 -0400 Subject: [PATCH] feat(12-07): gate /setup route on setupComplete (gap 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reverse-gate the /setup route: setupComplete===true → SetupPage alreadyLocked (Surface 8 'Setup already complete'); loading → no-flash placeholder; else wizard - Add App.test.tsx reverse-gate tests (already-complete surface + active wizard on /setup) - SetupPage mock now respects the alreadyLocked prop --- apps/pwa/src/App.test.tsx | 46 ++++++++++++++++++++++++++++++++++++++- apps/pwa/src/App.tsx | 34 ++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/apps/pwa/src/App.test.tsx b/apps/pwa/src/App.test.tsx index 0acd7aa..496d4ed 100644 --- a/apps/pwa/src/App.test.tsx +++ b/apps/pwa/src/App.test.tsx @@ -25,8 +25,17 @@ vi.mock('./components/CalendarShell.js', () => ({ CalendarShell: () =>
CalendarShell
, })); +// 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: () =>
SetupPage
, + SetupPage: ({ alreadyLocked }: { alreadyLocked?: boolean }) => + alreadyLocked ? ( +
Setup already complete
+ ) : ( +
Welcome to FamilySync Setup
+ ), })); vi.mock('./components/AppNav.js', () => ({ @@ -177,6 +186,41 @@ describe('App — setup-status gate', () => { 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(); + }); }); describe('App — setupStatus and route presence', () => { diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 1cf1e3f..b641ea5 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -81,11 +81,21 @@ export default function App() { // Fetch current user once at the app shell level so AppNav has member data on // ALL routes. This is the same query key (['me']) used by CalendarShell, so // TanStack Query deduplicates the request — no double fetch. + // + // gap 6 (mechanism (ii) — ['me'] staleness, NOT a linking gap; see SUMMARY): + // the first-login claim in upsertUser (auth/user.ts) preserves the same users.id, + // so the wizard-stored CalDAV credential stays linked → needsProviderSetup is + // correctly FALSE in the DB after the operator authenticates post-wizard. The bug + // was purely client-cache: a ['me'] entry populated BEFORE the claim (e.g. an + // earlier pre-auth visit) served a stale needsProviderSetup=true for up to 5 + // minutes, so the "Set up your calendar" banner kept showing. Set staleTime 0 on + // the boot ['me'] query so the authenticated app shell always refetches member + // status on entry — needsProviderSetup then reflects the just-claimed credential. const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, retry: false, - staleTime: 5 * 60 * 1000, + staleTime: 0, }); // isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403. @@ -135,8 +145,26 @@ export default function App() { return ( - {/* /setup route — standalone wizard, no AppNav/BottomTabBar shell (UI-SPEC §Routing) */} - } /> + {/* /setup route — standalone wizard, no AppNav/BottomTabBar shell (UI-SPEC §Routing). + Reverse gate (gap 5, T-12-04): once setup is complete the wizard must NOT re-mount. + - While setupQuery is loading → render the no-flash placeholder (no wizard before + status resolves), mirroring the `*`-route loading gate below. + - setupComplete === true → render SetupPage with alreadyLocked → Surface 8 + ("Setup already complete"), keeping the operator on /setup with a terminal surface. + - setupComplete === false (or undefined post-load) → active wizard, as before. + The backend already 423s setup mutations; this is the matching frontend reverse-gate. */} +