Files
jobtrackingapp/job-tracker-ui/src/job-discovery.test.tsx
2026-10-13 00:49:24 +02:00

356 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { api } from "./api";
import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
import { I18nProvider } from "./i18n/I18nProvider";
import JobDiscoveryPage from "./views/JobDiscoveryPage";
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn() } }));
const mockedApi = api as jest.Mocked<typeof api>;
function renderPage() {
return render(<I18nProvider><ConfirmProvider><PromptProvider><MemoryRouter><JobDiscoveryPage /></MemoryRouter></PromptProvider></ConfirmProvider></I18nProvider>);
}
function searchResponse(data: any[]) {
mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("saved-searches") ? [] : data } as any));
}
beforeEach(() => { jest.resetAllMocks(); window.localStorage.clear(); searchResponse([]); });
afterEach(() => jest.clearAllMocks());
test("shows honest source metadata and offers the existing reviewed save flow", async () => {
searchResponse([{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }]);
renderPage();
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "backend" } });
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
expect(await screen.findByText("Backend Developer")).toBeInTheDocument();
expect(screen.getByText("NAV Arbeidsplassen")).toBeInTheDocument();
expect(screen.getByText(/Searched listing · Retrieved/)).toBeInTheDocument();
expect(screen.getByText(/Application deadline:/)).toBeInTheDocument();
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/job-discovery/search", { params: { q: "backend", location: undefined } }));
expect(screen.getByRole("button", { name: "Save to tracker" })).toBeInTheDocument();
});
test("distinguishes the initial guidance from a completed empty search", async () => {
renderPage();
expect(screen.getByText("Search recent active vacancies by role, company, or municipality.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
expect(await screen.findByText(/No active vacancies matched this search/)).toBeInTheDocument();
expect(screen.queryByText("Search recent active vacancies by role, company, or municipality.")).not.toBeInTheDocument();
});
test("retries the last submitted search after an error", async () => {
let searchAttempts = 0;
mockedApi.get.mockImplementation((url) => {
if (String(url).endsWith("saved-searches")) return Promise.resolve({ data: [] } as any);
searchAttempts += 1;
return searchAttempts === 1 ? Promise.reject(new Error("offline")) : Promise.resolve({ data: [] } as any);
});
renderPage();
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " utvikler " } });
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: " Oslo " } });
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
fireEvent.click(await screen.findByRole("button", { name: "Retry" }));
await waitFor(() => expect(searchAttempts).toBe(2));
expect(mockedApi.get).toHaveBeenLastCalledWith("/job-discovery/search", { params: { q: "utvikler", location: "Oslo" } });
});
test("sorts returned vacancies by title", async () => {
searchResponse([
{ id: "2", title: "Zoologist", url: "https://arbeidsplassen.nav.no/2", source: "nav", countryCode: "NO" },
{ id: "1", title: "Analyst", url: "https://arbeidsplassen.nav.no/1", source: "nav", countryCode: "NO" },
]);
renderPage();
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
await screen.findByText("2 vacancies found");
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Sort results" }));
fireEvent.click(await screen.findByRole("option", { name: "Title A–Z" }));
expect(screen.getAllByRole("heading", { level: 3 }).map((element) => element.textContent)).toEqual(["Analyst", "Zoologist"]);
});
test("renders the discovery workflow in Norwegian Bokmål", async () => {
window.localStorage.setItem("uiLanguage", "nb");
renderPage();
expect(screen.getByLabelText("Rolle eller bedrift")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Søk i NAV" }));
expect(await screen.findByText(/Ingen aktive stillinger samsvarte/)).toBeInTheDocument();
});
test("runs a saved search, marks unseen vacancies, and dismisses them", async () => {
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);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
expect(await screen.findByText("Platform Engineer")).toBeInTheDocument();
expect(screen.getByText("New")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
await waitFor(() => expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument());
expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7/results/new-1/dismiss", { isDismissed: true });
});
test("lets the user pause automatic alerts for a saved search", async () => {
const saved = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 1, dismissedCount: 0 };
mockedApi.get.mockResolvedValue({ data: [saved] } as any);
mockedApi.patch.mockResolvedValue({ data: { ...saved, isActive: false } } as any);
renderPage();
const toggle = await screen.findByRole("switch", { name: "Automatic alerts" });
expect(toggle).toBeChecked();
fireEvent.click(toggle);
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();
});
const savedSearch = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 1, dismissedCount: 0 };
const discoveredJob = { id: "new-1", title: "Platform Engineer", url: "https://example.test/new-1", source: "nav", countryCode: "NO" };
function savedRun(saved = savedSearch, job = discoveredJob) {
return { data: { search: saved, jobs: [{ job, isNew: true, isDismissed: false }] } };
}
function savedFixture(saved = [savedSearch], jobs: any[] = []) {
mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("saved-searches") ? saved : jobs } as any));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test("a manual search after saved-search results has no dismiss action belonging to the saved search", async () => {
savedFixture([savedSearch], [{ ...discoveredJob, id: "manual-1", title: "Manual vacancy" }]);
mockedApi.post.mockResolvedValueOnce(savedRun() as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
await screen.findByText("Platform Engineer");
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "manual query" } });
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
await screen.findByText("Manual vacancy");
expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith("/job-discovery/search", { params: { q: "manual query", location: "Oslo" } });
expect(mockedApi.patch).not.toHaveBeenCalled();
});
test("starting saved search B hides saved search A's results and actions while the B response is held", async () => {
const second = { ...savedSearch, id: 8, name: "Data Bergen", query: "data", location: "Bergen" };
const read = deferred<any>();
savedFixture([savedSearch, second]);
mockedApi.post.mockResolvedValueOnce(savedRun() as any).mockImplementationOnce(() => read.promise);
renderPage();
fireEvent.click((await screen.findAllByRole("button", { name: "Check for new jobs" }))[0]);
await screen.findByText("Platform Engineer");
fireEvent.click(screen.getAllByRole("button", { name: "Check for new jobs" })[1]);
expect(mockedApi.post).toHaveBeenLastCalledWith("/job-discovery/saved-searches/8/run");
expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
expect(screen.getByLabelText("Role or company")).toHaveValue("data");
expect(screen.getByLabelText("Municipality")).toHaveValue("Bergen");
expect(screen.getAllByRole("button", { name: "Check for new jobs" }).every((button) => (button as HTMLButtonElement).disabled)).toBe(true);
await act(async () => { read.resolve(savedRun(second, { ...discoveredJob, id: "b-1", title: "Data Engineer" })); });
expect(await screen.findByText("Data Engineer")).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledTimes(2);
expect(mockedApi.patch).not.toHaveBeenCalled();
});
test("retrying a failed saved-search run repeats that saved ID instead of running a manual query", async () => {
const retry = deferred<any>();
savedFixture();
mockedApi.post.mockRejectedValueOnce(new Error("synthetic NAV unavailable")).mockImplementationOnce(() => retry.promise);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
const retryButton = await screen.findByRole("button", { name: "Retry" });
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "unsubmitted edited query" } });
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: "unsubmitted Bergen" } });
fireEvent.click(retryButton);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2));
expect(mockedApi.post.mock.calls.map(([url]) => url)).toEqual(["/job-discovery/saved-searches/7/run", "/job-discovery/saved-searches/7/run"]);
expect(mockedApi.get.mock.calls.some(([url]) => url === "/job-discovery/search")).toBe(false);
expect(screen.getByLabelText("Role or company")).toHaveValue("unsubmitted edited query");
expect(screen.getByLabelText("Municipality")).toHaveValue("unsubmitted Bergen");
expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
await act(async () => { retry.resolve(savedRun()); });
expect(await screen.findByText("Platform Engineer")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
expect(screen.getByLabelText("Role or company")).toHaveValue("unsubmitted edited query");
expect(screen.getByLabelText("Municipality")).toHaveValue("unsubmitted Bergen");
});
test("failed saved-search creation retains its draft and recovers only through the save action", async () => {
mockedApi.post.mockRejectedValueOnce(new Error("synthetic save failure")).mockResolvedValueOnce({ data: { ...savedSearch, name: "My search", query: "developer", location: "Bergen" } } as any);
renderPage();
await screen.findByText("No saved searches yet.");
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "developer" } });
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: "Bergen" } });
fireEvent.change(screen.getByLabelText("Search name"), { target: { value: "My search" } });
fireEvent.click(screen.getByRole("button", { name: "Save search" }));
expect(await screen.findByText("The search could not be saved.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
expect(screen.getByLabelText("Search name")).toHaveValue("My search");
expect(screen.getByLabelText("Role or company")).toHaveValue("developer");
expect(screen.getByLabelText("Municipality")).toHaveValue("Bergen");
expect(mockedApi.post).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Save search" }));
expect(await screen.findByText("My search")).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledTimes(2);
expect(mockedApi.post).toHaveBeenLastCalledWith("/job-discovery/saved-searches", { name: "My search", query: "developer", location: "Bergen" });
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("a failed alert toggle retains its checked state and recovers through that same toggle without manual search retry", async () => {
savedFixture();
mockedApi.patch.mockRejectedValueOnce(new Error("synthetic alerts failure")).mockResolvedValueOnce({ data: { ...savedSearch, isActive: false } } as any);
renderPage();
const toggle = await screen.findByRole("switch", { name: "Automatic alerts" });
fireEvent.click(toggle);
expect(await screen.findByText("The alert setting could not be updated. Try again.")).toBeInTheDocument();
expect(toggle).toBeChecked();
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
fireEvent.click(toggle);
await waitFor(() => expect(toggle).not.toBeChecked());
expect(mockedApi.patch).toHaveBeenCalledTimes(2);
expect(mockedApi.patch).toHaveBeenLastCalledWith("/job-discovery/saved-searches/7", { isActive: false });
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("failed saved-search deletion preserves the search and requires another explicit confirmation to recover", async () => {
savedFixture();
mockedApi.delete.mockRejectedValueOnce(new Error("synthetic delete failure")).mockResolvedValueOnce({ data: null } as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
let dialog = await screen.findByRole("dialog", { name: "Delete saved search?" });
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
expect(await screen.findByText("The saved search could not be deleted.")).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByText("Backend Oslo")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
expect(mockedApi.delete).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
dialog = await screen.findByRole("dialog", { name: "Delete saved search?" });
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(screen.queryByText("Backend Oslo")).not.toBeInTheDocument());
expect(mockedApi.delete).toHaveBeenCalledTimes(2);
expect(mockedApi.delete).toHaveBeenLastCalledWith("/job-discovery/saved-searches/7");
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("failed dismissal retains the vacancy and recovers only through that same result's dismiss action", async () => {
savedFixture();
mockedApi.post.mockResolvedValueOnce(savedRun() as any);
mockedApi.patch.mockRejectedValueOnce(new Error("synthetic dismiss failure")).mockResolvedValueOnce({ data: null } as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
await screen.findByText("Platform Engineer");
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(await screen.findByText("The vacancy could not be dismissed.")).toBeInTheDocument();
expect(screen.getByText("Platform Engineer")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
await waitFor(() => expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument());
expect(mockedApi.patch).toHaveBeenCalledTimes(2);
expect(mockedApi.patch).toHaveBeenLastCalledWith("/job-discovery/saved-searches/7/results/new-1/dismiss", { isDismissed: true });
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("a held saved-search creation locks its request snapshot and prevents a second start while preserving a failed draft", async () => {
const write = deferred<any>();
mockedApi.post.mockImplementationOnce(() => write.promise);
renderPage();
await screen.findByText("No saved searches yet.");
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " developer " } });
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: " Bergen " } });
fireEvent.change(screen.getByLabelText("Search name"), { target: { value: " Review my search " } });
const save = screen.getByRole("button", { name: "Save search" });
fireEvent.click(save);
expect(mockedApi.post).toHaveBeenCalledWith("/job-discovery/saved-searches", { name: "Review my search", query: "developer", location: "Bergen" });
expect(save).toBeDisabled();
expect(screen.getByLabelText("Role or company")).toBeDisabled();
expect(screen.getByLabelText("Municipality")).toBeDisabled();
expect(screen.getByLabelText("Search name")).toBeDisabled();
fireEvent.click(save);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
await act(async () => { write.reject(new Error("synthetic save failure")); });
expect(await screen.findByText("The search could not be saved.")).toBeInTheDocument();
expect(screen.getByLabelText("Role or company")).toHaveValue(" developer ");
expect(screen.getByLabelText("Municipality")).toHaveValue(" Bergen ");
expect(screen.getByLabelText("Search name")).toHaveValue(" Review my search ");
expect(save).toBeEnabled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
test("a saved-run failure follows the current language without refetching or replaying the request", async () => {
savedFixture();
mockedApi.post.mockRejectedValueOnce(new Error("synthetic NAV unavailable"));
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
expect(await screen.findByText("NAV job discovery is temporarily unavailable.")).toBeInTheDocument();
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
expect(screen.getByText("NAVs jobbsøk er midlertidig utilgjengelig.")).toBeInTheDocument();
expect(screen.queryByText("NAV job discovery is temporarily unavailable.")).not.toBeInTheDocument();
expect(screen.getByLabelText("Rolle eller bedrift")).toHaveValue("backend");
expect(screen.getByLabelText("Kommune")).toHaveValue("Oslo");
expect(mockedApi.get).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.patch).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});