93 lines
3.8 KiB
TypeScript
93 lines
3.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string> {
|
||
|
|
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}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|