From 2753d03db643c4b90b12c62748b0e844981d48b8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 9 Oct 2026 15:01:13 +0200 Subject: [PATCH] fix(discovery): expose saved-search load failures and retry --- .../e2e/saved-search-repair.spec.ts | 303 ++++++++++++++++++ job-tracker-ui/src/i18n/translations.ts | 4 + job-tracker-ui/src/job-discovery.test.tsx | 50 ++- job-tracker-ui/src/views/JobDiscoveryPage.tsx | 29 +- 4 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 job-tracker-ui/e2e/saved-search-repair.spec.ts diff --git a/job-tracker-ui/e2e/saved-search-repair.spec.ts b/job-tracker-ui/e2e/saved-search-repair.spec.ts new file mode 100644 index 0000000..44ec1a5 --- /dev/null +++ b/job-tracker-ui/e2e/saved-search-repair.spec.ts @@ -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; 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' }); +}); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 12fb82b..47b0e26 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -300,6 +300,8 @@ export const translations = { jobDiscoveryViewListing: "View listing", jobDiscoverySaveTracker: "Save to tracker", 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.", jobDiscoveryAutomaticAlerts: "Automatic alerts", jobDiscoveryUpdateAlertsFailed: "The alert setting could not be updated. Try again.", @@ -2878,6 +2880,8 @@ export const translations = { jobDiscoveryViewListing: "Vis stilling", jobDiscoverySaveTracker: "Lagre i oversikten", 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.", jobDiscoveryAutomaticAlerts: "Automatiske varsler", jobDiscoveryUpdateAlertsFailed: "Varslingsinnstillingen kunne ikke oppdateres. Prøv igjen.", diff --git a/job-tracker-ui/src/job-discovery.test.tsx b/job-tracker-ui/src/job-discovery.test.tsx index 55cb6d6..92bc333 100644 --- a/job-tracker-ui/src/job-discovery.test.tsx +++ b/job-tracker-ui/src/job-discovery.test.tsx @@ -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 () => { - 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.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); @@ -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(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(); +}); diff --git a/job-tracker-ui/src/views/JobDiscoveryPage.tsx b/job-tracker-ui/src/views/JobDiscoveryPage.tsx index bacc411..1bfc8a0 100644 --- a/job-tracker-ui/src/views/JobDiscoveryPage.tsx +++ b/job-tracker-ui/src/views/JobDiscoveryPage.tsx @@ -44,6 +44,8 @@ export default function JobDiscoveryPage() { const [lastSearch, setLastSearch] = useState({ query: "", location: "" }); const [sortOrder, setSortOrder] = useState("updated"); const [savedSearches, setSavedSearches] = useState([]); + const [savedSearchState, setSavedSearchState] = useState<"loading" | "loaded" | "error">("loading"); + const [savedSearchLoad, setSavedSearchLoad] = useState(0); const [savedName, setSavedName] = useState(""); const [savingSearch, setSavingSearch] = useState(false); const [activeSavedSearchId, setActiveSavedSearchId] = useState(null); @@ -51,11 +53,22 @@ export default function JobDiscoveryPage() { useEffect(() => { let active = true; + setSavedSearchState("loading"); api.get("/job-discovery/saved-searches") - .then((response) => { if (active) setSavedSearches(Array.isArray(response.data) ? response.data : []); }) - .catch(() => { /* One-off discovery remains usable if saved-search storage is unavailable. */ }); + .then((response) => { + 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; }; - }, []); + }, [savedSearchLoad]); const runSearch = async (nextQuery: string, nextLocation: string) => { setLoading(true); setError(""); setJobs([]); setHasSearched(true); setLastSearch({ query: nextQuery, location: nextLocation }); @@ -139,7 +152,7 @@ export default function JobDiscoveryPage() { setLocation(event.target.value)} /> - + {t("jobDiscoverySavedSearches")} @@ -147,9 +160,13 @@ export default function JobDiscoveryPage() { setSavedName(event.target.value)} /> - + - {savedSearches.length === 0 ? {t("jobDiscoveryNoSavedSearches")} : ( + {savedSearchState === "loading" ? {t("jobDiscoverySavedLoading")} : savedSearchState === "error" ? ( + setSavedSearchLoad((current) => current + 1)}>{t("retry")}}> + {t("jobDiscoverySavedLoadFailed")} + + ) : savedSearches.length === 0 ? {t("jobDiscoveryNoSavedSearches")} : ( {savedSearches.map((saved) => (