start project
This commit is contained in:
+6
-1
@@ -29,6 +29,9 @@ FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/app.conf
|
||||
# Included by the location blocks in app.conf - see each file's own comment.
|
||||
COPY frontend/security-headers.conf /etc/nginx/conf.d/security-headers.conf
|
||||
COPY frontend/proxy-backend.conf /etc/nginx/conf.d/proxy-backend.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# nginx:alpine already ships an unprivileged "nginx" user; the official
|
||||
@@ -39,5 +42,7 @@ RUN touch /var/run/nginx.pid \
|
||||
USER nginx
|
||||
EXPOSE 8080
|
||||
|
||||
# 127.0.0.1, not localhost: nginx listens on IPv4 only, and busybox wget
|
||||
# resolves localhost to ::1 first without falling back to IPv4.
|
||||
HEALTHCHECK --interval=30s --timeout=4s --start-period=10s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:8080/healthz || exit 1
|
||||
CMD wget -q --spider http://127.0.0.1:8080/healthz || exit 1
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { DEMO_ANALYST } from './support/env';
|
||||
|
||||
/**
|
||||
* End-to-end regression test for the bug reported against the running app:
|
||||
* ticking one value in a facet (e.g. "Job family") made every other value in
|
||||
* that same checkbox list disappear, because the backend's aggregations were
|
||||
* computed on the already-filtered result set. Fixed in
|
||||
* apps/search/backends/{elastic,postgres}.py (self-exclusion aggregations)
|
||||
* and covered at the unit/API level by test_query_builder.py and
|
||||
* test_search_api.py. This spec drives the real browser against the real,
|
||||
* Elasticsearch-backed stack (`docker compose up`) end to end, so it also
|
||||
* catches anything a backend-only test cannot: wiring in FacetGroup/
|
||||
* FilterPanel, the network round trip, and URL state.
|
||||
*/
|
||||
|
||||
async function signIn(page: import('@playwright/test').Page) {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(DEMO_ANALYST.email);
|
||||
await page.getByLabel('Password').fill(DEMO_ANALYST.password);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page.getByRole('button', { name: /^Job family/ })).toBeVisible();
|
||||
}
|
||||
|
||||
/** The raw facet value lives in the label's `title` attribute (see FacetGroup.tsx). */
|
||||
async function facetValues(group: import('@playwright/test').Locator): Promise<string[]> {
|
||||
const labels = group.locator('.facet__label');
|
||||
const count = await labels.count();
|
||||
const values: string[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
values.push((await labels.nth(i).getAttribute('title')) ?? '');
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
test.describe('multi-select facets', () => {
|
||||
test('ticking one job-family value keeps every sibling value in the list', async ({ page }) => {
|
||||
await signIn(page);
|
||||
|
||||
const jobFamily = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Job family/ }) });
|
||||
const before = await facetValues(jobFamily);
|
||||
expect(before.length).toBeGreaterThan(1);
|
||||
|
||||
const firstCheckbox = jobFamily.getByRole('checkbox').first();
|
||||
const firstValue = await jobFamily.locator('.facet__label').first().getAttribute('title');
|
||||
|
||||
const responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/'));
|
||||
await firstCheckbox.check();
|
||||
await responsePromise;
|
||||
|
||||
await expect(firstCheckbox).toBeChecked();
|
||||
|
||||
// The exact bug: this used to collapse to just the ticked value.
|
||||
const after = await facetValues(jobFamily);
|
||||
expect(after.sort()).toEqual(before.sort());
|
||||
|
||||
// The URL is the source of truth for the filter state.
|
||||
expect(page.url()).toContain(`roles=${encodeURIComponent(firstValue ?? '')}`);
|
||||
});
|
||||
|
||||
test('ticking a second job-family value accumulates instead of replacing the first', async ({ page }) => {
|
||||
await signIn(page);
|
||||
|
||||
const jobFamily = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Job family/ }) });
|
||||
const checkboxes = jobFamily.getByRole('checkbox');
|
||||
const labels = jobFamily.locator('.facet__label');
|
||||
|
||||
const firstValue = await labels.nth(0).getAttribute('title');
|
||||
const secondValue = await labels.nth(1).getAttribute('title');
|
||||
|
||||
let responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/'));
|
||||
await checkboxes.nth(0).check();
|
||||
await responsePromise;
|
||||
|
||||
responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/'));
|
||||
await checkboxes.nth(1).check();
|
||||
await responsePromise;
|
||||
|
||||
await expect(checkboxes.nth(0)).toBeChecked();
|
||||
await expect(checkboxes.nth(1)).toBeChecked();
|
||||
|
||||
const url = new URL(page.url());
|
||||
const roles = (url.searchParams.get('roles') ?? '').split(',');
|
||||
expect(roles).toContain(firstValue);
|
||||
expect(roles).toContain(secondValue);
|
||||
|
||||
// Ticking the second one must not have removed the first from the DOM.
|
||||
const after = await facetValues(jobFamily);
|
||||
expect(after).toContain(firstValue);
|
||||
expect(after).toContain(secondValue);
|
||||
|
||||
// "Clear all" must be able to undo both selections in one click.
|
||||
await page.getByRole('button', { name: /clear all/i }).click();
|
||||
await expect(checkboxes.nth(0)).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('a different facet still narrows when job family is filtered (no over-relaxing)', async ({ page }) => {
|
||||
await signIn(page);
|
||||
|
||||
const jobFamily = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Job family/ }) });
|
||||
const country = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Country/ }) });
|
||||
|
||||
// Country is collapsed by default (only the first three facets start
|
||||
// open) - expand it so its checkbox list is actually in the DOM.
|
||||
await page.getByRole('button', { name: /^Country/ }).click();
|
||||
const countryBefore = await facetValues(country);
|
||||
expect(countryBefore.length).toBeGreaterThan(0);
|
||||
|
||||
const responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/'));
|
||||
await jobFamily.getByRole('checkbox').first().check();
|
||||
await responsePromise;
|
||||
|
||||
const countryAfter = await facetValues(country);
|
||||
// A facet with no selection of its own must still be scoped by the
|
||||
// active filter - it should not also stay untouched.
|
||||
expect(countryAfter.length).toBeLessThanOrEqual(countryBefore.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Reads the demo analyst credentials straight out of the repo's `.env`
|
||||
* (created by `make env` from `.env.example`) instead of hard-coding them, so
|
||||
* the e2e suite keeps working if the seeded demo password ever changes.
|
||||
*/
|
||||
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!',
|
||||
};
|
||||
+48
-23
@@ -4,7 +4,21 @@
|
||||
# Putting both behind one origin means the browser never issues a cross-origin
|
||||
# request, so no CORS pre-flight, no third-party cookie rules, and the API can
|
||||
# keep its allow-list empty in production.
|
||||
#
|
||||
# Every location includes security-headers.conf rather than relying on a
|
||||
# server-level declaration - see the comment in that file for why inheritance
|
||||
# cannot be trusted here. Proxied locations additionally include
|
||||
# proxy-backend.conf, which forwards the client details and drops the
|
||||
# duplicate security headers Django emits.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# The SPA's policy: no inline script, nothing loaded off-origin. The bundle is
|
||||
# fully self-hosted, so this can stay strict. ('unsafe-inline' for styles is
|
||||
# still required by the inline style attributes React emits.)
|
||||
map $host $spa_csp {
|
||||
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
@@ -12,14 +26,6 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# --- Security headers ----------------------------------------------------
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
# The bundle is fully self-hosted, so the policy can stay strict.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 2m;
|
||||
|
||||
@@ -29,40 +35,59 @@ server {
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
include /etc/nginx/conf.d/security-headers.conf;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 'ok';
|
||||
}
|
||||
|
||||
# Hashed assets never change under the same name - cache them hard.
|
||||
location /assets/ {
|
||||
include /etc/nginx/conf.d/security-headers.conf;
|
||||
add_header Content-Security-Policy $spa_csp always;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
# --- API documentation ---------------------------------------------------
|
||||
# Swagger UI and ReDoc bootstrap themselves from an inline <script> block
|
||||
# that drf-spectacular generates, which the SPA's "script-src 'self'"
|
||||
# blocks - the page loads but renders blank. Rather than punching
|
||||
# 'unsafe-inline' into the policy the SPA runs under, the exemption is
|
||||
# confined to these three documentation paths: they render no user data and
|
||||
# no user input, they load nothing off-origin (drf-spectacular-sidecar
|
||||
# serves the bundles from /static/, not a CDN), and in production they are
|
||||
# not routed at all - SERVE_API_DOCS defaults to false there.
|
||||
location ~ ^/api/(docs|redoc|schema)/ {
|
||||
include /etc/nginx/conf.d/security-headers.conf;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
include /etc/nginx/conf.d/proxy-backend.conf;
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Request-ID $request_id;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Django admin and API documentation share the backend origin.
|
||||
location ~ ^/(admin|static)/ {
|
||||
location /api/ {
|
||||
include /etc/nginx/conf.d/security-headers.conf;
|
||||
add_header Content-Security-Policy $spa_csp always;
|
||||
|
||||
include /etc/nginx/conf.d/proxy-backend.conf;
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
|
||||
# Django admin and static files share the backend origin.
|
||||
location ~ ^/(admin|static)/ {
|
||||
include /etc/nginx/conf.d/security-headers.conf;
|
||||
add_header Content-Security-Policy $spa_csp always;
|
||||
|
||||
include /etc/nginx/conf.d/proxy-backend.conf;
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Client-side routing: unknown paths fall back to the SPA shell.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
include /etc/nginx/conf.d/security-headers.conf;
|
||||
add_header Content-Security-Policy $spa_csp always;
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1225
-1
File diff suppressed because it is too large
Load Diff
+15
-2
@@ -9,7 +9,12 @@
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:contract": "vitest run --config vitest.contract.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
@@ -20,17 +25,25 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.12.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitest/ui": "^2.1.9",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"globals": "^15.11.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.8.1",
|
||||
"vite": "^5.4.8"
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.9",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Runs against the already-running docker compose stack (`make up` /
|
||||
* `make dev`), not a spawned dev server: the search results these tests
|
||||
* assert on come from the real Postgres/Elasticsearch-backed API, which is
|
||||
* the whole point of an end-to-end test - a mocked backend would not have
|
||||
* caught the facet bug this suite guards against.
|
||||
*
|
||||
* npm run test:e2e
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:8080',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shared configuration for every location proxied to the Django container.
|
||||
#
|
||||
# Django's SecurityMiddleware sets its own X-Frame-Options, nosniff,
|
||||
# Referrer-Policy and Cross-Origin-Opener-Policy, and nginx adds the same
|
||||
# names from security-headers.conf - so without the proxy_hide_header lines
|
||||
# below the client receives each one twice. That is not merely untidy: on
|
||||
# /api/docs/ drf-spectacular relaxes COOP to "unsafe-none" while the edge
|
||||
# sends "same-origin", and a response carrying two contradictory
|
||||
# Cross-Origin-Opener-Policy values is one the browser is free to resolve
|
||||
# either way. The edge is the single authority; the upstream copies are
|
||||
# dropped here.
|
||||
# -----------------------------------------------------------------------------
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header X-Content-Type-Options;
|
||||
proxy_hide_header Referrer-Policy;
|
||||
proxy_hide_header Cross-Origin-Opener-Policy;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Request-ID $request_id;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
@@ -0,0 +1,19 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Security headers, kept in one file and `include`d into every location.
|
||||
#
|
||||
# Why an include rather than declaring these once at server level: nginx's
|
||||
# add_header is *replacing*, not additive - the moment a location block
|
||||
# declares any add_header of its own (a Cache-Control, say), every header
|
||||
# inherited from the enclosing server block is silently dropped. That is easy
|
||||
# to miss precisely because nothing fails: the page still loads, just without
|
||||
# its protections. Including the same snippet everywhere makes each location
|
||||
# self-contained, so adding a Cache-Control to a block can no longer disarm it.
|
||||
#
|
||||
# Note there is deliberately no Content-Security-Policy here: the SPA and the
|
||||
# API documentation need different policies, so each location sets its own.
|
||||
# -----------------------------------------------------------------------------
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* `FacetGroup` renders one facet's checkbox list. These tests cover the
|
||||
* multi-select contract from the rendering side (complementing
|
||||
* `useSearchFilters.test.tsx`, which covers the state side): several boxes
|
||||
* can be checked at once, checking one must not affect its siblings, and a
|
||||
* value that is selected but has since dropped out of the current bucket
|
||||
* list must stay visible so the user can still untick it.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FacetGroup } from '@/components/FacetGroup';
|
||||
import type { FacetBucket } from '@/types';
|
||||
|
||||
const BUCKETS: FacetBucket[] = [
|
||||
{ key: 'engineering', count: 42 },
|
||||
{ key: 'sales', count: 30 },
|
||||
{ key: 'human_resources', count: 12 },
|
||||
];
|
||||
|
||||
describe('FacetGroup', () => {
|
||||
it('renders one checkbox per bucket, none checked by default', () => {
|
||||
render(
|
||||
<FacetGroup title="Job family" buckets={BUCKETS} selected={[]} onToggle={vi.fn()} />,
|
||||
);
|
||||
|
||||
for (const bucket of BUCKETS) {
|
||||
const checkbox = screen.getByRole('checkbox', { name: new RegExp(bucket.key) });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
}
|
||||
});
|
||||
|
||||
it('checks every value present in `selected`, independently of the others', () => {
|
||||
render(
|
||||
<FacetGroup
|
||||
title="Job family"
|
||||
buckets={BUCKETS}
|
||||
selected={['engineering', 'human_resources']}
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /engineering/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /human_resources/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /^sales/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('calls onToggle with the clicked value only, leaving other selections to the caller', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FacetGroup title="Job family" buckets={BUCKETS} selected={['engineering']} onToggle={onToggle} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: /^sales/ }));
|
||||
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
expect(onToggle).toHaveBeenCalledWith('sales');
|
||||
});
|
||||
|
||||
it('clicking a second checkbox after the first stays independent (two separate calls, two separate values)', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FacetGroup title="Job family" buckets={BUCKETS} selected={[]} onToggle={onToggle} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: /engineering/ }));
|
||||
await user.click(screen.getByRole('checkbox', { name: /^sales/ }));
|
||||
|
||||
expect(onToggle).toHaveBeenNthCalledWith(1, 'engineering');
|
||||
expect(onToggle).toHaveBeenNthCalledWith(2, 'sales');
|
||||
});
|
||||
|
||||
it('keeps a selected value visible even after it drops out of the bucket list', () => {
|
||||
// Mirrors what the backend now guarantees for a facet's *own* filter,
|
||||
// and is still needed for facets narrowed by a *different* filter: the
|
||||
// checkbox for an already-ticked value must never vanish, or the user
|
||||
// would have no way to untick it.
|
||||
render(
|
||||
<FacetGroup
|
||||
title="Job family"
|
||||
buckets={[{ key: 'sales', count: 5 }]}
|
||||
selected={['engineering']}
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stale = screen.getByRole('checkbox', { name: /engineering/ });
|
||||
expect(stale).toBeChecked();
|
||||
});
|
||||
|
||||
it('a searchable facet filters its own list without touching the selection', async () => {
|
||||
const manyBuckets: FacetBucket[] = [
|
||||
...BUCKETS,
|
||||
{ key: 'marketing', count: 8 },
|
||||
{ key: 'finance', count: 7 },
|
||||
{ key: 'legal', count: 6 },
|
||||
{ key: 'design', count: 5 },
|
||||
{ key: 'support', count: 4 },
|
||||
{ key: 'operations', count: 3 },
|
||||
];
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FacetGroup
|
||||
title="Job family"
|
||||
buckets={manyBuckets}
|
||||
selected={['engineering']}
|
||||
onToggle={vi.fn()}
|
||||
searchable
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/filter job family/i), 'sal');
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /^sales/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('checkbox', { name: /marketing/ })).not.toBeInTheDocument();
|
||||
// The active selection must survive the local filter text too.
|
||||
expect(screen.getByRole('checkbox', { name: /engineering/ })).toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* `FilterPanel` wires six independent `FacetGroup`s to one `filters` object.
|
||||
* The regression this guards against is cross-talk between facets: selecting
|
||||
* a value in "Skills" must call `onToggle` with the `skills` key, never
|
||||
* `roles` or any other facet, and each group must show only its own
|
||||
* selections.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FilterPanel } from '@/components/FilterPanel';
|
||||
import { EMPTY_FILTERS } from '@/types';
|
||||
import type { FacetBucket, FacetName } from '@/types';
|
||||
|
||||
const FACETS: Record<FacetName, FacetBucket[]> = {
|
||||
skills: [{ key: 'python', count: 10 }, { key: 'react', count: 8 }],
|
||||
job_title_role: [{ key: 'engineering', count: 12 }, { key: 'sales', count: 6 }],
|
||||
seniority: [{ key: 'senior', count: 9 }],
|
||||
country: [{ key: 'germany', count: 5 }, { key: 'spain', count: 4 }],
|
||||
industry: [{ key: 'software', count: 7 }],
|
||||
company: [{ key: 'acme corp', count: 3 }],
|
||||
};
|
||||
|
||||
describe('FilterPanel', () => {
|
||||
it('toggling a value in one facet reports that facet key, not a sibling one', async () => {
|
||||
// Skills and Job family are both open by default (the first three
|
||||
// groups start expanded); Country only auto-opens once it has an active
|
||||
// selection, so this test sticks to the always-open pair.
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FilterPanel
|
||||
facets={FACETS}
|
||||
filters={EMPTY_FILTERS}
|
||||
activeCount={0}
|
||||
onToggle={onToggle}
|
||||
onUpdate={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: /engineering/ }));
|
||||
expect(onToggle).toHaveBeenCalledWith('roles', 'engineering');
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: /python/ }));
|
||||
expect(onToggle).toHaveBeenCalledWith('skills', 'python');
|
||||
});
|
||||
|
||||
it('each facet only shows its own active selections', () => {
|
||||
render(
|
||||
<FilterPanel
|
||||
facets={FACETS}
|
||||
filters={{ ...EMPTY_FILTERS, roles: ['engineering'], countries: ['germany', 'spain'] }}
|
||||
activeCount={3}
|
||||
onToggle={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /engineering/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /^sales/ })).not.toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /germany/ })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /spain/ })).toBeChecked();
|
||||
// Skills has no active selection, even though other facets do.
|
||||
expect(screen.getByRole('checkbox', { name: /python/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows the active filter count and clears everything on "Clear all"', async () => {
|
||||
const onReset = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FilterPanel
|
||||
facets={FACETS}
|
||||
filters={{ ...EMPTY_FILTERS, roles: ['engineering', 'sales'] }}
|
||||
activeCount={2}
|
||||
onToggle={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /clear all \(2\)/i });
|
||||
await user.click(clearButton);
|
||||
expect(onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not show a clear button when nothing is active', () => {
|
||||
render(
|
||||
<FilterPanel
|
||||
facets={FACETS}
|
||||
filters={EMPTY_FILTERS}
|
||||
activeCount={0}
|
||||
onToggle={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /clear all/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('typing an experience bound calls onUpdate with just that field', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FilterPanel
|
||||
facets={FACETS}
|
||||
filters={EMPTY_FILTERS}
|
||||
activeCount={0}
|
||||
onToggle={vi.fn()}
|
||||
onUpdate={onUpdate}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText(/minimum years of experience/i), '5');
|
||||
const lastCall = onUpdate.mock.calls.at(-1)?.[0];
|
||||
expect(lastCall).toEqual({ minExperience: 5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* `useSearchFilters` owns the multi-select state for every facet (skills,
|
||||
* roles, seniority, countries, industries, companies) via `toggle`. These
|
||||
* tests pin down the add/remove/OR-within-a-facet contract independently of
|
||||
* any rendering, since a regression here would silently break every facet at
|
||||
* once.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { useSearchFilters } from '@/hooks/useSearchFilters';
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <MemoryRouter initialEntries={['/']}>{children}</MemoryRouter>;
|
||||
}
|
||||
|
||||
describe('useSearchFilters - multi-select toggle', () => {
|
||||
it('starts with every list filter empty', () => {
|
||||
const { result } = renderHook(() => useSearchFilters(), { wrapper });
|
||||
expect(result.current.filters.roles).toEqual([]);
|
||||
expect(result.current.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('adds the first value picked in a facet', () => {
|
||||
const { result } = renderHook(() => useSearchFilters(), { wrapper });
|
||||
|
||||
act(() => result.current.toggle('roles', 'engineering'));
|
||||
|
||||
expect(result.current.filters.roles).toEqual(['engineering']);
|
||||
expect(result.current.activeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the first value when a second one is ticked in the same facet', () => {
|
||||
// This is the exact behaviour the user asked for: ticking "job family"
|
||||
// must accumulate selections, not replace the previous one.
|
||||
const { result } = renderHook(() => useSearchFilters(), { wrapper });
|
||||
|
||||
act(() => result.current.toggle('roles', 'engineering'));
|
||||
act(() => result.current.toggle('roles', 'sales'));
|
||||
|
||||
expect(result.current.filters.roles).toEqual(['engineering', 'sales']);
|
||||
expect(result.current.activeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('accumulates three or more values in one facet', () => {
|
||||
const { result } = renderHook(() => useSearchFilters(), { wrapper });
|
||||
|
||||
act(() => result.current.toggle('skills', 'python'));
|
||||
act(() => result.current.toggle('skills', 'django'));
|
||||
act(() => result.current.toggle('skills', 'postgresql'));
|
||||
|
||||
expect(result.current.filters.skills).toEqual(['python', 'django', 'postgresql']);
|
||||
});
|
||||
|
||||
it('un-ticking a value removes only that value, keeping its siblings', () => {
|
||||
const { result } = renderHook(() => useSearchFilters(), { wrapper });
|
||||
|
||||
act(() => result.current.toggle('roles', 'engineering'));
|
||||
act(() => result.current.toggle('roles', 'sales'));
|
||||
act(() => result.current.toggle('roles', 'human_resources'));
|
||||
act(() => result.current.toggle('roles', 'sales'));
|
||||
|
||||
expect(result.current.filters.roles).toEqual(['engineering', 'human_resources']);
|
||||
});
|
||||
|
||||
it('keeps independent facets independent', () => {
|
||||
const { result } = renderHook(() => useSearchFilters(), { wrapper });
|
||||
|
||||
act(() => result.current.toggle('roles', 'engineering'));
|
||||
act(() => result.current.toggle('countries', 'germany'));
|
||||
act(() => result.current.toggle('countries', 'spain'));
|
||||
|
||||
expect(result.current.filters.roles).toEqual(['engineering']);
|
||||
expect(result.current.filters.countries).toEqual(['germany', 'spain']);
|
||||
expect(result.current.activeCount).toBe(3);
|
||||
});
|
||||
|
||||
it('resets the page to 1 whenever a filter changes', () => {
|
||||
const { result } = renderHook(() => useSearchFilters(), { wrapper });
|
||||
|
||||
act(() => result.current.update({ page: 4 }));
|
||||
expect(result.current.filters.page).toBe(4);
|
||||
|
||||
act(() => result.current.toggle('roles', 'engineering'));
|
||||
expect(result.current.filters.page).toBe(1);
|
||||
});
|
||||
|
||||
it('reset() clears every filter back to defaults', () => {
|
||||
const { result } = renderHook(() => useSearchFilters(), { wrapper });
|
||||
|
||||
act(() => result.current.toggle('roles', 'engineering'));
|
||||
act(() => result.current.toggle('skills', 'python'));
|
||||
act(() => result.current.update({ minExperience: 5 }));
|
||||
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.filters).toEqual(result.current.defaults);
|
||||
expect(result.current.activeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function useFiltersWithLocation() {
|
||||
return { ...useSearchFilters(), search: useLocation().search };
|
||||
}
|
||||
|
||||
describe('useSearchFilters - URL round-trip', () => {
|
||||
it('serialises multiple selections in the same facet as one comma-joined param, and re-hydrates identically from that exact URL', () => {
|
||||
const { result } = renderHook(() => useFiltersWithLocation(), { wrapper });
|
||||
|
||||
act(() => result.current.toggle('roles', 'engineering'));
|
||||
act(() => result.current.toggle('roles', 'sales'));
|
||||
|
||||
const producedSearch = result.current.search;
|
||||
expect(producedSearch).toContain('roles=engineering%2Csales');
|
||||
|
||||
// A page refresh or a shared link starts a brand new hook instance with
|
||||
// nothing but that URL - re-reading through one proves the query string
|
||||
// itself carries both selections, not just the in-memory state.
|
||||
const { result: reread } = renderHook(() => useSearchFilters(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[`/${producedSearch}`]}>{children}</MemoryRouter>
|
||||
),
|
||||
});
|
||||
expect(reread.current.filters.roles).toEqual(['engineering', 'sales']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// vitest.config.ts intentionally leaves `test.globals` off (test files import
|
||||
// describe/it/expect explicitly), so Testing Library's own auto-cleanup -
|
||||
// which only registers itself when it detects a *global* `afterEach` - never
|
||||
// fires on its own. Without this, unmounted DOM from one `it()` block leaks
|
||||
// into the next within the same file, and every query starts matching
|
||||
// duplicate nodes.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -19,5 +19,13 @@
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": [
|
||||
"src",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"vitest.contract.config.ts",
|
||||
"playwright.config.ts",
|
||||
"e2e",
|
||||
"contract"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/components/FacetGroup.tsx","./src/components/FilterPanel.tsx","./src/components/Highlight.tsx","./src/components/Pagination.tsx","./src/components/ProfileDrawer.tsx","./src/components/ResultCard.tsx","./src/components/SearchBar.tsx","./src/hooks/useDebouncedValue.ts","./src/hooks/useSearchFilters.ts","./src/lib/auth-context.ts","./src/lib/auth.tsx","./src/pages/LoginPage.tsx","./src/pages/SearchPage.tsx","./src/types/index.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/components/FacetGroup.test.tsx","./src/components/FacetGroup.tsx","./src/components/FilterPanel.test.tsx","./src/components/FilterPanel.tsx","./src/components/Highlight.tsx","./src/components/Pagination.tsx","./src/components/ProfileDrawer.tsx","./src/components/ResultCard.tsx","./src/components/SearchBar.tsx","./src/hooks/useDebouncedValue.ts","./src/hooks/useSearchFilters.test.tsx","./src/hooks/useSearchFilters.ts","./src/lib/auth-context.ts","./src/lib/auth.tsx","./src/pages/LoginPage.tsx","./src/pages/SearchPage.tsx","./src/test/setup.ts","./src/types/index.ts","./vite.config.ts","./vitest.config.ts","./vitest.contract.config.ts","./playwright.config.ts","./e2e/multi-select-facets.spec.ts","./e2e/support/env.ts","./contract/schemas.ts","./contract/search-response.contract.test.ts","./contract/support/env.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
/**
|
||||
* Separate from vite.config.ts on purpose: the app's build config knows
|
||||
* nothing about a test runner, and vitest's `test` block is not a valid Vite
|
||||
* option, so mixing them makes `vite build` warn about an unknown key.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// Mirrors the "@/*" path mapping in tsconfig.json.
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: false,
|
||||
// e2e/ holds Playwright specs (a different `test()` global), and
|
||||
// contract/ hits the live stack over the network - neither belongs in
|
||||
// the hermetic default run. See vitest.contract.config.ts for the latter.
|
||||
exclude: ['node_modules/**', 'e2e/**', 'contract/**'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'text-summary'],
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: ['src/main.tsx', 'src/vite-env.d.ts', 'src/**/*.d.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
/**
|
||||
* Separate from vitest.config.ts on purpose: contract tests make real HTTP
|
||||
* calls to the running docker compose stack and must never be picked up by
|
||||
* a plain `npm test` (which has to stay hermetic and fast, and must pass
|
||||
* with no services running at all).
|
||||
*
|
||||
* npm run test:contract
|
||||
*/
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['contract/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user