53 - Frontend: wire scenario show route and distinct not-found state
This commit is contained in:
parent
2e73737c29
commit
bfaf5c49ed
4 changed files with 94 additions and 4 deletions
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
{state.status === 'not-found' && (
|
||||
<p role="status" className="text-muted-foreground font-mono text-sm">
|
||||
Scenario not found.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === 'ready' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
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', () =>
|
||||
|
|
|
|||
|
|
@ -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: <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).
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
|
|
|
|||
Loading…
Reference in a new issue