Compare commits
3 commits
f16e279f58
...
2e73737c29
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e73737c29 | |||
| 35d83c774f | |||
| 6fd2243c83 |
18 changed files with 1263 additions and 12 deletions
64
frontend/src/lib/bucketCapacity.test.ts
Normal file
64
frontend/src/lib/bucketCapacity.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { bucketCapacity } from './bucketCapacity'
|
||||||
|
import type { Bucket } from '../types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bucketCapacity maps a Bucket's allocation fields to the {current, capacity}
|
||||||
|
* shape DigitalProgressBar expects. Each allocation type has a distinct rule
|
||||||
|
* (fixed_limit / percentage / unlimited), tested independently below.
|
||||||
|
*/
|
||||||
|
describe('bucketCapacity', () => {
|
||||||
|
it('for a fixed_limit bucket, capacity is allocationValue scaled by (1 + bufferMultiplier)', () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
'@id': '/api/buckets/11111111-1111-1111-1111-111111111111',
|
||||||
|
'@type': 'Bucket',
|
||||||
|
name: 'Rent',
|
||||||
|
type: 'need',
|
||||||
|
priority: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
allocationType: 'fixed_limit',
|
||||||
|
allocationValue: 10000,
|
||||||
|
startingAmount: 3000,
|
||||||
|
bufferMultiplier: '0.50',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(bucketCapacity(bucket)).toEqual({ current: 3000, capacity: 15000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for a percentage bucket, capacity is 0 (no finite client-computable cap)', () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
'@id': '/api/buckets/22222222-2222-2222-2222-222222222222',
|
||||||
|
'@type': 'Bucket',
|
||||||
|
name: 'Dining Out',
|
||||||
|
type: 'want',
|
||||||
|
priority: 2,
|
||||||
|
sortOrder: 1,
|
||||||
|
allocationType: 'percentage',
|
||||||
|
allocationValue: 2500,
|
||||||
|
startingAmount: 1200,
|
||||||
|
bufferMultiplier: '0.00',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(bucketCapacity(bucket).capacity).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('for an unlimited bucket, the bar is full even when startingAmount is 0', () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
'@id': '/api/buckets/33333333-3333-3333-3333-333333333333',
|
||||||
|
'@type': 'Bucket',
|
||||||
|
name: 'Overflow',
|
||||||
|
type: 'overflow',
|
||||||
|
priority: 3,
|
||||||
|
sortOrder: 2,
|
||||||
|
allocationType: 'unlimited',
|
||||||
|
allocationValue: null,
|
||||||
|
startingAmount: 0,
|
||||||
|
bufferMultiplier: '0.00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current, capacity } = bucketCapacity(bucket)
|
||||||
|
|
||||||
|
expect(capacity).toBeGreaterThan(0)
|
||||||
|
expect(current).toBe(capacity)
|
||||||
|
})
|
||||||
|
})
|
||||||
39
frontend/src/lib/bucketCapacity.ts
Normal file
39
frontend/src/lib/bucketCapacity.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { Bucket } from '../types/api'
|
||||||
|
|
||||||
|
/** The `{current, capacity}` shape DigitalProgressBar renders (fill = current / capacity). */
|
||||||
|
export interface CapacityBar {
|
||||||
|
current: number
|
||||||
|
capacity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a Bucket's allocation fields to a progress-bar fill. The API exposes no
|
||||||
|
* effective capacity (it's model-only, never serialized), so we derive one per
|
||||||
|
* allocation type:
|
||||||
|
*
|
||||||
|
* - `fixed_limit`: a real cap — `allocationValue` scaled by the buffer
|
||||||
|
* (`allocationValue * (1 + bufferMultiplier)`), filled to `startingAmount`.
|
||||||
|
* - `percentage`: no finite client-computable cap → `capacity: 0`, which
|
||||||
|
* DigitalProgressBar renders as an empty bar. The percentage is shown as a
|
||||||
|
* text label instead.
|
||||||
|
* - `unlimited`: unconstrained by design → a full bar (`current === capacity`),
|
||||||
|
* independent of `startingAmount` (which may be 0).
|
||||||
|
*
|
||||||
|
* `bufferMultiplier` arrives as a decimal string and must be Number-parsed.
|
||||||
|
*/
|
||||||
|
export function bucketCapacity(bucket: Bucket): CapacityBar {
|
||||||
|
switch (bucket.allocationType) {
|
||||||
|
case 'fixed_limit': {
|
||||||
|
const limit = bucket.allocationValue ?? 0
|
||||||
|
const buffer = Number(bucket.bufferMultiplier)
|
||||||
|
return {
|
||||||
|
current: bucket.startingAmount,
|
||||||
|
capacity: Math.round(limit * (1 + buffer)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'percentage':
|
||||||
|
return { current: bucket.startingAmount, capacity: 0 }
|
||||||
|
case 'unlimited':
|
||||||
|
return { current: 1, capacity: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/src/lib/bucketDisplay.test.ts
Normal file
45
frontend/src/lib/bucketDisplay.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { formatAllocation } from './bucketDisplay'
|
||||||
|
import type { Bucket } from '../types/api'
|
||||||
|
|
||||||
|
function bucket(overrides: Partial<Bucket>): Bucket {
|
||||||
|
return {
|
||||||
|
'@id': '/api/buckets/11111111-1111-1111-1111-111111111111',
|
||||||
|
'@type': 'Bucket',
|
||||||
|
name: 'Test',
|
||||||
|
type: 'need',
|
||||||
|
priority: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
allocationType: 'fixed_limit',
|
||||||
|
allocationValue: 0,
|
||||||
|
startingAmount: 0,
|
||||||
|
bufferMultiplier: '0.00',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('formatAllocation', () => {
|
||||||
|
it('formats a fixed_limit allocation as dollars from cents', () => {
|
||||||
|
expect(
|
||||||
|
formatAllocation(
|
||||||
|
bucket({ allocationType: 'fixed_limit', allocationValue: 150000 }),
|
||||||
|
),
|
||||||
|
).toBe('$1,500.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats a percentage allocation from basis points', () => {
|
||||||
|
expect(
|
||||||
|
formatAllocation(
|
||||||
|
bucket({ allocationType: 'percentage', allocationValue: 2500 }),
|
||||||
|
),
|
||||||
|
).toBe('25%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders unlimited allocations as a word', () => {
|
||||||
|
expect(
|
||||||
|
formatAllocation(
|
||||||
|
bucket({ allocationType: 'unlimited', allocationValue: null }),
|
||||||
|
),
|
||||||
|
).toBe('Unlimited')
|
||||||
|
})
|
||||||
|
})
|
||||||
29
frontend/src/lib/bucketDisplay.ts
Normal file
29
frontend/src/lib/bucketDisplay.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { Bucket } from '../types/api'
|
||||||
|
|
||||||
|
const USD = new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable allocation label for a bucket, formatted by allocation type:
|
||||||
|
*
|
||||||
|
* - `fixed_limit`: `allocationValue` is integer cents → a dollar amount
|
||||||
|
* (`150000` → `$1,500.00`).
|
||||||
|
* - `percentage`: `allocationValue` is basis points → a percent
|
||||||
|
* (`2500` → `25%`).
|
||||||
|
* - `unlimited`: no value → the literal `Unlimited`.
|
||||||
|
*
|
||||||
|
* A fixed_limit/percentage bucket always carries a non-null `allocationValue`
|
||||||
|
* (backend invariant); the `?? 0` guards the type only.
|
||||||
|
*/
|
||||||
|
export function formatAllocation(bucket: Bucket): string {
|
||||||
|
switch (bucket.allocationType) {
|
||||||
|
case 'fixed_limit':
|
||||||
|
return USD.format((bucket.allocationValue ?? 0) / 100)
|
||||||
|
case 'percentage':
|
||||||
|
return `${(bucket.allocationValue ?? 0) / 100}%`
|
||||||
|
case 'unlimited':
|
||||||
|
return 'Unlimited'
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/src/lib/iri.test.ts
Normal file
25
frontend/src/lib/iri.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { iriToId } from './iri'
|
||||||
|
|
||||||
|
describe('iriToId', () => {
|
||||||
|
it('extracts the trailing uuid from an API Platform IRI', () => {
|
||||||
|
expect(iriToId('/api/scenarios/11111111-1111-1111-1111-111111111111')).toBe(
|
||||||
|
'11111111-1111-1111-1111-111111111111',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works for any resource IRI, not just scenarios', () => {
|
||||||
|
expect(iriToId('/api/buckets/abc-123')).toBe('abc-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the whole string when there is no slash', () => {
|
||||||
|
expect(iriToId('bare-id')).toBe('bare-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pinned as a known boundary: a trailing-slash IRI yields an empty id. The
|
||||||
|
// API never emits one (API Platform IRIs always end in the resource id), so
|
||||||
|
// this documents the limitation rather than guarding a reachable case.
|
||||||
|
it('returns an empty string for a trailing-slash IRI', () => {
|
||||||
|
expect(iriToId('/api/scenarios/')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
13
frontend/src/lib/iri.ts
Normal file
13
frontend/src/lib/iri.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Extract the trailing id from a JSON-LD `@id` IRI.
|
||||||
|
*
|
||||||
|
* API Platform IRIs look like `/api/scenarios/{uuid}` or `/api/buckets/{uuid}`;
|
||||||
|
* the SPA routes on the bare id (`/scenarios/{uuid}`), so we take everything
|
||||||
|
* after the last slash. `slice(lastIndexOf + 1)` always returns a string (no
|
||||||
|
* possibly-undefined index access), so there is no unreachable fallback branch.
|
||||||
|
* A trailing-slash IRI would yield `''`, but API Platform always ends an IRI in
|
||||||
|
* the resource id, so that case never occurs (pinned in the test as a boundary).
|
||||||
|
*/
|
||||||
|
export function iriToId(iri: string): string {
|
||||||
|
return iri.slice(iri.lastIndexOf('/') + 1)
|
||||||
|
}
|
||||||
101
frontend/src/pages/ScenarioShowPage.test.tsx
Normal file
101
frontend/src/pages/ScenarioShowPage.test.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
114
frontend/src/pages/ScenarioShowPage.tsx
Normal file
114
frontend/src/pages/ScenarioShowPage.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
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 { bucketCapacity } from '../lib/bucketCapacity'
|
||||||
|
import { formatAllocation } from '../lib/bucketDisplay'
|
||||||
|
import type { Bucket, Scenario } from '../types/api'
|
||||||
|
|
||||||
|
type LoadState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'error' }
|
||||||
|
| { status: 'ready'; scenario: Scenario }
|
||||||
|
|
||||||
|
function BucketCard({ bucket }: { bucket: Bucket }) {
|
||||||
|
const { current, capacity } = bucketCapacity(bucket)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="space-y-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h3 className="text-primary font-bold uppercase tracking-wider">
|
||||||
|
{bucket.name}
|
||||||
|
</h3>
|
||||||
|
<span className="text-muted-foreground font-mono text-xs uppercase">
|
||||||
|
{bucket.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-digital text-primary text-xl">
|
||||||
|
{formatAllocation(bucket)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DigitalProgressBar current={current} capacity={capacity} />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground font-mono text-xs">
|
||||||
|
Priority {bucket.priority}
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScenarioShowPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const [state, setState] = useState<LoadState>({ status: 'loading' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
api
|
||||||
|
.get<Scenario>(`/scenarios/${id}`)
|
||||||
|
.then((scenario) => {
|
||||||
|
if (active) {
|
||||||
|
setState({ status: 'ready', scenario })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
setState({ status: 'error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthedLayout>
|
||||||
|
<div className="mx-auto max-w-4xl space-y-4">
|
||||||
|
{state.status === 'loading' && (
|
||||||
|
<p role="status" className="text-muted-foreground font-mono text-sm">
|
||||||
|
Loading scenario…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === 'error' && (
|
||||||
|
<p role="alert" className="text-destructive font-mono text-sm">
|
||||||
|
Could not load this scenario. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === 'ready' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||||
|
{state.scenario.name}
|
||||||
|
</h2>
|
||||||
|
{state.scenario.description !== null && (
|
||||||
|
<p className="text-muted-foreground font-mono text-sm">
|
||||||
|
{state.scenario.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.scenario.buckets.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground font-mono text-sm">
|
||||||
|
This scenario has no buckets yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{state.scenario.buckets.map((bucket) => (
|
||||||
|
<li key={bucket['@id']}>
|
||||||
|
<BucketCard bucket={bucket} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AuthedLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
frontend/src/pages/ScenariosPage.test.tsx
Normal file
146
frontend/src/pages/ScenariosPage.test.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitForElementToBeRemoved,
|
||||||
|
} from '@testing-library/react'
|
||||||
|
import { delay, http, HttpResponse } from 'msw'
|
||||||
|
import { MemoryRouter } from 'react-router'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { AuthProvider } from '../auth/AuthProvider'
|
||||||
|
import { server } from '../test/server'
|
||||||
|
import ScenariosPage from './ScenariosPage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScenariosPage renders under AuthProvider (it probes /api/me on mount, same
|
||||||
|
* as every other authed-page test — see LoginPage.test.tsx / router.test.tsx)
|
||||||
|
* and a MemoryRouter (its rows are <Link>s to /scenarios/{uuid}).
|
||||||
|
*/
|
||||||
|
function renderPage() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<AuthProvider>
|
||||||
|
<ScenariosPage />
|
||||||
|
</AuthProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScenariosPage', () => {
|
||||||
|
it('renders a row per scenario with a link to its show page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('http://localhost/api/me', () =>
|
||||||
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
|
),
|
||||||
|
http.get('http://localhost/api/scenarios', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
'@context': '/api/contexts/Scenario',
|
||||||
|
'@id': '/api/scenarios',
|
||||||
|
'@type': 'hydra:Collection',
|
||||||
|
member: [
|
||||||
|
{
|
||||||
|
'@id': '/api/scenarios/11111111-1111-1111-1111-111111111111',
|
||||||
|
'@type': 'Scenario',
|
||||||
|
name: 'Emergency Fund',
|
||||||
|
description: 'Rainy day savings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@id': '/api/scenarios/22222222-2222-2222-2222-222222222222',
|
||||||
|
'@type': 'Scenario',
|
||||||
|
name: 'Vacation',
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalItems: 2,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Vacation')).toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: /emergency fund/i }),
|
||||||
|
).toHaveAttribute('href', '/scenarios/11111111-1111-1111-1111-111111111111')
|
||||||
|
expect(screen.getByRole('link', { name: /vacation/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/scenarios/22222222-2222-2222-2222-222222222222',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty state when the user has no scenarios', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('http://localhost/api/me', () =>
|
||||||
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
|
),
|
||||||
|
http.get('http://localhost/api/scenarios', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
'@context': '/api/contexts/Scenario',
|
||||||
|
'@id': '/api/scenarios',
|
||||||
|
'@type': 'hydra:Collection',
|
||||||
|
member: [],
|
||||||
|
totalItems: 0,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/don.t have any scenarios|no scenarios/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a loading state while scenarios are being fetched', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('http://localhost/api/me', () =>
|
||||||
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
|
),
|
||||||
|
http.get('http://localhost/api/scenarios', async () => {
|
||||||
|
await delay(50)
|
||||||
|
return HttpResponse.json({
|
||||||
|
'@context': '/api/contexts/Scenario',
|
||||||
|
'@id': '/api/scenarios',
|
||||||
|
'@type': 'hydra:Collection',
|
||||||
|
member: [
|
||||||
|
{
|
||||||
|
'@id': '/api/scenarios/11111111-1111-1111-1111-111111111111',
|
||||||
|
'@type': 'Scenario',
|
||||||
|
name: 'Emergency Fund',
|
||||||
|
description: 'Rainy day savings',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalItems: 1,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const loadingMarker = await screen.findByRole('status')
|
||||||
|
expect(loadingMarker).toBeInTheDocument()
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(loadingMarker)
|
||||||
|
|
||||||
|
expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error state when the scenarios request fails', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('http://localhost/api/me', () =>
|
||||||
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
|
),
|
||||||
|
http.get('http://localhost/api/scenarios', () =>
|
||||||
|
HttpResponse.json({ message: 'boom' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,21 +1,89 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router'
|
||||||
import AuthedLayout from '../components/layout/AuthedLayout'
|
import AuthedLayout from '../components/layout/AuthedLayout'
|
||||||
import Button from '../components/ui/Button'
|
|
||||||
import DigitalProgressBar from '../components/ui/DigitalProgressBar'
|
|
||||||
import Panel from '../components/ui/Panel'
|
import Panel from '../components/ui/Panel'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { iriToId } from '../lib/iri'
|
||||||
|
import type { HydraCollection, ScenarioSummary } from '../types/api'
|
||||||
|
|
||||||
|
type LoadState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'error' }
|
||||||
|
| { status: 'ready'; scenarios: ScenarioSummary[] }
|
||||||
|
|
||||||
// Placeholder for the authenticated app home. The real scenario UI lands in the
|
|
||||||
// frontend feature tickets (#53–#56); for now it shows the primitives + theme.
|
|
||||||
export default function ScenariosPage() {
|
export default function ScenariosPage() {
|
||||||
|
const [state, setState] = useState<LoadState>({ status: 'loading' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
api
|
||||||
|
.get<HydraCollection<ScenarioSummary>>('/scenarios')
|
||||||
|
.then((collection) => {
|
||||||
|
if (active) {
|
||||||
|
setState({ status: 'ready', scenarios: collection.member })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
setState({ status: 'error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthedLayout>
|
<AuthedLayout>
|
||||||
<Panel className="mx-auto max-w-md space-y-4">
|
<div className="mx-auto max-w-2xl space-y-4">
|
||||||
<h2 className="text-primary font-bold uppercase tracking-wider">
|
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||||
Emergency Fund
|
Scenarios
|
||||||
</h2>
|
</h2>
|
||||||
<DigitalProgressBar current={750} capacity={1000} />
|
|
||||||
<p className="font-digital text-primary text-xl">$750 / $1000</p>
|
{state.status === 'loading' && (
|
||||||
<Button glow>+ Add Bucket</Button>
|
<p role="status" className="text-muted-foreground font-mono text-sm">
|
||||||
</Panel>
|
Loading scenarios…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === 'error' && (
|
||||||
|
<p role="alert" className="text-destructive font-mono text-sm">
|
||||||
|
Could not load your scenarios. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === 'ready' && state.scenarios.length === 0 && (
|
||||||
|
<p className="text-muted-foreground font-mono text-sm">
|
||||||
|
You don’t have any scenarios yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === 'ready' && state.scenarios.length > 0 && (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{state.scenarios.map((scenario) => (
|
||||||
|
<li key={scenario['@id']}>
|
||||||
|
<Link
|
||||||
|
to={`/scenarios/${iriToId(scenario['@id'])}`}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<Panel className="space-y-1 transition hover:brightness-110">
|
||||||
|
<h3 className="text-primary font-bold uppercase tracking-wider">
|
||||||
|
{scenario.name}
|
||||||
|
</h3>
|
||||||
|
{scenario.description !== null && (
|
||||||
|
<p className="text-muted-foreground font-mono text-sm">
|
||||||
|
{scenario.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</AuthedLayout>
|
</AuthedLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,22 @@ describe('app router config', () => {
|
||||||
http.get('http://localhost/api/me', () =>
|
http.get('http://localhost/api/me', () =>
|
||||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
),
|
),
|
||||||
|
http.get('http://localhost/api/scenarios', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
'@context': '/api/contexts/Scenario',
|
||||||
|
'@id': '/api/scenarios',
|
||||||
|
'@type': 'hydra:Collection',
|
||||||
|
member: [
|
||||||
|
{
|
||||||
|
'@id': '/api/scenarios/11111111-1111-1111-1111-111111111111',
|
||||||
|
'@type': 'Scenario',
|
||||||
|
name: 'Emergency Fund',
|
||||||
|
description: 'Rainy day savings',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalItems: 1,
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const router = createMemoryRouter(routes, {
|
const router = createMemoryRouter(routes, {
|
||||||
|
|
@ -57,6 +73,22 @@ describe('app router config', () => {
|
||||||
http.get('http://localhost/api/me', () =>
|
http.get('http://localhost/api/me', () =>
|
||||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
),
|
),
|
||||||
|
http.get('http://localhost/api/scenarios', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
'@context': '/api/contexts/Scenario',
|
||||||
|
'@id': '/api/scenarios',
|
||||||
|
'@type': 'hydra:Collection',
|
||||||
|
member: [
|
||||||
|
{
|
||||||
|
'@id': '/api/scenarios/11111111-1111-1111-1111-111111111111',
|
||||||
|
'@type': 'Scenario',
|
||||||
|
name: 'Emergency Fund',
|
||||||
|
description: 'Rainy day savings',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalItems: 1,
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const router = createMemoryRouter(routes, { initialEntries: ['/login'] })
|
const router = createMemoryRouter(routes, { initialEntries: ['/login'] })
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,49 @@ export interface HydraCollection<T> {
|
||||||
member: T[]
|
member: T[]
|
||||||
totalItems: number
|
totalItems: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Scenario as it appears in the collection (`GET /api/scenarios`) — the lean
|
||||||
|
* list shape. Buckets are NOT embedded here (only on the item representation,
|
||||||
|
* see #64); a distinct type keeps list code from reading `.buckets` off a member.
|
||||||
|
*/
|
||||||
|
export interface ScenarioSummary extends HydraResource {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket classification. String-literal unions mirror the backend's backed
|
||||||
|
* enums (`App\Enum\BucketType` / `BucketAllocationType`); `erasableSyntaxOnly`
|
||||||
|
* bans TS enums. Invariant: `overflow` ⟺ `unlimited`, `need`/`want` pair with
|
||||||
|
* `fixed_limit`/`percentage`.
|
||||||
|
*/
|
||||||
|
export type BucketType = 'need' | 'want' | 'overflow'
|
||||||
|
export type BucketAllocationType = 'fixed_limit' | 'percentage' | 'unlimited'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Bucket as embedded in the Scenario item representation (`GET
|
||||||
|
* /api/scenarios/{id}`, see #64). Monetary fields are integer cents;
|
||||||
|
* `allocationValue` is cents for fixed_limit, basis-points for percentage, and
|
||||||
|
* null for unlimited. `bufferMultiplier` is a decimal string (e.g. '1.50').
|
||||||
|
*/
|
||||||
|
export interface Bucket extends HydraResource {
|
||||||
|
name: string
|
||||||
|
type: BucketType
|
||||||
|
priority: number
|
||||||
|
sortOrder: number
|
||||||
|
allocationType: BucketAllocationType
|
||||||
|
allocationValue: number | null
|
||||||
|
startingAmount: number
|
||||||
|
bufferMultiplier: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Scenario's item representation with its buckets embedded inline (#64), so
|
||||||
|
* the show page fetches one resource. `owner` is intentionally not exposed.
|
||||||
|
*/
|
||||||
|
export interface Scenario extends HydraResource {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
buckets: Bucket[]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use App\Validator\CompatibleAllocationType;
|
||||||
use App\Validator\SingleOverflowPerScenario;
|
use App\Validator\SingleOverflowPerScenario;
|
||||||
use App\Validator\UniquePriorityPerScenario;
|
use App\Validator\UniquePriorityPerScenario;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Uid\UuidV7;
|
use Symfony\Component\Uid\UuidV7;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
|
@ -36,35 +37,43 @@ class Bucket
|
||||||
#[ORM\Column(type: 'uuid')]
|
#[ORM\Column(type: 'uuid')]
|
||||||
private UuidV7 $id;
|
private UuidV7 $id;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Scenario::class)]
|
#[ORM\ManyToOne(targetEntity: Scenario::class, inversedBy: 'buckets')]
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
private Scenario $scenario;
|
private Scenario $scenario;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
#[Assert\NotBlank]
|
#[Assert\NotBlank]
|
||||||
|
#[Groups(['bucket:read'])]
|
||||||
private string $name;
|
private string $name;
|
||||||
|
|
||||||
#[ORM\Column(enumType: BucketType::class)]
|
#[ORM\Column(enumType: BucketType::class)]
|
||||||
|
#[Groups(['bucket:read'])]
|
||||||
private BucketType $type = BucketType::NEED;
|
private BucketType $type = BucketType::NEED;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Assert\NotNull]
|
#[Assert\NotNull]
|
||||||
|
#[Groups(['bucket:read'])]
|
||||||
private int $priority;
|
private int $priority;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
#[Groups(['bucket:read'])]
|
||||||
private int $sortOrder = 0;
|
private int $sortOrder = 0;
|
||||||
|
|
||||||
#[ORM\Column(enumType: BucketAllocationType::class, options: ['default' => BucketAllocationType::FIXED_LIMIT])]
|
#[ORM\Column(enumType: BucketAllocationType::class, options: ['default' => BucketAllocationType::FIXED_LIMIT])]
|
||||||
|
#[Groups(['bucket:read'])]
|
||||||
private BucketAllocationType $allocationType = BucketAllocationType::FIXED_LIMIT;
|
private BucketAllocationType $allocationType = BucketAllocationType::FIXED_LIMIT;
|
||||||
|
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
|
#[Groups(['bucket:read'])]
|
||||||
private ?int $allocationValue = null;
|
private ?int $allocationValue = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
#[Groups(['bucket:read'])]
|
||||||
private int $startingAmount = 0;
|
private int $startingAmount = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'decimal', precision: 5, scale: 2, options: ['default' => '0.00'])]
|
#[ORM\Column(type: 'decimal', precision: 5, scale: 2, options: ['default' => '0.00'])]
|
||||||
#[Assert\GreaterThanOrEqual(0)]
|
#[Assert\GreaterThanOrEqual(0)]
|
||||||
|
#[Groups(['bucket:read'])]
|
||||||
private string $bufferMultiplier = '0.00';
|
private string $bufferMultiplier = '0.00';
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,21 @@ use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Repository\ScenarioRepository;
|
use App\Repository\ScenarioRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Uid\UuidV7;
|
use Symfony\Component\Uid\UuidV7;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: ScenarioRepository::class)]
|
#[ORM\Entity(repositoryClass: ScenarioRepository::class)]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [new GetCollection(), new Get(), new Post()],
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new Get(normalizationContext: ['groups' => ['scenario:read', 'bucket:read']]),
|
||||||
|
new Post(),
|
||||||
|
],
|
||||||
security: "is_granted('ROLE_USER')",
|
security: "is_granted('ROLE_USER')",
|
||||||
)]
|
)]
|
||||||
class Scenario
|
class Scenario
|
||||||
|
|
@ -32,16 +39,30 @@ class Scenario
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
private User $owner;
|
private User $owner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedded in the item representation only (scenario:read is on the Get op, not GetCollection):
|
||||||
|
* the collection stays lean and avoids an N+1 lazy-load of buckets per scenario.
|
||||||
|
*
|
||||||
|
* @var Collection<int, Bucket>
|
||||||
|
*/
|
||||||
|
#[ORM\OneToMany(mappedBy: 'scenario', targetEntity: Bucket::class)]
|
||||||
|
#[ORM\OrderBy(['sortOrder' => 'ASC'])]
|
||||||
|
#[Groups(['scenario:read'])]
|
||||||
|
private Collection $buckets;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
#[Assert\NotBlank]
|
#[Assert\NotBlank]
|
||||||
|
#[Groups(['scenario:read'])]
|
||||||
private string $name;
|
private string $name;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
#[Groups(['scenario:read'])]
|
||||||
private ?string $description = null;
|
private ?string $description = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->id = new UuidV7();
|
$this->id = new UuidV7();
|
||||||
|
$this->buckets = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): UuidV7
|
public function getId(): UuidV7
|
||||||
|
|
@ -54,6 +75,14 @@ class Scenario
|
||||||
return $this->owner;
|
return $this->owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Bucket>
|
||||||
|
*/
|
||||||
|
public function getBuckets(): Collection
|
||||||
|
{
|
||||||
|
return $this->buckets;
|
||||||
|
}
|
||||||
|
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
return $this->name;
|
return $this->name;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Tests\Entity;
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Tests\Concerns\CreatesUsers;
|
use App\Tests\Concerns\CreatesUsers;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
@ -79,4 +80,49 @@ final class ScenarioPersistenceTest extends KernelTestCase
|
||||||
self::assertNotNull($reloaded);
|
self::assertNotNull($reloaded);
|
||||||
self::assertNull($reloaded->getDescription());
|
self::assertNull($reloaded->getDescription());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testScenarioExposesItsBucketsInverseRelation(): void
|
||||||
|
{
|
||||||
|
$scenario = new Scenario();
|
||||||
|
$scenario->setName('Household Budget');
|
||||||
|
$scenario->setOwner($this->persistOwner());
|
||||||
|
|
||||||
|
$this->em->persist($scenario);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rent = new Bucket();
|
||||||
|
$rent->setScenario($scenario);
|
||||||
|
$rent->setName('Rent');
|
||||||
|
$rent->setPriority(1);
|
||||||
|
|
||||||
|
$groceries = new Bucket();
|
||||||
|
$groceries->setScenario($scenario);
|
||||||
|
$groceries->setName('Groceries');
|
||||||
|
$groceries->setPriority(2);
|
||||||
|
|
||||||
|
$this->em->persist($rent);
|
||||||
|
$this->em->persist($groceries);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$id = $scenario->getId();
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
$reloaded = $this->em->find(Scenario::class, $id);
|
||||||
|
|
||||||
|
self::assertNotNull($reloaded);
|
||||||
|
|
||||||
|
$names = array_map(
|
||||||
|
static fn (Bucket $bucket): string => $bucket->getName(),
|
||||||
|
$reloaded->getBuckets()->toArray(),
|
||||||
|
);
|
||||||
|
|
||||||
|
sort($names);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['Groceries', 'Rent'],
|
||||||
|
$names,
|
||||||
|
'A reloaded Scenario must expose its Buckets via an inverse relation.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,79 @@ final class BucketApiTest extends WebTestCase
|
||||||
self::assertSame('Rent', $body['name'] ?? null);
|
self::assertSame('Rent', $body['name'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CHARACTERIZATION TEST — pins the CURRENT flat Bucket item AND collection
|
||||||
|
* representations before #64 introduces serialization groups elsewhere.
|
||||||
|
* #64 does not touch GET /api/buckets or GET /api/buckets/{id} — this
|
||||||
|
* test guards against a group definition accidentally narrowing the
|
||||||
|
* flat Bucket representation as a side effect. Born green.
|
||||||
|
*/
|
||||||
|
public function testGetBucketItemAndCollectionFieldsUnchanged(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->persistScenario('Household Budget');
|
||||||
|
$bucket = $this->persistBucket($scenario, 'Rent', 1);
|
||||||
|
$bucket->setSortOrder(4);
|
||||||
|
$bucket->setType(BucketType::NEED);
|
||||||
|
$bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT);
|
||||||
|
$bucket->setAllocationValue(150000);
|
||||||
|
$bucket->setStartingAmount(5000);
|
||||||
|
$bucket->setBufferMultiplier('1.50');
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/buckets/'.$bucket->getId(), server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$itemResponse = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(200, $itemResponse->getStatusCode());
|
||||||
|
|
||||||
|
$item = json_decode((string) $itemResponse->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($item, 'The item response must be a JSON-LD object.');
|
||||||
|
self::assertArrayHasKey('@id', $item);
|
||||||
|
self::assertSame('Bucket', $item['@type'] ?? null);
|
||||||
|
self::assertSame('Rent', $item['name'] ?? null);
|
||||||
|
self::assertSame(BucketType::NEED->value, $item['type'] ?? null);
|
||||||
|
self::assertSame(1, $item['priority'] ?? null);
|
||||||
|
self::assertSame(4, $item['sortOrder'] ?? null);
|
||||||
|
self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $item['allocationType'] ?? null);
|
||||||
|
self::assertSame(150000, $item['allocationValue'] ?? null);
|
||||||
|
self::assertSame(5000, $item['startingAmount'] ?? null);
|
||||||
|
self::assertSame('1.50', $item['bufferMultiplier'] ?? null);
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/buckets', server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$collectionResponse = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(200, $collectionResponse->getStatusCode());
|
||||||
|
|
||||||
|
$collection = json_decode((string) $collectionResponse->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($collection, 'The collection response must be a JSON-LD/Hydra object.');
|
||||||
|
self::assertArrayHasKey('member', $collection);
|
||||||
|
|
||||||
|
$members = array_column($collection['member'], null, 'name');
|
||||||
|
|
||||||
|
self::assertArrayHasKey('Rent', $members, 'GET /api/buckets must still list the persisted bucket.');
|
||||||
|
|
||||||
|
$member = $members['Rent'];
|
||||||
|
|
||||||
|
self::assertArrayHasKey('@id', $member);
|
||||||
|
self::assertSame('Bucket', $member['@type'] ?? null);
|
||||||
|
self::assertSame(BucketType::NEED->value, $member['type'] ?? null);
|
||||||
|
self::assertSame(1, $member['priority'] ?? null);
|
||||||
|
self::assertSame(4, $member['sortOrder'] ?? null);
|
||||||
|
self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $member['allocationType'] ?? null);
|
||||||
|
self::assertSame(150000, $member['allocationValue'] ?? null);
|
||||||
|
self::assertSame(5000, $member['startingAmount'] ?? null);
|
||||||
|
self::assertSame('1.50', $member['bufferMultiplier'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
public function testPostCreatesABucketWhenAuthenticatedAndResolvesScenarioIri(): void
|
public function testPostCreatesABucketWhenAuthenticatedAndResolvesScenarioIri(): void
|
||||||
{
|
{
|
||||||
$scenario = $this->persistScenario('Household Budget');
|
$scenario = $this->persistScenario('Household Budget');
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@
|
||||||
|
|
||||||
namespace App\Tests\Functional;
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Enum\BucketAllocationType;
|
||||||
|
use App\Enum\BucketType;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
@ -67,6 +70,19 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
return $scenario;
|
return $scenario;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function persistBucket(Scenario $scenario, string $name, int $priority): Bucket
|
||||||
|
{
|
||||||
|
$bucket = new Bucket();
|
||||||
|
$bucket->setScenario($scenario);
|
||||||
|
$bucket->setName($name);
|
||||||
|
$bucket->setPriority($priority);
|
||||||
|
|
||||||
|
$this->em->persist($bucket);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $bucket;
|
||||||
|
}
|
||||||
|
|
||||||
public function testGetCollectionIsRejectedWhenUnauthenticated(): void
|
public function testGetCollectionIsRejectedWhenUnauthenticated(): void
|
||||||
{
|
{
|
||||||
$this->client->request('GET', '/api/scenarios', server: [
|
$this->client->request('GET', '/api/scenarios', server: [
|
||||||
|
|
@ -174,6 +190,258 @@ final class ScenarioApiTest extends WebTestCase
|
||||||
self::assertSame('Emergency Fund', $body['name'] ?? null);
|
self::assertSame('Emergency Fund', $body['name'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testGetItemEmbedsItsBuckets(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->persistScenario('Household Budget');
|
||||||
|
$this->persistBucket($scenario, 'Rent', 1);
|
||||||
|
$this->persistBucket($scenario, 'Groceries', 2);
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
200,
|
||||||
|
$response->getStatusCode(),
|
||||||
|
'GET /api/scenarios/{id} must return 200 for an authenticated owner.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
|
||||||
|
self::assertArrayHasKey(
|
||||||
|
'buckets',
|
||||||
|
$body,
|
||||||
|
'The Scenario item representation must embed its buckets so the SPA can fetch one resource.',
|
||||||
|
);
|
||||||
|
self::assertIsArray($body['buckets'], 'The embedded buckets field must be an array.');
|
||||||
|
|
||||||
|
$names = array_column($body['buckets'], 'name');
|
||||||
|
|
||||||
|
self::assertContains('Rent', $names);
|
||||||
|
self::assertContains('Groceries', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmbeddedBucketsExposeTheFieldsTheFrontendNeeds(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->persistScenario('Household Budget');
|
||||||
|
$bucket = $this->persistBucket($scenario, 'Rent', 1);
|
||||||
|
$bucket->setSortOrder(3);
|
||||||
|
$bucket->setType(BucketType::NEED);
|
||||||
|
$bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT);
|
||||||
|
$bucket->setAllocationValue(150000);
|
||||||
|
$bucket->setStartingAmount(5000);
|
||||||
|
$bucket->setBufferMultiplier('1.50');
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
|
||||||
|
self::assertIsArray($body['buckets'] ?? null);
|
||||||
|
|
||||||
|
$embedded = $body['buckets'][0] ?? null;
|
||||||
|
|
||||||
|
self::assertIsArray($embedded, 'The embedded bucket must be an object.');
|
||||||
|
self::assertArrayHasKey(
|
||||||
|
'@id',
|
||||||
|
$embedded,
|
||||||
|
'Each embedded bucket must expose its own IRI so the SPA can target it for inline PATCH/DELETE from the nested view.',
|
||||||
|
);
|
||||||
|
self::assertSame('/api/buckets/'.$bucket->getId(), $embedded['@id'] ?? null);
|
||||||
|
self::assertSame('Rent', $embedded['name'] ?? null);
|
||||||
|
self::assertSame(BucketType::NEED->value, $embedded['type'] ?? null);
|
||||||
|
self::assertSame(1, $embedded['priority'] ?? null);
|
||||||
|
self::assertSame(3, $embedded['sortOrder'] ?? null);
|
||||||
|
self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $embedded['allocationType'] ?? null);
|
||||||
|
self::assertSame(
|
||||||
|
150000,
|
||||||
|
$embedded['allocationValue'] ?? null,
|
||||||
|
'allocationValue must serialize as an int literal, not a float.',
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
5000,
|
||||||
|
$embedded['startingAmount'] ?? null,
|
||||||
|
'startingAmount must serialize as an int literal, not a float.',
|
||||||
|
);
|
||||||
|
self::assertSame('1.50', $embedded['bufferMultiplier'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmbeddedBucketsAreOrderedBySortOrder(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->persistScenario('Household Budget');
|
||||||
|
|
||||||
|
$last = $this->persistBucket($scenario, 'Last', 1);
|
||||||
|
$last->setSortOrder(2);
|
||||||
|
|
||||||
|
$first = $this->persistBucket($scenario, 'First', 2);
|
||||||
|
$first->setSortOrder(0);
|
||||||
|
|
||||||
|
$middle = $this->persistBucket($scenario, 'Middle', 3);
|
||||||
|
$middle->setSortOrder(1);
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
|
||||||
|
|
||||||
|
$names = array_column($body['buckets'] ?? [], 'name');
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['First', 'Middle', 'Last'],
|
||||||
|
$names,
|
||||||
|
'Embedded buckets must be ordered by sortOrder ascending, regardless of persistence order.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testScenarioWithNoBucketsEmbedsAnEmptyBucketsArray(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->persistScenario('Empty Scenario');
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
200,
|
||||||
|
$response->getStatusCode(),
|
||||||
|
'A scenario with no buckets must still return 200, not error.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
|
||||||
|
self::assertArrayHasKey('buckets', $body);
|
||||||
|
self::assertSame(
|
||||||
|
[],
|
||||||
|
$body['buckets'],
|
||||||
|
'A bucket-less scenario must embed an empty buckets array, not null or a missing key.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetCollectionDoesNotEmbedBuckets(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->persistScenario('Household Budget');
|
||||||
|
$this->persistBucket($scenario, 'Rent', 1);
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/scenarios', server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.');
|
||||||
|
self::assertArrayHasKey('member', $body);
|
||||||
|
|
||||||
|
$members = array_column($body['member'], null, 'name');
|
||||||
|
|
||||||
|
self::assertArrayHasKey('Household Budget', $members);
|
||||||
|
self::assertArrayNotHasKey(
|
||||||
|
'buckets',
|
||||||
|
$members['Household Budget'],
|
||||||
|
'GET /api/scenarios collection members must NOT embed buckets — only the item op does.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CHARACTERIZATION TEST — pins the CURRENT item representation before
|
||||||
|
* serialization groups land (#64). Confirms name/description/@id/@var
|
||||||
|
* survive the group-flip. Owner-presence is intentionally NOT asserted
|
||||||
|
* here — it is dropped as part of #64, so pinning it now would make this
|
||||||
|
* test fail for the wrong reason once groups land. Born green.
|
||||||
|
*/
|
||||||
|
public function testGetItemExposesScenarioFieldsUnchanged(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->persistScenario('Emergency Fund');
|
||||||
|
$scenario->setDescription('Six months of essential expenses.');
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
200,
|
||||||
|
$response->getStatusCode(),
|
||||||
|
'GET /api/scenarios/{id} must return 200 for an authenticated owner.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
|
||||||
|
self::assertArrayHasKey('@id', $body, 'A JSON-LD item must expose its IRI under @id.');
|
||||||
|
self::assertSame('Scenario', $body['@type'] ?? null, 'The item @type must be Scenario.');
|
||||||
|
self::assertSame('Emergency Fund', $body['name'] ?? null);
|
||||||
|
self::assertSame(
|
||||||
|
'Six months of essential expenses.',
|
||||||
|
$body['description'] ?? null,
|
||||||
|
'The item representation must still expose description.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetItemDoesNotExposeOwner(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->persistScenario('Emergency Fund');
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
|
||||||
|
self::assertArrayNotHasKey(
|
||||||
|
'owner',
|
||||||
|
$body,
|
||||||
|
'The Scenario item representation must not leak the owner relation — it is implicit from the '
|
||||||
|
.'authenticated session, not part of the public shape (#64).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function testPostCreatesAScenarioWhenAuthenticated(): void
|
public function testPostCreatesAScenarioWhenAuthenticated(): void
|
||||||
{
|
{
|
||||||
$this->login();
|
$this->login();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Tests\Functional;
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use App\Entity\Bucket;
|
||||||
use App\Entity\Scenario;
|
use App\Entity\Scenario;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
@ -63,6 +64,31 @@ final class ScenarioOwnerApiTest extends WebTestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function persistScenario(User $owner, string $name): Scenario
|
||||||
|
{
|
||||||
|
$scenario = new Scenario();
|
||||||
|
$scenario->setName($name);
|
||||||
|
$scenario->setOwner($owner);
|
||||||
|
|
||||||
|
$this->em->persist($scenario);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistBucket(Scenario $scenario, string $name, int $priority): Bucket
|
||||||
|
{
|
||||||
|
$bucket = new Bucket();
|
||||||
|
$bucket->setScenario($scenario);
|
||||||
|
$bucket->setName($name);
|
||||||
|
$bucket->setPriority($priority);
|
||||||
|
|
||||||
|
$this->em->persist($bucket);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $bucket;
|
||||||
|
}
|
||||||
|
|
||||||
public function testPostCreatesAScenarioOwnedByTheAuthenticatedUser(): void
|
public function testPostCreatesAScenarioOwnedByTheAuthenticatedUser(): void
|
||||||
{
|
{
|
||||||
$this->login();
|
$this->login();
|
||||||
|
|
@ -153,6 +179,84 @@ final class ScenarioOwnerApiTest extends WebTestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testGetItemOnAScenarioOwnedBySomeoneElseReturnsNotFoundWithNoBucketLeak(): void
|
||||||
|
{
|
||||||
|
$othersScenario = $this->persistScenario($this->otherUser, 'Someone Else\'s Fund');
|
||||||
|
$this->persistBucket($othersScenario, 'Their Rent', 1);
|
||||||
|
$this->persistBucket($othersScenario, 'Their Groceries', 2);
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/scenarios/'.$othersScenario->getId(), server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
404,
|
||||||
|
$response->getStatusCode(),
|
||||||
|
'GET /api/scenarios/{id} for a scenario owned by another user must return 404, even when it has buckets.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$content = (string) $response->getContent();
|
||||||
|
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
'Their Rent',
|
||||||
|
$content,
|
||||||
|
'A 404 for a foreign scenario must not leak any of its embedded bucket data in the response body.',
|
||||||
|
);
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
'Their Groceries',
|
||||||
|
$content,
|
||||||
|
'A 404 for a foreign scenario must not leak any of its embedded bucket data in the response body.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmbeddedBucketsContainOnlyThisScenariosBuckets(): void
|
||||||
|
{
|
||||||
|
$scenarioA = $this->persistScenario($this->caller, 'Scenario A');
|
||||||
|
$this->persistBucket($scenarioA, 'A Rent', 1);
|
||||||
|
$this->persistBucket($scenarioA, 'A Groceries', 2);
|
||||||
|
|
||||||
|
$scenarioB = $this->persistScenario($this->caller, 'Scenario B');
|
||||||
|
$this->persistBucket($scenarioB, 'B Rent', 1);
|
||||||
|
$this->persistBucket($scenarioB, 'B Groceries', 2);
|
||||||
|
|
||||||
|
$this->login();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/scenarios/'.$scenarioA->getId(), server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/ld+json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$body = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
|
||||||
|
self::assertIsArray($body['buckets'] ?? null);
|
||||||
|
|
||||||
|
$names = array_column($body['buckets'], 'name');
|
||||||
|
|
||||||
|
self::assertContains('A Rent', $names, 'Scenario A\'s item must embed its own buckets.');
|
||||||
|
self::assertContains('A Groceries', $names, 'Scenario A\'s item must embed its own buckets.');
|
||||||
|
|
||||||
|
self::assertNotContains(
|
||||||
|
'B Rent',
|
||||||
|
$names,
|
||||||
|
'Scenario A\'s embedded buckets must never include another scenario\'s buckets, even when both '
|
||||||
|
.'scenarios are owned by the same caller.',
|
||||||
|
);
|
||||||
|
self::assertNotContains(
|
||||||
|
'B Groceries',
|
||||||
|
$names,
|
||||||
|
'Scenario A\'s embedded buckets must never include another scenario\'s buckets, even when both '
|
||||||
|
.'scenarios are owned by the same caller.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function testGetCollectionOnlyReturnsScenariosOwnedByTheCaller(): void
|
public function testGetCollectionOnlyReturnsScenariosOwnedByTheCaller(): void
|
||||||
{
|
{
|
||||||
$mine = new Scenario();
|
$mine = new Scenario();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue