import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { api } from "./api"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations, type UiLanguage } from "./i18n/translations"; import type { JobApplication } from "./types"; import OfferComparisonPage from "./views/OfferComparisonPage"; jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() }, getApiErrorMessage: (_error: unknown, fallback: string) => fallback, })); const mockedApi = api as jest.Mocked; const request = { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "dateApplied", sortDir: "asc" } }; beforeEach(() => { window.localStorage.clear(); jest.clearAllMocks(); mockedApi.get.mockReset(); }); afterEach(() => jest.restoreAllMocks()); function copy(key: string, language: UiLanguage = "en", params: Record = {}) { const template = (translations[language] as Record)[key] ?? key; return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`)); } function offer(id: number, overrides: Partial = {}): JobApplication { return { id, jobTitle: `Offer role ${id}`, company: { id, name: `Company ${id}` }, status: "Offer", dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10, ...overrides, }; } function paged(items: JobApplication[], total = items.length) { return { data: { items, total, page: 1, pageSize: 25 } }; } function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } function RouteContext() { const location = useLocation(); return {location.pathname}{location.search}{location.hash}; } function renderPage(language: UiLanguage = "en") { window.localStorage.setItem("uiLanguage", language); return render(); } function changeLanguage(language: UiLanguage) { act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: language })); }); } function assertNoWrites() { expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); } test("compares recorded offer facts without filling missing values", async () => { mockedApi.get.mockResolvedValue({ data: { items: [ { id: 1, jobTitle: "Backend Engineer", company: { id: 1, name: "Acme" }, status: "Offer", salaryMin: 800000, salaryMax: 900000, salaryCurrency: "NOK", salaryPeriod: "year", location: "Oslo", deadline: "2026-09-10", nextAction: "Review contract", notes: "Hybrid three days", dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10 }, { id: 2, jobTitle: "Platform Engineer", company: { id: 2, name: "Beta" }, status: "Offer", dateApplied: "2026-08-02", savedAt: "2026-07-21", responseReceived: true, daysSince: 9 }, ], total: 2, page: 1, pageSize: 25 } } as any); renderPage(); expect(await screen.findByText("Backend Engineer")).toBeInTheDocument(); expect(screen.getByText(`800,000 – 900,000 NOK / ${translations.en.salaryPeriodYear}`)).toBeInTheDocument(); expect(screen.getByText("Hybrid three days")).toBeInTheDocument(); expect(screen.getAllByText("Not recorded").length).toBeGreaterThan(0); expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", request); assertNoWrites(); }); test("shows an honest empty state when there are no offers", async () => { mockedApi.get.mockResolvedValue({ data: { items: [], total: 0, page: 1, pageSize: 25 } } as any); renderPage(); expect(await screen.findByText("No active offers to compare")).toBeInTheDocument(); assertNoWrites(); }); test("shows the first 25 of the real total in server application-date order without inventing the remaining offers", async () => { const items = Array.from({ length: 25 }, (_, index) => offer(index + 1, { dateApplied: `2026-08-${String(index + 1).padStart(2, "0")}`, deadline: index === 0 ? "2026-12-01" : "2026-09-01", })); mockedApi.get.mockResolvedValue(paged(items, 61) as any); renderPage(); expect(await screen.findByText(copy("offerComparisonSampleCount", "en", { shown: 25, total: 61 }))).toBeInTheDocument(); expect(screen.getByText(copy("offerComparisonSampleHelp"))).toBeInTheDocument(); expect(screen.getAllByRole("article")).toHaveLength(25); expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(items.map((item) => item.jobTitle)); expect(screen.queryByText("Offer role 26")).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", request); assertNoWrites(); }); test("opens the full recorded offer source with the same supported filter and ordering", async () => { mockedApi.get.mockResolvedValue(paged([offer(7)], 42) as any); renderPage(); fireEvent.click(await screen.findByRole("button", { name: copy("offerComparisonAllOffers") })); const route = new URL(screen.getByLabelText("Current route").textContent!, "https://example.test"); expect(route.pathname).toBe("/jobs"); expect(Object.fromEntries(route.searchParams.entries())).toEqual({ status: "Offer", sortBy: "dateApplied", sortDir: "asc" }); expect(mockedApi.get).toHaveBeenCalledTimes(1); assertNoWrites(); }); test("distinguishes held and failed reads from empty results and recovers through one explicit identical retry", async () => { const initial = deferred>(); const retry = deferred>(); mockedApi.get.mockImplementationOnce(() => initial.promise as any).mockImplementationOnce(() => retry.promise as any); renderPage(); expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(1); await act(async () => { initial.reject({ response: { status: 503 } }); }); expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.offerComparisonLoadFailed); expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: translations.en.retry })); expect(screen.queryByRole("button", { name: translations.en.retry })).not.toBeInTheDocument(); expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.get).toHaveBeenNthCalledWith(1, "/jobapplications", request); expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications", request); await act(async () => { retry.resolve(paged([offer(91)])); }); expect(await screen.findByRole("article", { name: "Offer role 91" })).toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(2); assertNoWrites(); }); test("a live locale change translates loaded offer chrome and preserves recorded facts without another GET", async () => { const notes = "Recorded English notes stay unchanged — æøå"; mockedApi.get.mockResolvedValue(paged([offer(8, { jobTitle: "English source title", notes, salary: "GBP package as recorded" })]) as any); renderPage(); expect(await screen.findByText(notes)).toBeInTheDocument(); changeLanguage("nb"); expect(screen.getByRole("heading", { level: 2, name: translations.nb.offerComparisonTitle })).toBeInTheDocument(); const article = screen.getByRole("article", { name: "English source title" }); expect(within(article).getByText(notes)).toBeInTheDocument(); expect(within(article).getByText("GBP package as recorded")).toBeInTheDocument(); expect(within(article).getByText(translations.nb.statusOffer)).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(1); assertNoWrites(); }); test("an existing read failure translates without refetching and Retry retains the same query", async () => { mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce(paged([offer(9)]) as any); renderPage(); expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.offerComparisonLoadFailed); changeLanguage("nb"); expect(screen.getByRole("alert")).toHaveTextContent(translations.nb.offerComparisonLoadFailed); expect(mockedApi.get).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: translations.nb.retry })); expect(await screen.findByRole("article", { name: "Offer role 9" })).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications", request); expect(mockedApi.get).toHaveBeenCalledTimes(2); assertNoWrites(); }); test("structured salaries follow UI locale rather than host locale while preserving zero, bounds, currency, unknown periods and free text", async () => { const originalLocaleString = Number.prototype.toLocaleString; jest.spyOn(Number.prototype, "toLocaleString").mockImplementation(function ( this: number, locales?: Parameters[0], options?: Parameters[1], ) { return originalLocaleString.call(this, locales ?? "de-DE", options); }); const rawSalary = " Negotiable — no numeric estimate "; const items = [ offer(11, { salaryMin: 0, salaryCurrency: "NOK", salaryPeriod: "year" }), offer(12, { salaryMin: 850000, salaryCurrency: "NOK", salaryPeriod: "month" }), offer(13, { salaryMax: 900000, salaryCurrency: "USD", salaryPeriod: "hour" }), offer(14, { salaryMin: 800000, salaryMax: 800000, salaryPeriod: "fortnight" }), offer(15, { salary: rawSalary }), offer(16, { salaryMin: 800000, salaryMax: 900000, salaryCurrency: "SEK" }), offer(17, { salaryMin: 700000, salaryCurrency: "NOK", salaryPeriod: "constructor" }), ]; mockedApi.get.mockResolvedValue(paged(items) as any); renderPage(); await screen.findByRole("article", { name: items[0].jobTitle }); const salaryText = (id: number) => { const article = screen.getByRole("article", { name: `Offer role ${id}` }); const label = within(article).getByText(copy("offerComparisonCompensation", document.documentElement.lang === "nb-NO" ? "nb" : "en")); return label.parentElement!.textContent!.replace(label.textContent!, ""); }; const normalizeSpaces = (value: string) => value.replace(/\s+/g, " ").trim(); for (const language of ["en", "nb"] as const) { if (language === "nb") changeLanguage(language); const fmt = (value: number) => new Intl.NumberFormat(language === "nb" ? "nb-NO" : "en").format(value); expect(normalizeSpaces(salaryText(11))).toBe(`0 NOK / ${translations[language].salaryPeriodYear}`); expect(normalizeSpaces(salaryText(12))).toBe(normalizeSpaces(`${fmt(850000)} NOK / ${translations[language].salaryPeriodMonth}`)); expect(normalizeSpaces(salaryText(13))).toBe(normalizeSpaces(`${fmt(900000)} USD / ${translations[language].salaryPeriodHour}`)); expect(normalizeSpaces(salaryText(14))).toBe(normalizeSpaces(`${fmt(800000)} – ${fmt(800000)} / fortnight`)); expect(salaryText(15)).toBe(rawSalary); expect(normalizeSpaces(salaryText(16))).toBe(normalizeSpaces(`${fmt(800000)} – ${fmt(900000)} SEK`)); expect(normalizeSpaces(salaryText(17))).toBe(normalizeSpaces(`${fmt(700000)} NOK / constructor`)); } expect(mockedApi.get).toHaveBeenCalledTimes(1); assertNoWrites(); }); test("uses recorded applied/deadline/contact dates, keeps absent applied dates unknown, and opens the exact current application", async () => { const item = offer(402, { jobTitle: "Recorded role", dateApplied: "2026-08-01", deadline: "2026-09-10", followUpAt: "2026-09-12" }); const missing = offer(407, { jobTitle: "No applied date", dateApplied: null, savedAt: "2026-07-20" }); mockedApi.get.mockResolvedValue(paged([item, missing]) as any); renderPage("nb"); const article = await screen.findByRole("article", { name: item.jobTitle }); const fact = (label: string) => within(article).getByText(label).parentElement!; const date = (value: string) => new Date(value).toLocaleDateString("nb-NO"); expect(fact(translations.nb.jobTableDateApplied)).toHaveTextContent(date(item.dateApplied!)); expect(fact(translations.nb.offerComparisonDecisionDeadline)).toHaveTextContent(date(item.deadline!)); expect(fact(translations.nb.companiesNextContact)).toHaveTextContent(date(item.followUpAt!)); expect(within(article).getByText(translations.nb.statusOffer)).toBeInTheDocument(); const missingArticle = screen.getByRole("article", { name: missing.jobTitle }); expect(within(missingArticle).getByText(translations.nb.jobTableDateApplied).parentElement).toHaveTextContent("—"); expect(within(missingArticle).queryByText(date(missing.savedAt))).not.toBeInTheDocument(); fireEvent.click(within(article).getByRole("button", { name: translations.nb.workspaceOpenFull })); await waitFor(() => expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/jobs\/402$/)); expect(mockedApi.get).toHaveBeenCalledTimes(1); assertNoWrites(); });