/** * Contract test: hits the real, running API (not a mock) and validates the * response against the same shape `src/types/index.ts` promises the rest of * the frontend. This is the third kind of test in this suite, distinct from * the other two: * * - unit tests (Vitest + Testing Library, `src/**\/*.test.tsx`) check * frontend logic in isolation with a fake backend; * - the e2e test (Playwright, `e2e/`) checks the full user-facing flow * through a real browser against the real stack; * - this one checks that the *data shape* the two sides agree on has not * silently drifted - a Django field rename, a nullability change, or a * dropped facet breaks this test even though it would type-check fine on * both sides in isolation (the frontend's `SearchResponse` interface is * just an assertion about what the backend sends, never verified against * the backend itself anywhere else in this codebase). * * Requires the docker compose stack to be running (`make up`); not part of * `npm test`, run separately via `npm run test:contract`. */ import { describe, expect, it } from 'vitest'; import { ApiErrorSchema, FACET_NAMES, SearchResponseSchema, TokenPairSchema } from './schemas'; import { API_BASE_URL, DEMO_ANALYST } from './support/env'; async function login(): Promise { const response = await fetch(`${API_BASE_URL}/auth/login/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(DEMO_ANALYST), }); const body = await response.json(); const parsed = TokenPairSchema.parse(body); return parsed.access; } describe('search response contract', () => { it('matches the SearchResponse shape the frontend types promise', async () => { const token = await login(); const response = await fetch(`${API_BASE_URL}/search/?roles=engineering&page_size=5`, { headers: { Authorization: `Bearer ${token}` }, }); expect(response.status).toBe(200); const body = await response.json(); const result = SearchResponseSchema.safeParse(body); if (!result.success) { throw new Error(`API response no longer matches SearchResponse:\n${result.error.message}`); } }); it('always returns every facet the frontend renders a filter group for', async () => { // FilterPanel.tsx hard-codes these six facet names; a backend response // missing one would leave that whole filter group silently empty. const token = await login(); const response = await fetch(`${API_BASE_URL}/search/?page_size=1`, { headers: { Authorization: `Bearer ${token}` }, }); const body = SearchResponseSchema.parse(await response.json()); for (const facet of FACET_NAMES) { expect(body.facets).toHaveProperty(facet); expect(Array.isArray(body.facets[facet])).toBe(true); } }); it('returns real results for the seeded demo dataset', async () => { const token = await login(); const response = await fetch(`${API_BASE_URL}/search/?page_size=5`, { headers: { Authorization: `Bearer ${token}` }, }); const body = SearchResponseSchema.parse(await response.json()); expect(body.count).toBeGreaterThan(0); expect(body.results.length).toBeGreaterThan(0); }); it('an unauthorized request matches the ApiError envelope the frontend error handler expects', async () => { // api/client.ts's `toError()` reaches into `error.response.data.error.*` // unconditionally - if the backend ever changed this envelope, every // error message in the UI would silently become empty. const response = await fetch(`${API_BASE_URL}/search/`); expect(response.status).toBe(401); const body = await response.json(); const result = ApiErrorSchema.safeParse(body); if (!result.success) { throw new Error(`401 response no longer matches ApiError:\n${result.error.message}`); } }); });