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; 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 { 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) { 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 { 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(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' }); });