start project
CI / Backend (lint, types, tests) (push) Has been cancelled
CI / Frontend (lint, types, build) (push) Has been cancelled
CI / Docker images build (push) Has been cancelled

This commit is contained in:
2026-09-04 12:52:21 +03:30
parent a464e550c9
commit 212074c5f5
40 changed files with 2804 additions and 164 deletions
+6 -1
View File
@@ -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
+79
View File
@@ -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}`);
}
});
});
+26
View File
@@ -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';
+119
View File
@@ -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);
});
});
+26
View File
@@ -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
View File
@@ -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;
}
}
+1225 -1
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -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"
}
}
+23
View File
@@ -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'] } }],
});
+26
View File
@@ -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;
+19
View File
@@ -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;
+122
View File
@@ -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']);
});
});
+14
View File
@@ -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();
});
+9 -1
View File
@@ -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
View File
@@ -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"}
+32
View File
@@ -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'],
},
},
});
+16
View File
@@ -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'],
},
});