diff --git a/frontend/src/pages/ScenarioShowPage.test.tsx b/frontend/src/pages/ScenarioShowPage.test.tsx index f5340c0..2be3109 100644 --- a/frontend/src/pages/ScenarioShowPage.test.tsx +++ b/frontend/src/pages/ScenarioShowPage.test.tsx @@ -98,4 +98,44 @@ describe('ScenarioShowPage', () => { expect(rentPriorityMarker).toBeInTheDocument() expect(diningPriorityMarker).toBeInTheDocument() }) + + it('renders a distinct not-found state when the scenario 404s (e.g. not owned)', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + http.get(`http://localhost/api/scenarios/${SCENARIO_ID}`, () => + HttpResponse.json({ message: 'Not Found' }, { status: 404 }), + ), + ) + + renderPage() + + expect(await screen.findByText(/not found/i)).toBeInTheDocument() + expect( + screen.queryByText(/could not load this scenario/i), + ).not.toBeInTheDocument() + + // Lock in the accessibility semantics distinction from the error state + // (role="alert") — a copy-paste that swaps this to "alert" should fail + // here even if the visible copy still says "not found". + expect(await screen.findByRole('status')).toHaveTextContent(/not found/i) + }) + + it('renders the generic error state for a non-404 failure', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + http.get(`http://localhost/api/scenarios/${SCENARIO_ID}`, () => + HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }), + ), + ) + + renderPage() + + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not load this scenario/i, + ) + }) }) diff --git a/frontend/src/pages/ScenarioShowPage.tsx b/frontend/src/pages/ScenarioShowPage.tsx index dfa8910..c5c5882 100644 --- a/frontend/src/pages/ScenarioShowPage.tsx +++ b/frontend/src/pages/ScenarioShowPage.tsx @@ -3,7 +3,7 @@ import { useParams } from 'react-router' import AuthedLayout from '../components/layout/AuthedLayout' import DigitalProgressBar from '../components/ui/DigitalProgressBar' import Panel from '../components/ui/Panel' -import { api } from '../lib/api' +import { ApiError, api } from '../lib/api' import { bucketCapacity } from '../lib/bucketCapacity' import { formatAllocation } from '../lib/bucketDisplay' import type { Bucket, Scenario } from '../types/api' @@ -11,6 +11,7 @@ import type { Bucket, Scenario } from '../types/api' type LoadState = | { status: 'loading' } | { status: 'error' } + | { status: 'not-found' } | { status: 'ready'; scenario: Scenario } function BucketCard({ bucket }: { bucket: Bucket }) { @@ -54,8 +55,15 @@ export default function ScenarioShowPage() { setState({ status: 'ready', scenario }) } }) - .catch(() => { - if (active) { + .catch((error: unknown) => { + if (!active) { + return + } + // Owner scoping (#50) returns 404 for a scenario you don't own — surface + // it as a distinct not-found state rather than a generic failure. + if (error instanceof ApiError && error.status === 404) { + setState({ status: 'not-found' }) + } else { setState({ status: 'error' }) } }) @@ -80,6 +88,12 @@ export default function ScenarioShowPage() {

)} + {state.status === 'not-found' && ( +

+ Scenario not found. +

+ )} + {state.status === 'ready' && ( <>
diff --git a/frontend/src/router.test.tsx b/frontend/src/router.test.tsx index cd2a6d4..d79569d 100644 --- a/frontend/src/router.test.tsx +++ b/frontend/src/router.test.tsx @@ -68,6 +68,38 @@ describe('app router config', () => { expect(await screen.findByLabelText(/email/i)).toBeInTheDocument() }) + it('navigating to /scenarios/:id renders the scenario show page', async () => { + const SCENARIO_ID = '11111111-1111-1111-1111-111111111111' + + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + http.get(`http://localhost/api/scenarios/${SCENARIO_ID}`, () => + HttpResponse.json({ + '@context': '/api/contexts/Scenario', + '@id': `/api/scenarios/${SCENARIO_ID}`, + '@type': 'Scenario', + name: 'Emergency Fund', + description: 'Rainy day savings', + buckets: [], + }), + ), + ) + + const router = createMemoryRouter(routes, { + initialEntries: [`/scenarios/${SCENARIO_ID}`], + }) + + render( + + + , + ) + + expect(await screen.findByText('Emergency Fund')).toBeInTheDocument() + }) + it('an authenticated user at /login is redirected to the app', async () => { server.use( http.get('http://localhost/api/me', () => diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index d33ac3b..0ed147c 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate, type RouteObject } from 'react-router' import LoginPage from './auth/LoginPage' import RegisterPage from './auth/RegisterPage' import { RequireAuth, RequireGuest } from './auth/guards' +import ScenarioShowPage from './pages/ScenarioShowPage' import ScenariosPage from './pages/ScenariosPage' /** @@ -23,7 +24,10 @@ export const routes: RouteObject[] = [ }, { element: , - children: [{ path: '/', element: }], + children: [ + { path: '/', element: }, + { path: '/scenarios/:id', element: }, + ], }, // Unknown paths fall back to the app root (which re-guards). { path: '*', element: },