53 - Frontend: wire scenario show route and distinct not-found state
Some checks failed
CI / backend (push) Failing after 8m48s
CI / frontend (push) Failing after 29s
CI / e2e (push) Has been skipped

This commit is contained in:
myrmidex 2026-07-05 23:27:17 +02:00
parent 2e73737c29
commit bfaf5c49ed
4 changed files with 94 additions and 4 deletions

View file

@ -98,4 +98,44 @@ describe('ScenarioShowPage', () => {
expect(rentPriorityMarker).toBeInTheDocument() expect(rentPriorityMarker).toBeInTheDocument()
expect(diningPriorityMarker).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,
)
})
}) })

View file

@ -3,7 +3,7 @@ import { useParams } from 'react-router'
import AuthedLayout from '../components/layout/AuthedLayout' import AuthedLayout from '../components/layout/AuthedLayout'
import DigitalProgressBar from '../components/ui/DigitalProgressBar' import DigitalProgressBar from '../components/ui/DigitalProgressBar'
import Panel from '../components/ui/Panel' import Panel from '../components/ui/Panel'
import { api } from '../lib/api' import { ApiError, api } from '../lib/api'
import { bucketCapacity } from '../lib/bucketCapacity' import { bucketCapacity } from '../lib/bucketCapacity'
import { formatAllocation } from '../lib/bucketDisplay' import { formatAllocation } from '../lib/bucketDisplay'
import type { Bucket, Scenario } from '../types/api' import type { Bucket, Scenario } from '../types/api'
@ -11,6 +11,7 @@ import type { Bucket, Scenario } from '../types/api'
type LoadState = type LoadState =
| { status: 'loading' } | { status: 'loading' }
| { status: 'error' } | { status: 'error' }
| { status: 'not-found' }
| { status: 'ready'; scenario: Scenario } | { status: 'ready'; scenario: Scenario }
function BucketCard({ bucket }: { bucket: Bucket }) { function BucketCard({ bucket }: { bucket: Bucket }) {
@ -54,8 +55,15 @@ export default function ScenarioShowPage() {
setState({ status: 'ready', scenario }) setState({ status: 'ready', scenario })
} }
}) })
.catch(() => { .catch((error: unknown) => {
if (active) { 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' }) setState({ status: 'error' })
} }
}) })
@ -80,6 +88,12 @@ export default function ScenarioShowPage() {
</p> </p>
)} )}
{state.status === 'not-found' && (
<p role="status" className="text-muted-foreground font-mono text-sm">
Scenario not found.
</p>
)}
{state.status === 'ready' && ( {state.status === 'ready' && (
<> <>
<div className="space-y-1"> <div className="space-y-1">

View file

@ -68,6 +68,38 @@ describe('app router config', () => {
expect(await screen.findByLabelText(/email/i)).toBeInTheDocument() 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(
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>,
)
expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
})
it('an authenticated user at /login is redirected to the app', async () => { it('an authenticated user at /login is redirected to the app', async () => {
server.use( server.use(
http.get('http://localhost/api/me', () => http.get('http://localhost/api/me', () =>

View file

@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
import LoginPage from './auth/LoginPage' import LoginPage from './auth/LoginPage'
import RegisterPage from './auth/RegisterPage' import RegisterPage from './auth/RegisterPage'
import { RequireAuth, RequireGuest } from './auth/guards' import { RequireAuth, RequireGuest } from './auth/guards'
import ScenarioShowPage from './pages/ScenarioShowPage'
import ScenariosPage from './pages/ScenariosPage' import ScenariosPage from './pages/ScenariosPage'
/** /**
@ -23,7 +24,10 @@ export const routes: RouteObject[] = [
}, },
{ {
element: <RequireAuth />, element: <RequireAuth />,
children: [{ path: '/', element: <ScenariosPage /> }], children: [
{ path: '/', element: <ScenariosPage /> },
{ path: '/scenarios/:id', element: <ScenarioShowPage /> },
],
}, },
// Unknown paths fall back to the app root (which re-guards). // Unknown paths fall back to the app root (which re-guards).
{ path: '*', element: <Navigate to="/" replace /> }, { path: '*', element: <Navigate to="/" replace /> },