238 lines
13 KiB
TypeScript
238 lines
13 KiB
TypeScript
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();
|
||
});
|