fix(discovery): expose saved-search load failures and retry
This commit is contained in:
@@ -0,0 +1,303 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { expect, test as base, type APIRequestContext, type Page, type Route, type TestInfo } from '@playwright/test';
|
||||||
|
import { translations } from '../src/i18n/translations';
|
||||||
|
|
||||||
|
const apiUrl = 'http://localhost:5302/api';
|
||||||
|
const savedUrl = `${apiUrl}/job-discovery/saved-searches`;
|
||||||
|
const password = 'E2ePassword123!';
|
||||||
|
type Account = { headers: Record<string, string>; id: string };
|
||||||
|
type SavedSearch = { id: number; name: string; query: string; location: string; isActive: boolean; resultCount: number; dismissedCount: number; lastRunAtUtc: string | null };
|
||||||
|
const providerJob = {
|
||||||
|
id: 'repair-provider-vacancy', title: 'Provider fixture engineer', company: 'Synthetic AS', location: 'Bergen',
|
||||||
|
url: 'https://arbeidsplassen.nav.no/stillinger/stilling/repair-provider-vacancy', source: 'nav',
|
||||||
|
sourceName: 'NAV Arbeidsplassen', acquisitionType: 'searched', countryCode: 'NO', retrievedAt: '2026-10-09T10:00:00Z',
|
||||||
|
};
|
||||||
|
const providerBoundary = 'Saved-search listing, create, pause/resume, delete, authentication and ownership denials use the native disposable SQLite API. Only vacancy-provider output for one-off /search and the owner UI /saved-searches/{id}/run response is intercepted: NAV has a hard-coded external base URL. This browser suite does not claim runner/history persistence; backend SQLite controller tests cover the real runner with a fake IJobDiscoverySearchService. List faults are explicitly injected 500, malformed payload and delayed native GET.';
|
||||||
|
|
||||||
|
async function login(api: APIRequestContext, email: string): Promise<Account> {
|
||||||
|
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||||
|
const response = await api.get(`${apiUrl}/auth/me`);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const me = await response.json();
|
||||||
|
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||||
|
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAccount(admin: APIRequestContext, headers: Record<string, string>) {
|
||||||
|
const email = `saved-repair-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||||
|
expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = base.extend<{ account: Account }>({
|
||||||
|
account: async ({ playwright, page, context }, provide) => {
|
||||||
|
const admin = await playwright.request.newContext();
|
||||||
|
try {
|
||||||
|
const administrator = await login(admin, 'e2e@example.test');
|
||||||
|
const account = await login(page.request, await createAccount(admin, administrator.headers));
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en');
|
||||||
|
if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light');
|
||||||
|
});
|
||||||
|
await context.route('**/*', route => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
|
||||||
|
? route.continue() : route.abort();
|
||||||
|
});
|
||||||
|
await provide(account);
|
||||||
|
} finally { await admin.dispose(); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function nativeList(api: APIRequestContext): Promise<SavedSearch[]> {
|
||||||
|
const response = await api.get(savedUrl);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserResponse(page: Page, method: string, url = savedUrl) {
|
||||||
|
return page.waitForResponse(response => response.url() === url && response.request().method() === method);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capture(page: Page, info: TestInfo, name: string, scan = false) {
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), `${name} horizontal reflow`).toBe(true);
|
||||||
|
const path = info.outputPath(`${name}.png`);
|
||||||
|
await page.screenshot({ path, fullPage: true, animations: 'disabled' });
|
||||||
|
await info.attach(name, { path, contentType: 'image/png' });
|
||||||
|
if (scan) {
|
||||||
|
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||||
|
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
|
||||||
|
expect(result.violations, `${name}: ${JSON.stringify(result.violations)}`).toEqual([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('[P5-15-01R-01] native saved NAV search survives Jobs navigation, reload, apply, pause/resume and delete', async ({ page, account }, info) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const t = translations.en;
|
||||||
|
await info.attach('saved-search-provider-boundary', { body: providerBoundary, contentType: 'text/plain' });
|
||||||
|
expect(await nativeList(page.request)).toEqual([]);
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.goto('/jobs');
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: t.jobApplications, exact: true })).toBeVisible();
|
||||||
|
const initial = browserResponse(page, 'GET');
|
||||||
|
await page.getByRole('button', { name: t.discoverJobs, exact: true }).click();
|
||||||
|
expect((await initial).status()).toBe(200);
|
||||||
|
expect(await (await initial).json()).toEqual([]);
|
||||||
|
await expect(page).toHaveURL(/\/discover$/);
|
||||||
|
await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill(' backend ');
|
||||||
|
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill(' Oslo ');
|
||||||
|
await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill(' Backend Oslo native ');
|
||||||
|
const createdResponse = browserResponse(page, 'POST');
|
||||||
|
await page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }).click();
|
||||||
|
const response = await createdResponse;
|
||||||
|
expect(response.status()).toBe(201);
|
||||||
|
const saved: SavedSearch = await response.json();
|
||||||
|
expect(saved).toMatchObject({ name: 'Backend Oslo native', query: 'backend', location: 'Oslo', isActive: true, resultCount: 0, dismissedCount: 0 });
|
||||||
|
expect(await nativeList(page.request)).toEqual([saved]);
|
||||||
|
await expect(page.getByText(saved.name, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: t.jobApplications, exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/jobs$/);
|
||||||
|
// Jobs Saved views are account-scoped local filters, a different domain from saved NAV searches.
|
||||||
|
await page.getByRole('button', { name: t.savedViewsTooltip, exact: true }).click();
|
||||||
|
await expect(page.getByRole('menuitem', { name: t.savedViewsEmpty, exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('menu').getByText(saved.name, { exact: true })).toHaveCount(0);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
const returned = browserResponse(page, 'GET');
|
||||||
|
await page.getByRole('button', { name: t.discoverJobs, exact: true }).click();
|
||||||
|
expect((await returned).status()).toBe(200);
|
||||||
|
expect(await (await returned).json()).toEqual([saved]);
|
||||||
|
const refreshed = browserResponse(page, 'GET');
|
||||||
|
await page.reload();
|
||||||
|
expect((await refreshed).status()).toBe(200);
|
||||||
|
expect(await (await refreshed).json()).toEqual([saved]);
|
||||||
|
await expect(page.getByText(saved.name, { exact: true })).toBeVisible();
|
||||||
|
await capture(page, info, 'saved-search-native-persisted-en-light-desktop', true);
|
||||||
|
|
||||||
|
// Intercept the provider-dependent run only; the search DTO remains a native owner read.
|
||||||
|
await page.route(`${savedUrl}/${saved.id}/run`, async route => {
|
||||||
|
expect(route.request().method()).toBe('POST');
|
||||||
|
const current = (await nativeList(page.request)).find(row => row.id === saved.id)!;
|
||||||
|
await route.fulfill({ json: { search: current, jobs: [{ job: providerJob, isNew: true, isDismissed: false }] } });
|
||||||
|
});
|
||||||
|
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('changed draft');
|
||||||
|
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Bergen');
|
||||||
|
await page.getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }).click();
|
||||||
|
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('backend');
|
||||||
|
await expect(page.getByLabel(t.jobDiscoveryMunicipality, { exact: true })).toHaveValue('Oslo');
|
||||||
|
await expect(page.getByText(providerJob.title, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
for (const isActive of [false, true]) {
|
||||||
|
const updated = browserResponse(page, 'PATCH', `${savedUrl}/${saved.id}`);
|
||||||
|
await page.getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }).click();
|
||||||
|
expect((await updated).status()).toBe(200);
|
||||||
|
expect(await (await updated).json()).toMatchObject({ id: saved.id, isActive });
|
||||||
|
expect(await nativeList(page.request)).toEqual([{ ...saved, isActive }]);
|
||||||
|
const reloaded = browserResponse(page, 'GET');
|
||||||
|
await page.reload();
|
||||||
|
expect((await reloaded).status()).toBe(200);
|
||||||
|
expect(await (await reloaded).json()).toEqual([{ ...saved, isActive }]);
|
||||||
|
await expect(page.getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked({ checked: isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: t.adminUsersDelete, exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog.getByText(t.jobDiscoveryDeleteSavedConfirm.replace('{name}', saved.name), { exact: true })).toBeVisible();
|
||||||
|
const deleted = browserResponse(page, 'DELETE', `${savedUrl}/${saved.id}`);
|
||||||
|
await dialog.getByRole('button', { name: t.adminUsersDelete, exact: true }).click();
|
||||||
|
expect((await deleted).status()).toBe(204);
|
||||||
|
await expect(page.getByText(saved.name, { exact: true })).toHaveCount(0);
|
||||||
|
expect(await nativeList(page.request)).toEqual([]);
|
||||||
|
const empty = browserResponse(page, 'GET');
|
||||||
|
await page.reload();
|
||||||
|
expect((await empty).status()).toBe(200);
|
||||||
|
expect(await (await empty).json()).toEqual([]);
|
||||||
|
await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
|
||||||
|
expect((await page.request.patch(`${savedUrl}/${saved.id}`, { headers: account.headers, data: { isActive: true } })).status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P5-15-01R-01] native owner filters and crafted saved-search mutations deny foreign and anonymous access', async ({ page, account, playwright }, info) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const admin = await playwright.request.newContext();
|
||||||
|
const foreign = await playwright.request.newContext();
|
||||||
|
const anonymous = await playwright.request.newContext();
|
||||||
|
const evidence: { operation: string; status: number }[] = [];
|
||||||
|
try {
|
||||||
|
const administrator = await login(admin, 'e2e@example.test');
|
||||||
|
const other = await login(foreign, await createAccount(admin, administrator.headers));
|
||||||
|
const created = await page.request.post(savedUrl, { headers: account.headers, data: { name: 'Private owner saved NAV search', query: 'private role', location: 'Oslo' } });
|
||||||
|
expect(created.status()).toBe(201);
|
||||||
|
const saved: SavedSearch = await created.json();
|
||||||
|
expect(await nativeList(foreign)).toEqual([]);
|
||||||
|
const forgedRead = await foreign.get(`${savedUrl}?ownerUserId=${account.id}&id=${saved.id}&q=private`);
|
||||||
|
evidence.push({ operation: 'foreign list with forged owner/id/filter', status: forgedRead.status() });
|
||||||
|
expect(forgedRead.status()).toBe(200);
|
||||||
|
expect(await forgedRead.json()).toEqual([]);
|
||||||
|
|
||||||
|
for (const [operation, method, suffix, data] of [
|
||||||
|
['foreign update', 'PATCH', `/${saved.id}`, { id: saved.id, ownerUserId: account.id, isActive: false }],
|
||||||
|
['foreign delete', 'DELETE', `/${saved.id}?ownerUserId=${account.id}`, undefined],
|
||||||
|
['foreign run', 'POST', `/${saved.id}/run`, { ownerUserId: account.id }],
|
||||||
|
['foreign dismiss', 'PATCH', `/${saved.id}/results/repair-provider-vacancy/dismiss`, { ownerUserId: account.id, isDismissed: true }],
|
||||||
|
] as const) {
|
||||||
|
const denied = await foreign.fetch(`${savedUrl}${suffix}`, { method, headers: other.headers, data });
|
||||||
|
evidence.push({ operation, status: denied.status() });
|
||||||
|
expect(denied.status(), operation).toBe(404);
|
||||||
|
}
|
||||||
|
expect(await nativeList(page.request)).toEqual([saved]);
|
||||||
|
const forgedCreate = await foreign.post(savedUrl, { headers: other.headers, data: { name: 'Other account own search', query: 'analyst', location: 'Bergen', ownerUserId: account.id, id: saved.id } });
|
||||||
|
expect(forgedCreate.status()).toBe(201);
|
||||||
|
const own = await forgedCreate.json();
|
||||||
|
expect(await nativeList(foreign)).toEqual([own]);
|
||||||
|
expect(await nativeList(page.request)).toEqual([saved]);
|
||||||
|
expect(await (await foreign.get(`${savedUrl}?ownerUserId=${account.id}`)).json()).toEqual([own]);
|
||||||
|
|
||||||
|
for (const [operation, method, suffix, data] of [
|
||||||
|
['anonymous list', 'GET', '', undefined],
|
||||||
|
['anonymous create', 'POST', '', { name: 'Unauthorized', query: '', location: '' }],
|
||||||
|
['anonymous update', 'PATCH', `/${saved.id}`, { isActive: false }],
|
||||||
|
['anonymous delete', 'DELETE', `/${saved.id}`, undefined],
|
||||||
|
['anonymous run', 'POST', `/${saved.id}/run`, undefined],
|
||||||
|
['anonymous dismiss', 'PATCH', `/${saved.id}/results/repair-provider-vacancy/dismiss`, { isDismissed: true }],
|
||||||
|
] as const) {
|
||||||
|
const denied = await anonymous.fetch(`${savedUrl}${suffix}`, { method, data });
|
||||||
|
evidence.push({ operation, status: denied.status() });
|
||||||
|
expect(denied.status(), operation).toBe(401);
|
||||||
|
}
|
||||||
|
expect(await nativeList(page.request)).toEqual([saved]);
|
||||||
|
expect(await nativeList(foreign)).toEqual([own]);
|
||||||
|
} finally {
|
||||||
|
await info.attach('saved-search-native-owner-isolation', { body: JSON.stringify({ mockBoundary: 'No endpoint interception in this case; denied run checks ownership before any provider call.', evidence }, null, 2), contentType: 'application/json' });
|
||||||
|
await Promise.all([admin, foreign, anonymous].map(api => api.dispose()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P5-15-01R-01] saved-list faults retry a native GET without resetting discovery across EN/Bokmål, themes and widths', async ({ page, account }, info) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
await info.attach('saved-search-provider-boundary', { body: providerBoundary, contentType: 'text/plain' });
|
||||||
|
const created = await page.request.post(savedUrl, { headers: account.headers, data: { name: 'Native retry saved search', query: 'backend', location: 'Oslo' } });
|
||||||
|
expect(created.status()).toBe(201);
|
||||||
|
const saved: SavedSearch = await created.json();
|
||||||
|
await page.route(`${apiUrl}/job-discovery/search**`, route => route.fulfill({ json: [providerJob] }));
|
||||||
|
await page.goto('/jobs');
|
||||||
|
const variants = [
|
||||||
|
{ language: 'en', theme: 'light', width: 1440, fault: '500' },
|
||||||
|
{ language: 'en', theme: 'dark', width: 390, fault: 'malformed' },
|
||||||
|
{ language: 'nb', theme: 'light', width: 390, fault: '500' },
|
||||||
|
{ language: 'nb', theme: 'dark', width: 1440, fault: 'malformed' },
|
||||||
|
] as const;
|
||||||
|
const evidence: { variant: string; faultStatus: number; retryStatus: number; delayedStatus: number }[] = [];
|
||||||
|
for (const variant of variants) {
|
||||||
|
await test.step(`${variant.language}-${variant.theme}-${variant.width}-${variant.fault}`, async () => {
|
||||||
|
const t = translations[variant.language];
|
||||||
|
const name = `saved-search-${variant.language}-${variant.theme}-${variant.width}`;
|
||||||
|
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: account.headers, data: { language: variant.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||||
|
await page.evaluate(({ language, theme }) => {
|
||||||
|
localStorage.setItem('uiLanguage', language);
|
||||||
|
localStorage.setItem('jobtracker.themeMode', theme);
|
||||||
|
}, variant);
|
||||||
|
await page.setViewportSize({ width: variant.width, height: variant.width === 390 ? 844 : 900 });
|
||||||
|
// Duplicate Strict Mode mount reads share the fault; Retry goes to the real local API.
|
||||||
|
let injectFault = true;
|
||||||
|
const faultRoute = (route: Route) => injectFault
|
||||||
|
? route.fulfill(variant.fault === '500'
|
||||||
|
? { status: 500, json: { detail: 'Internal database stack trace must never reach the UI' } }
|
||||||
|
: { status: 200, json: { message: 'Invalid saved-search list fixture' } })
|
||||||
|
: route.continue();
|
||||||
|
await page.route(savedUrl, faultRoute);
|
||||||
|
const failed = browserResponse(page, 'GET');
|
||||||
|
await page.goto('/discover');
|
||||||
|
const faultStatus = (await failed).status();
|
||||||
|
expect(faultStatus).toBe(variant.fault === '500' ? 500 : 200);
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', variant.theme);
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('lang', variant.language === 'nb' ? 'nb-NO' : 'en');
|
||||||
|
const alert = page.getByRole('alert').filter({ hasText: t.jobDiscoverySavedLoadFailed });
|
||||||
|
await expect(alert).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true })).toBeDisabled();
|
||||||
|
await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByText(/Internal database stack trace|Invalid saved-search list fixture/)).toHaveCount(0);
|
||||||
|
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('developer draft');
|
||||||
|
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Bergen');
|
||||||
|
await page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }).click();
|
||||||
|
await expect(page.getByText(providerJob.title, { exact: true })).toBeVisible();
|
||||||
|
await capture(page, info, `${name}-load-error`, true);
|
||||||
|
injectFault = false;
|
||||||
|
const retried = browserResponse(page, 'GET');
|
||||||
|
await alert.getByRole('button', { name: t.retry, exact: true }).click();
|
||||||
|
const retryStatus = (await retried).status();
|
||||||
|
expect(retryStatus).toBe(200);
|
||||||
|
expect(await (await retried).json()).toEqual([saved]);
|
||||||
|
await expect(page.getByText(saved.name, { exact: true })).toBeVisible();
|
||||||
|
await expect(alert).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true })).toBeEnabled();
|
||||||
|
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('developer draft');
|
||||||
|
await expect(page.getByLabel(t.jobDiscoveryMunicipality, { exact: true })).toHaveValue('Bergen');
|
||||||
|
await expect(page.getByText(providerJob.title, { exact: true })).toBeVisible();
|
||||||
|
await capture(page, info, `${name}-native-retry`, true);
|
||||||
|
await page.unroute(savedUrl, faultRoute);
|
||||||
|
|
||||||
|
let release!: () => void;
|
||||||
|
const pending = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
// Hold transport, then continue the genuine GET; no successful list fixture is supplied.
|
||||||
|
const delayRoute = async (route: Route) => { await pending; await route.continue(); };
|
||||||
|
await page.route(savedUrl, delayRoute);
|
||||||
|
const delayed = browserResponse(page, 'GET');
|
||||||
|
try {
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('status', { name: t.jobDiscoverySavedLoading, exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toHaveCount(0);
|
||||||
|
await capture(page, info, `${name}-loading`);
|
||||||
|
} finally { release(); }
|
||||||
|
const delayedStatus = (await delayed).status();
|
||||||
|
expect(delayedStatus).toBe(200);
|
||||||
|
expect(await (await delayed).json()).toEqual([saved]);
|
||||||
|
await expect(page.getByText(saved.name, { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('status', { name: t.jobDiscoverySavedLoading, exact: true })).toHaveCount(0);
|
||||||
|
await page.unroute(savedUrl, delayRoute);
|
||||||
|
evidence.push({ variant: name, faultStatus, retryStatus, delayedStatus });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await info.attach('saved-search-fault-native-readbacks', { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' });
|
||||||
|
});
|
||||||
@@ -300,6 +300,8 @@ export const translations = {
|
|||||||
jobDiscoveryViewListing: "View listing",
|
jobDiscoveryViewListing: "View listing",
|
||||||
jobDiscoverySaveTracker: "Save to tracker",
|
jobDiscoverySaveTracker: "Save to tracker",
|
||||||
jobDiscoverySavedSearches: "Saved searches",
|
jobDiscoverySavedSearches: "Saved searches",
|
||||||
|
jobDiscoverySavedLoading: "Loading saved searches…",
|
||||||
|
jobDiscoverySavedLoadFailed: "Saved searches could not be loaded. Try again.",
|
||||||
jobDiscoverySavedSearchesHelp: "Saved NAV searches are checked automatically. You will be notified when new vacancies appear.",
|
jobDiscoverySavedSearchesHelp: "Saved NAV searches are checked automatically. You will be notified when new vacancies appear.",
|
||||||
jobDiscoveryAutomaticAlerts: "Automatic alerts",
|
jobDiscoveryAutomaticAlerts: "Automatic alerts",
|
||||||
jobDiscoveryUpdateAlertsFailed: "The alert setting could not be updated. Try again.",
|
jobDiscoveryUpdateAlertsFailed: "The alert setting could not be updated. Try again.",
|
||||||
@@ -2878,6 +2880,8 @@ export const translations = {
|
|||||||
jobDiscoveryViewListing: "Vis stilling",
|
jobDiscoveryViewListing: "Vis stilling",
|
||||||
jobDiscoverySaveTracker: "Lagre i oversikten",
|
jobDiscoverySaveTracker: "Lagre i oversikten",
|
||||||
jobDiscoverySavedSearches: "Lagrede søk",
|
jobDiscoverySavedSearches: "Lagrede søk",
|
||||||
|
jobDiscoverySavedLoading: "Laster lagrede søk…",
|
||||||
|
jobDiscoverySavedLoadFailed: "Lagrede søk kunne ikke lastes. Prøv igjen.",
|
||||||
jobDiscoverySavedSearchesHelp: "Lagrede NAV-søk kontrolleres automatisk. Du får et varsel når nye stillinger dukker opp.",
|
jobDiscoverySavedSearchesHelp: "Lagrede NAV-søk kontrolleres automatisk. Du får et varsel når nye stillinger dukker opp.",
|
||||||
jobDiscoveryAutomaticAlerts: "Automatiske varsler",
|
jobDiscoveryAutomaticAlerts: "Automatiske varsler",
|
||||||
jobDiscoveryUpdateAlertsFailed: "Varslingsinnstillingen kunne ikke oppdateres. Prøv igjen.",
|
jobDiscoveryUpdateAlertsFailed: "Varslingsinnstillingen kunne ikke oppdateres. Prøv igjen.",
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ test("renders the discovery workflow in Norwegian Bokmål", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("runs a saved search, marks unseen vacancies, and dismisses them", async () => {
|
test("runs a saved search, marks unseen vacancies, and dismisses them", async () => {
|
||||||
const saved = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", resultCount: 0, dismissedCount: 0 };
|
const saved = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 0, dismissedCount: 0 };
|
||||||
mockedApi.get.mockResolvedValue({ data: [saved] } as any);
|
mockedApi.get.mockResolvedValue({ data: [saved] } as any);
|
||||||
mockedApi.post.mockResolvedValue({ data: { search: { ...saved, resultCount: 1 }, jobs: [{ job: { id: "new-1", title: "Platform Engineer", url: "https://example.test/new-1", source: "nav", countryCode: "NO" }, isNew: true, isDismissed: false }] } } as any);
|
mockedApi.post.mockResolvedValue({ data: { search: { ...saved, resultCount: 1 }, jobs: [{ job: { id: "new-1", title: "Platform Engineer", url: "https://example.test/new-1", source: "nav", countryCode: "NO" }, isNew: true, isDismissed: false }] } } as any);
|
||||||
mockedApi.patch.mockResolvedValue({ data: null } as any);
|
mockedApi.patch.mockResolvedValue({ data: null } as any);
|
||||||
@@ -107,3 +107,51 @@ test("lets the user pause automatic alerts for a saved search", async () => {
|
|||||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7", { isActive: false }));
|
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7", { isActive: false }));
|
||||||
await waitFor(() => expect(toggle).not.toBeChecked());
|
await waitFor(() => expect(toggle).not.toBeChecked());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("announces saved-search loading without presenting a successful empty list", async () => {
|
||||||
|
let finish!: (value: any) => void;
|
||||||
|
mockedApi.get.mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||||
|
renderPage();
|
||||||
|
expect(screen.getByRole("status", { name: "Loading saved searches…" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No saved searches yet.")).not.toBeInTheDocument();
|
||||||
|
finish({ data: [] });
|
||||||
|
expect(await screen.findByText("No saved searches yet.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("status", { name: "Loading saved searches…" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports saved-search failure and retries that read without replacing active discovery filters or results", async () => {
|
||||||
|
let savedAttempts = 0;
|
||||||
|
mockedApi.get.mockImplementation((url) => {
|
||||||
|
if (String(url).endsWith("saved-searches")) {
|
||||||
|
savedAttempts++;
|
||||||
|
return savedAttempts === 1
|
||||||
|
? Promise.reject({ response: { status: 500, data: { detail: "Internal database stack trace" } } })
|
||||||
|
: Promise.resolve({ data: [{ id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 0, dismissedCount: 0 }] } as any);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: [{ id: "vacancy", title: "Existing result", url: "https://example.test/vacancy", source: "nav", countryCode: "NO" }] } as any);
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByText("Saved searches could not be loaded. Try again.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No saved searches yet.")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Save search" })).toBeDisabled();
|
||||||
|
expect(screen.queryByText(/Internal database stack trace/)).not.toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "developer" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: "Bergen" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
|
||||||
|
await screen.findByText("Existing result");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
expect(await screen.findByText("Backend Oslo")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Save search" })).toBeEnabled();
|
||||||
|
expect(screen.getByLabelText("Role or company")).toHaveValue("developer");
|
||||||
|
expect(screen.getByLabelText("Municipality")).toHaveValue("Bergen");
|
||||||
|
expect(screen.getByText("Existing result")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Saved searches could not be loaded. Try again.")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([null, { message: "not a list" }, [{ id: 7 }]])("rejects malformed saved-search payload %j as a failed read rather than an empty list", async (data) => {
|
||||||
|
mockedApi.get.mockResolvedValue({ data } as any);
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByText("Saved searches could not be loaded. Try again.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No saved searches yet.")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export default function JobDiscoveryPage() {
|
|||||||
const [lastSearch, setLastSearch] = useState({ query: "", location: "" });
|
const [lastSearch, setLastSearch] = useState({ query: "", location: "" });
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("updated");
|
const [sortOrder, setSortOrder] = useState<SortOrder>("updated");
|
||||||
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
|
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
|
||||||
|
const [savedSearchState, setSavedSearchState] = useState<"loading" | "loaded" | "error">("loading");
|
||||||
|
const [savedSearchLoad, setSavedSearchLoad] = useState(0);
|
||||||
const [savedName, setSavedName] = useState("");
|
const [savedName, setSavedName] = useState("");
|
||||||
const [savingSearch, setSavingSearch] = useState(false);
|
const [savingSearch, setSavingSearch] = useState(false);
|
||||||
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
|
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
|
||||||
@@ -51,11 +53,22 @@ export default function JobDiscoveryPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
setSavedSearchState("loading");
|
||||||
api.get<SavedSearch[]>("/job-discovery/saved-searches")
|
api.get<SavedSearch[]>("/job-discovery/saved-searches")
|
||||||
.then((response) => { if (active) setSavedSearches(Array.isArray(response.data) ? response.data : []); })
|
.then((response) => {
|
||||||
.catch(() => { /* One-off discovery remains usable if saved-search storage is unavailable. */ });
|
const rows = response.data;
|
||||||
|
if (!Array.isArray(rows) || !rows.every((row) => row && Number.isInteger(row.id) && row.id > 0
|
||||||
|
&& typeof row.name === "string" && typeof row.query === "string" && typeof row.location === "string"
|
||||||
|
&& typeof row.isActive === "boolean" && Number.isInteger(row.resultCount) && row.resultCount >= 0
|
||||||
|
&& Number.isInteger(row.dismissedCount) && row.dismissedCount >= 0
|
||||||
|
&& (row.lastRunAtUtc == null || typeof row.lastRunAtUtc === "string"))) {
|
||||||
|
throw new Error("Invalid saved-search response");
|
||||||
|
}
|
||||||
|
if (active) { setSavedSearches(rows); setSavedSearchState("loaded"); }
|
||||||
|
})
|
||||||
|
.catch(() => { if (active) setSavedSearchState("error"); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, []);
|
}, [savedSearchLoad]);
|
||||||
|
|
||||||
const runSearch = async (nextQuery: string, nextLocation: string) => {
|
const runSearch = async (nextQuery: string, nextLocation: string) => {
|
||||||
setLoading(true); setError(""); setJobs([]); setHasSearched(true); setLastSearch({ query: nextQuery, location: nextLocation });
|
setLoading(true); setError(""); setJobs([]); setHasSearched(true); setLastSearch({ query: nextQuery, location: nextLocation });
|
||||||
@@ -139,7 +152,7 @@ export default function JobDiscoveryPage() {
|
|||||||
<TextField label={t("jobDiscoveryMunicipality")} value={location} onChange={(event) => setLocation(event.target.value)} />
|
<TextField label={t("jobDiscoveryMunicipality")} value={location} onChange={(event) => setLocation(event.target.value)} />
|
||||||
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress size={18} color="inherit" /> : <SearchIcon />} disabled={loading}>{t("jobDiscoverySearchNav")}</Button>
|
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress size={18} color="inherit" /> : <SearchIcon />} disabled={loading}>{t("jobDiscoverySearchNav")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
<Card variant="outlined">
|
<Card variant="outlined" aria-busy={savedSearchState === "loading"}>
|
||||||
<CardContent sx={{ display: "grid", gap: 1.5 }}>
|
<CardContent sx={{ display: "grid", gap: 1.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography component="h2" variant="h6" sx={{ fontWeight: 800 }}>{t("jobDiscoverySavedSearches")}</Typography>
|
<Typography component="h2" variant="h6" sx={{ fontWeight: 800 }}>{t("jobDiscoverySavedSearches")}</Typography>
|
||||||
@@ -147,9 +160,13 @@ export default function JobDiscoveryPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "minmax(0, 1fr) auto" }, gap: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "minmax(0, 1fr) auto" }, gap: 1 }}>
|
||||||
<TextField size="small" label={t("jobDiscoverySavedName")} value={savedName} onChange={(event) => setSavedName(event.target.value)} />
|
<TextField size="small" label={t("jobDiscoverySavedName")} value={savedName} onChange={(event) => setSavedName(event.target.value)} />
|
||||||
<Button variant="outlined" startIcon={<BookmarkAddOutlinedIcon />} onClick={() => void saveSearch()} disabled={savingSearch}>{t("jobDiscoverySaveSearch")}</Button>
|
<Button variant="outlined" startIcon={<BookmarkAddOutlinedIcon />} onClick={() => void saveSearch()} disabled={savingSearch || savedSearchState !== "loaded"}>{t("jobDiscoverySaveSearch")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{savedSearches.length === 0 ? <Typography variant="body2" color="text.secondary">{t("jobDiscoveryNoSavedSearches")}</Typography> : (
|
{savedSearchState === "loading" ? <Typography role="status" aria-label={t("jobDiscoverySavedLoading")} variant="body2" color="text.secondary">{t("jobDiscoverySavedLoading")}</Typography> : savedSearchState === "error" ? (
|
||||||
|
<Alert severity="error" variant="outlined" action={<Button color="inherit" onClick={() => setSavedSearchLoad((current) => current + 1)}>{t("retry")}</Button>}>
|
||||||
|
{t("jobDiscoverySavedLoadFailed")}
|
||||||
|
</Alert>
|
||||||
|
) : savedSearches.length === 0 ? <Typography variant="body2" color="text.secondary">{t("jobDiscoveryNoSavedSearches")}</Typography> : (
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
{savedSearches.map((saved) => (
|
{savedSearches.map((saved) => (
|
||||||
<Box key={saved.id} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.25, border: 1, borderColor: activeSavedSearchId === saved.id ? "primary.main" : "divider", borderRadius: 2 }}>
|
<Box key={saved.id} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.25, border: 1, borderColor: activeSavedSearchId === saved.id ? "primary.main" : "divider", borderRadius: 2 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user