Files
jobtrackingapp/job-tracker-ui/src/offer-comparison.test.tsx
T
2026-10-13 13:19:53 +02:00

238 lines
13 KiB
TypeScript
Raw 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, 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<typeof api>;
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<string, number> = {}) {
const template = (translations[language] as Record<string, string>)[key] ?? key;
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
}
function offer(id: number, overrides: Partial<JobApplication> = {}): 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<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 };
}
function RouteContext() {
const location = useLocation();
return <output aria-label="Current route">{location.pathname}{location.search}{location.hash}</output>;
}
function renderPage(language: UiLanguage = "en") {
window.localStorage.setItem("uiLanguage", language);
return render(<I18nProvider><MemoryRouter initialEntries={["/offers"]}><RouteContext /><OfferComparisonPage /></MemoryRouter></I18nProvider>);
}
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<ReturnType<typeof paged>>();
const retry = deferred<ReturnType<typeof paged>>();
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<typeof Number.prototype.toLocaleString>[0],
options?: Parameters<typeof Number.prototype.toLocaleString>[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();
});