141 lines
5 KiB
TypeScript
141 lines
5 KiB
TypeScript
import { render, screen } from '@testing-library/react'
|
|
import { http, HttpResponse } from 'msw'
|
|
import { MemoryRouter, Route, Routes } from 'react-router'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { AuthProvider } from '../auth/AuthProvider'
|
|
import { server } from '../test/server'
|
|
import ScenarioShowPage from './ScenarioShowPage'
|
|
|
|
const SCENARIO_ID = '11111111-1111-1111-1111-111111111111'
|
|
|
|
/**
|
|
* ScenarioShowPage renders under AuthProvider (same /api/me probe as every
|
|
* authed page) plus a minimal own route harness carrying the `:id` param —
|
|
* the real routes.tsx wiring is a separate test (14), so this page test does
|
|
* not depend on it.
|
|
*/
|
|
function renderPage() {
|
|
render(
|
|
<MemoryRouter initialEntries={[`/scenarios/${SCENARIO_ID}`]}>
|
|
<AuthProvider>
|
|
<Routes>
|
|
<Route path="/scenarios/:id" element={<ScenarioShowPage />} />
|
|
</Routes>
|
|
</AuthProvider>
|
|
</MemoryRouter>,
|
|
)
|
|
}
|
|
|
|
describe('ScenarioShowPage', () => {
|
|
it('renders each embedded bucket with its name, type, allocation and priority', 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({
|
|
'@context': '/api/contexts/Scenario',
|
|
'@id': `/api/scenarios/${SCENARIO_ID}`,
|
|
'@type': 'Scenario',
|
|
name: 'Emergency Fund',
|
|
description: 'Rainy day savings',
|
|
buckets: [
|
|
{
|
|
'@id': '/api/buckets/22222222-2222-2222-2222-222222222222',
|
|
'@type': 'Bucket',
|
|
name: 'Rent',
|
|
type: 'need',
|
|
priority: 1,
|
|
sortOrder: 0,
|
|
allocationType: 'fixed_limit',
|
|
allocationValue: 10000,
|
|
startingAmount: 3000,
|
|
bufferMultiplier: '0.00',
|
|
},
|
|
{
|
|
'@id': '/api/buckets/33333333-3333-3333-3333-333333333333',
|
|
'@type': 'Bucket',
|
|
name: 'Dining Out',
|
|
type: 'want',
|
|
priority: 2,
|
|
sortOrder: 1,
|
|
allocationType: 'percentage',
|
|
allocationValue: 2500,
|
|
startingAmount: 1200,
|
|
bufferMultiplier: '0.00',
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
// Deliberately NOT stubbing /api/buckets — MSW's onUnhandledRequest:
|
|
// 'error' fails this test if the page ever issues a second fetch,
|
|
// enforcing the single-GET-with-embedded-buckets contract.
|
|
)
|
|
|
|
renderPage()
|
|
|
|
expect(await screen.findByText('Rent')).toBeInTheDocument()
|
|
expect(screen.getByText('Dining Out')).toBeInTheDocument()
|
|
|
|
expect(screen.getByText(/need/i)).toBeInTheDocument()
|
|
expect(screen.getByText(/want/i)).toBeInTheDocument()
|
|
|
|
// Allocation: formatted per the locked display contract — fixed_limit is
|
|
// dollars-with-cents from CENTS (10000 -> '$100.00'), percentage is
|
|
// percent from BASIS POINTS (2500 -> '25%').
|
|
expect(screen.getByText('$100.00')).toBeInTheDocument()
|
|
expect(screen.getByText('25%')).toBeInTheDocument()
|
|
|
|
// Priority: assert each bucket's own priority is shown somewhere within
|
|
// its own rendered content, rather than a page-wide loose number match
|
|
// (which could coincidentally match allocation/other digits instead).
|
|
const rentPriorityMarker = await screen.findByText((_, element) =>
|
|
element?.textContent === 'Priority 1',
|
|
)
|
|
const diningPriorityMarker = await screen.findByText((_, element) =>
|
|
element?.textContent === 'Priority 2',
|
|
)
|
|
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,
|
|
)
|
|
})
|
|
})
|