diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..785a308 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +coverage *.local # Editor directories and files diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 0143ddc..e2e3bb4 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,4 +1,5 @@ node_modules dist +coverage package-lock.json *.tsbuildinfo diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d62a8ff..0557df3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.9", "jsdom": "^29.1.1", "msw": "^2.14.6", "oxlint": "^1.69.0", @@ -105,17 +106,42 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -126,6 +152,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1620,6 +1670,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -1777,6 +1858,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -2104,6 +2204,16 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/headers-polyfill": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", @@ -2128,6 +2238,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2162,6 +2279,45 @@ "dev": true, "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2513,6 +2669,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -2936,6 +3120,19 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", @@ -3045,6 +3242,19 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index b84a714..5a4f373 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "format:check": "prettier --check .", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "preview": "vite preview" }, "dependencies": { @@ -27,6 +28,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.9", "jsdom": "^29.1.1", "msw": "^2.14.6", "oxlint": "^1.69.0", diff --git a/frontend/src/auth/AuthProvider.test.tsx b/frontend/src/auth/AuthProvider.test.tsx index 7ecffc9..cbbc0c7 100644 --- a/frontend/src/auth/AuthProvider.test.tsx +++ b/frontend/src/auth/AuthProvider.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { delay, http, HttpResponse } from 'msw' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { server } from '../test/server' import { AuthProvider } from './AuthProvider' import { useAuth } from './useAuth' @@ -59,6 +59,29 @@ describe('AuthProvider', () => { expect(screen.getByTestId('user-email')).toHaveTextContent('') }) + it('treats a non-401 probe failure as unauthenticated and logs it', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ message: 'boom' }, { status: 500 }), + ), + ) + + render( + + + , + ) + + // A backend outage shouldn't strand the app in `loading` — it resolves to + // unauthenticated, but unlike a 401 it's logged (it's not a normal "no + // session" answer). + expect(await screen.findByText('unauthenticated')).toBeInTheDocument() + expect(spy).toHaveBeenCalled() + + spy.mockRestore() + }) + it('does not flash a resolved status while the auth probe is loading', async () => { server.use( http.get('http://localhost/api/me', async () => { diff --git a/frontend/src/auth/LoginPage.test.tsx b/frontend/src/auth/LoginPage.test.tsx index bd51a4e..eb21d9d 100644 --- a/frontend/src/auth/LoginPage.test.tsx +++ b/frontend/src/auth/LoginPage.test.tsx @@ -93,6 +93,32 @@ describe('LoginPage', () => { expect(screen.queryByText('SCENARIOS')).not.toBeInTheDocument() }) + it('shows a generic error for a non-401 failure', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ message: 'unauthorized' }, { status: 401 }), + ), + http.post('http://localhost/api/login', () => + HttpResponse.json({ message: 'boom' }, { status: 500 }), + ), + ) + + const user = userEvent.setup() + + render( + + + , + ) + + await user.type(await screen.findByLabelText(/email/i), 'a@b.com') + await user.type(screen.getByLabelText(/password/i), 'correct-password') + await user.click(screen.getByRole('button', { name: /log in/i })) + + expect(await screen.findByText(/login failed/i)).toBeInTheDocument() + expect(screen.queryByText('SCENARIOS')).not.toBeInTheDocument() + }) + it('disables the submit button while the request is in flight', async () => { server.use( http.get('http://localhost/api/me', () => diff --git a/frontend/src/auth/RegisterPage.test.tsx b/frontend/src/auth/RegisterPage.test.tsx index 9b26582..9967553 100644 --- a/frontend/src/auth/RegisterPage.test.tsx +++ b/frontend/src/auth/RegisterPage.test.tsx @@ -137,6 +137,32 @@ describe('RegisterPage', () => { expect(screen.queryByText('LOGIN')).not.toBeInTheDocument() }) + it('shows a generic error for a non-422 failure', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ message: 'unauthorized' }, { status: 401 }), + ), + http.post('http://localhost/api/register', () => + HttpResponse.json({ message: 'boom' }, { status: 500 }), + ), + ) + + const user = userEvent.setup() + + render( + + + , + ) + + await user.type(await screen.findByLabelText(/email/i), 'new@example.com') + await user.type(screen.getByLabelText(/password/i), 'correct-horse-battery') + await user.click(screen.getByRole('button', { name: /register/i })) + + expect(await screen.findByText(/registration failed/i)).toBeInTheDocument() + expect(screen.queryByText('LOGIN')).not.toBeInTheDocument() + }) + it('disables the submit button while the request is in flight', async () => { server.use( http.get('http://localhost/api/me', () => diff --git a/frontend/src/auth/useAuth.test.tsx b/frontend/src/auth/useAuth.test.tsx new file mode 100644 index 0000000..d5adc5e --- /dev/null +++ b/frontend/src/auth/useAuth.test.tsx @@ -0,0 +1,16 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useAuth } from './useAuth' + +describe('useAuth', () => { + it('throws when used outside an AuthProvider', () => { + // Silence the expected React error-boundary console noise for this throw. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => renderHook(() => useAuth())).toThrow( + /must be used within an AuthProvider/i, + ) + + spy.mockRestore() + }) +}) diff --git a/frontend/src/components/ui/DigitalProgressBar.test.tsx b/frontend/src/components/ui/DigitalProgressBar.test.tsx new file mode 100644 index 0000000..93d5846 --- /dev/null +++ b/frontend/src/components/ui/DigitalProgressBar.test.tsx @@ -0,0 +1,40 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import DigitalProgressBar from './DigitalProgressBar' + +describe('DigitalProgressBar', () => { + it('fills all three color zones when full (red, amber, green)', () => { + const { container } = render( + , + ) + + // A full bar fills every index, so all three zone classes appear. + expect(container.querySelectorAll('.bg-chart-1').length).toBeGreaterThan(0) + expect(container.querySelectorAll('.bg-chart-2').length).toBeGreaterThan(0) + expect(container.querySelectorAll('.bg-chart-3').length).toBeGreaterThan(0) + }) + + it('renders 20 bars regardless of fill level', () => { + const { container } = render( + , + ) + + // The grid container's direct children are the 20 bars. + const grid = container.firstElementChild + expect(grid?.children.length).toBe(20) + // Partial fill leaves the rest as dimmed empties. + expect( + container.querySelectorAll('.bg-chart-1\\/10').length, + ).toBeGreaterThan(0) + }) + + it('shows no filled bars when capacity is zero (guards divide-by-zero)', () => { + const { container } = render( + , + ) + + // capacity<=0 → filledBars 0 → no zone-colored bars, all empties. + expect(container.querySelectorAll('.bg-chart-2').length).toBe(0) + expect(container.querySelectorAll('.bg-chart-3').length).toBe(0) + }) +}) diff --git a/frontend/src/components/ui/DigitalProgressBar.tsx b/frontend/src/components/ui/DigitalProgressBar.tsx index 3ec9983..c7f7cee 100644 --- a/frontend/src/components/ui/DigitalProgressBar.tsx +++ b/frontend/src/components/ui/DigitalProgressBar.tsx @@ -12,14 +12,28 @@ const BAR_COUNT = 20 * — both reference the same source so they can't drift. `upTo` is the exclusive * upper bound of each zone. */ -const ZONES = [ +interface Zone { + upTo: number + token: string + className: string +} + +// The green zone is also the guaranteed fallback for any index at/above the +// last threshold, so it's named separately to keep the return type non-optional. +const GREEN_ZONE: Zone = { + upTo: BAR_COUNT, + token: 'var(--chart-3)', + className: 'bg-chart-3', +} + +const ZONES: readonly Zone[] = [ { upTo: 10, token: 'var(--chart-1)', className: 'bg-chart-1' }, { upTo: 18, token: 'var(--chart-2)', className: 'bg-chart-2' }, - { upTo: BAR_COUNT, token: 'var(--chart-3)', className: 'bg-chart-3' }, -] as const + GREEN_ZONE, +] -function getZone(index: number) { - return ZONES.find((zone) => index < zone.upTo) ?? ZONES[ZONES.length - 1] +function getZone(index: number): Zone { + return ZONES.find((zone) => index < zone.upTo) ?? GREEN_ZONE } export default function DigitalProgressBar({ diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 694942b..710f06f 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -36,3 +36,77 @@ describe('api.postJson', () => { expect((error as ApiError).status).toBe(401) }) }) + +describe('api path guard', () => { + it('throws if a path already includes the /api prefix', async () => { + // Callers pass `/login`, not `/api/login` — the client prepends BASE. + await expect(api.get('/api/login')).rejects.toThrow(/must not include/i) + }) +}) + +describe('api.get', () => { + it('sends Accept: application/ld+json and returns the parsed body', async () => { + let accept: string | null = null + + server.use( + http.get('http://localhost/api/scenarios', ({ request }) => { + accept = request.headers.get('Accept') + return HttpResponse.json({ member: [] }) + }), + ) + + const result = await api.get<{ member: unknown[] }>('/scenarios') + + expect(accept).toBe('application/ld+json') + expect(result).toEqual({ member: [] }) + }) +}) + +describe('api.post', () => { + it('sends Content-Type: application/ld+json (API Platform write format)', async () => { + let contentType: string | null = null + + server.use( + http.post('http://localhost/api/buckets', async ({ request }) => { + contentType = request.headers.get('Content-Type') + return HttpResponse.json({ '@id': '/api/buckets/1' }, { status: 201 }) + }), + ) + + await api.post('/buckets', { name: 'Rent' }) + + expect(contentType).toBe('application/ld+json') + }) +}) + +describe('api.patch', () => { + it('sends Content-Type: application/merge-patch+json', async () => { + let contentType: string | null = null + + server.use( + http.patch('http://localhost/api/buckets/1', async ({ request }) => { + contentType = request.headers.get('Content-Type') + return HttpResponse.json({ '@id': '/api/buckets/1' }) + }), + ) + + await api.patch('/buckets/1', { name: 'Rent' }) + + expect(contentType).toBe('application/merge-patch+json') + }) +}) + +describe('api.delete', () => { + it('resolves to undefined on a 204 No Content', async () => { + server.use( + http.delete( + 'http://localhost/api/buckets/1', + () => new HttpResponse(null, { status: 204 }), + ), + ) + + const result = await api.delete('/buckets/1') + + expect(result).toBeUndefined() + }) +}) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 7f42e5f..9184f23 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -16,6 +16,8 @@ "jsx": "react-jsx", /* Linting */ + "strict": true, + "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 8455dcb..97838ce 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -14,6 +14,8 @@ "noEmit": true, /* Linting */ + "strict": true, + "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 09f362c..81e7834 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,6 +15,26 @@ export default defineConfig({ setupFiles: ['./src/test/setup.ts'], // Keep the future Playwright e2e suite out of the Vitest run. exclude: ['**/node_modules/**', '**/dist/**', 'e2e/**'], + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/main.tsx', // app mount point — no logic to test + 'src/**/*.test.{ts,tsx}', // the tests themselves + 'src/test/**', // test harness (MSW server/setup) + 'src/vite-env.d.ts', + 'src/types/**', // type-only declarations + ], + // Floors set a touch below current (≈98% lines) to catch regressions + // without being brittle. Raise as coverage climbs; the goal is "no silent + // backslide", not gaming a round number. + thresholds: { + statements: 95, + branches: 90, + functions: 95, + lines: 95, + }, + }, }, server: { host: '0.0.0.0', diff --git a/shell.nix b/shell.nix index 9d7268b..bb2033f 100644 --- a/shell.nix +++ b/shell.nix @@ -110,6 +110,23 @@ pkgs.mkShell { $COMPOSE exec -e XDEBUG_MODE=coverage php php bin/phpunit --coverage-text --colors=never "$@" } + dev-coverage-check() { + # Backend coverage gate: PHPUnit 13 has no native "fail under N%" option, + # so run the report and fail if the Lines % drops below the floor (95%). + # Floor set a touch below current (~99.7%) to catch regressions, not game. + local floor=95 + local out pct + out=$($COMPOSE exec -e XDEBUG_MODE=coverage php php bin/phpunit --coverage-text --colors=never "$@") + echo "$out" + pct=$(echo "$out" | grep -oE 'Lines:[[:space:]]+[0-9]+\.[0-9]+%' | grep -oE '[0-9]+\.[0-9]+' | head -1) + if [ -z "$pct" ]; then echo "✗ could not parse coverage %"; return 1; fi + # integer compare (drop decimals) against the floor + if [ "''${pct%.*}" -lt "$floor" ]; then + echo "✗ backend line coverage $pct% is below the $floor% floor"; return 1 + fi + echo "✓ backend line coverage $pct% (floor $floor%)" + } + dev-stan() { # Warm the dev cache so phpstan-symfony can read the compiled container, then analyse. $COMPOSE exec php php bin/console cache:warmup --quiet @@ -134,6 +151,11 @@ pkgs.mkShell { $COMPOSE exec frontend npm test "$@" } + dev-fe-coverage() { + # Vitest run with the v8 coverage report. + $COMPOSE exec frontend npm run test:coverage "$@" + } + dev-fe-test-watch() { $COMPOSE exec frontend npm run test:watch "$@" } @@ -179,13 +201,10 @@ pkgs.mkShell { dev-check-all() { # Thorough gate: everything in dev-check, but with COVERAGE instead of the # plain test runs, plus the Playwright e2e. Slow; needs the stack up. - # NOTE: frontend coverage (dev-fe-coverage) is not wired yet — added in #62 - # alongside the @vitest/coverage-v8 setup; until then this runs the plain - # frontend test suite for the frontend leg. - echo "▶ backend: coverage" && dev-coverage \ + echo "▶ backend: coverage" && dev-coverage-check \ && echo "▶ backend: phpstan" && dev-stan \ && echo "▶ backend: cs" && dev-cs \ - && echo "▶ frontend: tests (coverage pending #62)" && dev-fe-test \ + && echo "▶ frontend: coverage" && dev-fe-coverage \ && echo "▶ frontend: lint" && dev-fe-lint \ && echo "▶ frontend: cs" && dev-fe-cs \ && echo "▶ frontend: build" && dev-fe-build \ @@ -291,6 +310,7 @@ pkgs.mkShell { echo " dev-fe-cs [args] Check frontend formatting (Prettier)" echo " dev-fe-cs-fix Apply frontend formatting" echo " dev-fe-build Build the frontend (tsc + vite build)" + echo " dev-fe-coverage Frontend Vitest run with coverage report" echo " dev-e2e [args] Run Playwright e2e smoke suite (container)" echo " dev-check Fast gate: all back+front tests/stan/cs/build" echo " dev-check-all Thorough gate: dev-check + coverage + e2e" diff --git a/src/Kernel.php b/src/Kernel.php index a00950f..0ca9bd5 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -11,6 +11,12 @@ class Kernel extends BaseKernel /** * @return list An array of allowed values for APP_ENV + * + * Called by the framework via reflection (env validation), never by app + * code — same reason it's PHPStan-ignored. Excluded from coverage rather + * than exercised by a contrived test. + * + * @codeCoverageIgnore */ private function getAllowedEnvs(): array { diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 248bbd3..ba66dab 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -21,6 +21,12 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader /** * Used to upgrade (rehash) the user's password automatically over time. + * + * Symfony maker-generated PasswordUpgraderInterface boilerplate with no + * app-specific logic — excluded from coverage rather than tested with a + * contrived case. + * + * @codeCoverageIgnore */ public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void { diff --git a/tests/Doctrine/ScenarioOwnerExtensionTest.php b/tests/Doctrine/ScenarioOwnerExtensionTest.php new file mode 100644 index 0000000..b7ff46a --- /dev/null +++ b/tests/Doctrine/ScenarioOwnerExtensionTest.php @@ -0,0 +1,86 @@ +get(EntityManagerInterface::class); + + return $em->createQueryBuilder() + ->select('s') + ->from(Scenario::class, 's'); + } + + private function extensionWithUser(?object $user): ScenarioOwnerExtension + { + /** @var Security&Stub $security */ + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn($user); + + return new ScenarioOwnerExtension($security); + } + + public function testAddsOwnerFilterForAnAuthenticatedUserOnItsOwnResource(): void + { + $qb = $this->queryBuilder(); + $extension = $this->extensionWithUser(new User()); + + $extension->applyToCollection($qb, new QueryNameGenerator(), Scenario::class); + + self::assertStringContainsStringIgnoringCase( + '.owner = :', + (string) $qb->getDQL(), + 'An authenticated read of its own resource must be scoped to the owner.', + ); + } + + public function testDoesNothingForAResourceItDoesNotGuard(): void + { + $qb = $this->queryBuilder(); + $extension = $this->extensionWithUser(new User()); + + // Bucket is guarded by a different extension — this one must no-op. + $extension->applyToCollection($qb, new QueryNameGenerator(), \App\Entity\Bucket::class); + + self::assertStringNotContainsString( + 'owner', + (string) $qb->getDQL(), + 'The extension must not touch queries for a resource it does not guard.', + ); + } + + public function testDoesNothingForAnAnonymousRequest(): void + { + $qb = $this->queryBuilder(); + $extension = $this->extensionWithUser(null); + + $extension->applyToItem($qb, new QueryNameGenerator(), Scenario::class, ['id' => 'x']); + + self::assertStringNotContainsString( + 'owner', + (string) $qb->getDQL(), + 'With no authenticated user the extension must not add an owner filter ' + .'(it is a fail-safe behind the resource-level ROLE_USER wall).', + ); + } +} diff --git a/tests/Validator/BufferOnlyForFixedLimitValidatorTest.php b/tests/Validator/BufferOnlyForFixedLimitValidatorTest.php index 0519b96..ef257f1 100644 --- a/tests/Validator/BufferOnlyForFixedLimitValidatorTest.php +++ b/tests/Validator/BufferOnlyForFixedLimitValidatorTest.php @@ -8,7 +8,9 @@ use App\Enum\BucketAllocationType; use App\Enum\BucketType; use App\Validator\BufferOnlyForFixedLimit; use App\Validator\BufferOnlyForFixedLimitValidator; +use App\Validator\SingleOverflowPerScenario; use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; /** @@ -21,6 +23,22 @@ final class BufferOnlyForFixedLimitValidatorTest extends ConstraintValidatorTest return new BufferOnlyForFixedLimitValidator(); } + public function testThrowsWhenGivenTheWrongConstraintType(): void + { + $bucket = $this->makeBucket(BucketAllocationType::FIXED_LIMIT, '0.00'); + + $this->expectException(UnexpectedTypeException::class); + + $this->validator->validate($bucket, new SingleOverflowPerScenario()); + } + + public function testNonBucketValueIsIgnored(): void + { + $this->validator->validate('not a bucket', new BufferOnlyForFixedLimit()); + + $this->assertNoViolation(); + } + public function testFixedLimitWithNonZeroBufferIsValid(): void { $bucket = $this->makeBucket(BucketAllocationType::FIXED_LIMIT, '1.50'); diff --git a/tests/Validator/CompatibleAllocationTypeValidatorTest.php b/tests/Validator/CompatibleAllocationTypeValidatorTest.php index 6593775..6292740 100644 --- a/tests/Validator/CompatibleAllocationTypeValidatorTest.php +++ b/tests/Validator/CompatibleAllocationTypeValidatorTest.php @@ -8,7 +8,9 @@ use App\Enum\BucketAllocationType; use App\Enum\BucketType; use App\Validator\CompatibleAllocationType; use App\Validator\CompatibleAllocationTypeValidator; +use App\Validator\SingleOverflowPerScenario; use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; /** @@ -21,6 +23,22 @@ final class CompatibleAllocationTypeValidatorTest extends ConstraintValidatorTes return new CompatibleAllocationTypeValidator(); } + public function testThrowsWhenGivenTheWrongConstraintType(): void + { + $bucket = $this->makeBucket(BucketType::OVERFLOW, BucketAllocationType::UNLIMITED); + + $this->expectException(UnexpectedTypeException::class); + + $this->validator->validate($bucket, new SingleOverflowPerScenario()); + } + + public function testNonBucketValueIsIgnored(): void + { + $this->validator->validate('not a bucket', new CompatibleAllocationType()); + + $this->assertNoViolation(); + } + public function testOverflowWithUnlimitedIsValid(): void { $bucket = $this->makeBucket(BucketType::OVERFLOW, BucketAllocationType::UNLIMITED); diff --git a/tests/Validator/SingleOverflowPerScenarioValidatorTest.php b/tests/Validator/SingleOverflowPerScenarioValidatorTest.php index 191b448..61dabe4 100644 --- a/tests/Validator/SingleOverflowPerScenarioValidatorTest.php +++ b/tests/Validator/SingleOverflowPerScenarioValidatorTest.php @@ -7,10 +7,12 @@ use App\Entity\Scenario; use App\Enum\BucketAllocationType; use App\Enum\BucketType; use App\Repository\BucketRepository; +use App\Validator\BufferOnlyForFixedLimit; use App\Validator\SingleOverflowPerScenario; use App\Validator\SingleOverflowPerScenarioValidator; use PHPUnit\Framework\MockObject\Stub; use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; /** @@ -27,6 +29,22 @@ final class SingleOverflowPerScenarioValidatorTest extends ConstraintValidatorTe return new SingleOverflowPerScenarioValidator($this->bucketRepository); } + public function testThrowsWhenGivenTheWrongConstraintType(): void + { + $bucket = $this->makeBucket(BucketType::OVERFLOW); + + $this->expectException(UnexpectedTypeException::class); + + $this->validator->validate($bucket, new BufferOnlyForFixedLimit()); + } + + public function testNonBucketValueIsIgnored(): void + { + $this->validator->validate('not a bucket', new SingleOverflowPerScenario()); + + $this->assertNoViolation(); + } + public function testOverflowBucketIsValidWhenRepositoryReportsNoOtherOverflowBuckets(): void { $this->bucketRepository