start project
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Runtime mirrors of the TypeScript interfaces in `src/types/index.ts`.
|
||||
*
|
||||
* A TS interface is a compile-time-only promise: nothing stops the Django
|
||||
* side from renaming a field, changing its nullability, or dropping a facet,
|
||||
* and the frontend would only find out at runtime, in production, as a blank
|
||||
* screen or a silently-wrong render. These schemas are validated against the
|
||||
* real API response in `search-response.contract.test.ts`, so that class of
|
||||
* drift fails a test instead.
|
||||
*/
|
||||
|
||||
export const FacetBucketSchema = z.object({
|
||||
key: z.string(),
|
||||
count: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const FACET_NAMES = [
|
||||
'skills',
|
||||
'job_title_role',
|
||||
'seniority',
|
||||
'country',
|
||||
'industry',
|
||||
'company',
|
||||
] as const;
|
||||
|
||||
export const SearchHitSchema = z.object({
|
||||
id: z.number(),
|
||||
score: z.number(),
|
||||
full_name: z.string(),
|
||||
headline: z.string(),
|
||||
job_title: z.string(),
|
||||
job_title_role: z.string(),
|
||||
seniority_levels: z.array(z.string()),
|
||||
company: z.string(),
|
||||
industry: z.string(),
|
||||
location_name: z.string(),
|
||||
location_country: z.string(),
|
||||
years_experience: z.number().nullable(),
|
||||
linkedin_url: z.string(),
|
||||
skills: z.array(z.string()),
|
||||
highlight: z.record(z.string(), z.array(z.string())),
|
||||
});
|
||||
|
||||
export const SearchResponseSchema = z.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
page: z.number().int().positive(),
|
||||
page_size: z.number().int().positive(),
|
||||
total_pages: z.number().int().nonnegative(),
|
||||
took_ms: z.number().int().nonnegative(),
|
||||
backend: z.string(),
|
||||
results: z.array(SearchHitSchema),
|
||||
facets: z.record(z.enum(FACET_NAMES), z.array(FacetBucketSchema)),
|
||||
});
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.number(),
|
||||
email: z.string(),
|
||||
full_name: z.string(),
|
||||
role: z.enum(['admin', 'analyst', 'viewer']),
|
||||
can_view_pii: z.boolean(),
|
||||
date_joined: z.string(),
|
||||
});
|
||||
|
||||
export const TokenPairSchema = z.object({
|
||||
access: z.string(),
|
||||
refresh: z.string(),
|
||||
user: UserSchema,
|
||||
});
|
||||
|
||||
export const ApiErrorSchema = z.object({
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.unknown().optional(),
|
||||
request_id: z.string(),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Same idea as e2e/support/env.ts - read the seeded demo credentials from `.env` rather than hard-coding them. */
|
||||
function readDotEnv(): Record<string, string> {
|
||||
const path = fileURLToPath(new URL('../../../.env', import.meta.url));
|
||||
const text = readFileSync(path, 'utf-8');
|
||||
const values: Record<string, string> = {};
|
||||
for (const line of text.split('\n')) {
|
||||
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
|
||||
const key = match?.[1];
|
||||
if (key !== undefined) values[key] = match?.[2] ?? '';
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
const env = readDotEnv();
|
||||
|
||||
export const DEMO_ANALYST = {
|
||||
email: env.DEMO_ANALYST_EMAIL ?? 'analyst@example.com',
|
||||
password: env.DEMO_ANALYST_PASSWORD ?? 'Analyst-Passw0rd!',
|
||||
};
|
||||
|
||||
export const API_BASE_URL = process.env.E2E_BASE_URL
|
||||
? `${process.env.E2E_BASE_URL}/api/v1`
|
||||
: 'http://localhost:8080/api/v1';
|
||||
Reference in New Issue
Block a user