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
+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!',
};