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 RemindersView from "./components/RemindersView"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations, type UiLanguage } from "./i18n/translations"; import { ToastProvider } from "./toast"; import type { JobApplication, WorkflowSignal } from "./types"; jest.mock("./api", () => ({ api: { get: jest.fn(), patch: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() }, getApiErrorMessage: (_error: unknown, fallback: string) => fallback, })); const mockedApi = api as jest.Mocked; const readRequest = { params: { upcomingDays: 14 } }; const now = Date.parse("2026-11-15T23:30:00Z"); beforeEach(() => { window.localStorage.clear(); document.documentElement.lang = "en"; jest.clearAllMocks(); mockedApi.get.mockReset(); mockedApi.patch.mockReset(); }); afterEach(() => jest.restoreAllMocks()); function language(): UiLanguage { return document.documentElement.lang === "nb-NO" ? "nb" : "en"; } function copy(key: string, params: Record = {}, locale: UiLanguage = language()) { const template = (translations[locale] as Record)[key] ?? key; return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`)); } function signal(actionKey: WorkflowSignal["actionKey"], workspaceTab: WorkflowSignal["workspaceTab"], reason: string): WorkflowSignal { return { actionKey, workspaceTab, reason, needsAttention: true, hasPackageGap: actionKey === "package-work", needsInterviewPrep: actionKey === "interview-prep", needsFollowUpAction: actionKey === "follow-up", hasTailoredCv: true, hasSavedApplicationAnswerDraft: true, hasInterviewPrepNotes: true, }; } function job(id: number, overrides: Partial = {}): JobApplication { return { id, jobTitle: `Reminder role ${id}`, company: { id, name: `Company ${id}` }, status: "Waiting", dateApplied: "2026-11-01", savedAt: "2026-10-20", responseReceived: false, daysSince: 14, needsFollowUp: true, followUpAt: "2026-11-17", followUpReason: "Legacy source reason", workflowSignal: signal("follow-up", "follow-up", "Recorded workflow reason"), ...overrides, }; } 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}; } function renderPage() { return render( ); } function changeLanguage(locale: UiLanguage) { act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: locale })); }); } // Mutation contracts also run against the legacy row presentation, so RED reaches // duplicate-write/recovery behavior instead of stopping at the new article name. // The first case separately requires every migrated article and action name. function card(title: string): HTMLElement { const article = screen.queryByRole("article", { name: title }); if (article) return article; const label = screen.getByText(new RegExp(title)); return label.closest(".MuiPaper-root")!; } function action(title: string, days: number | null): HTMLElement { const name = days === null ? copy("remindersClearForJob", { job: title }) : copy("remindersSnoozeForJob", { days, job: title }); return within(card(title)).queryByRole("button", { name }) ?? within(card(title)).getByRole("button", { name: days === null ? copy("remindersClear") : `+${days}d` }); } function region() { return screen.getByRole("region", { name: copy("remindersTitle") }); } function feedbackScope(): HTMLElement { return screen.queryByRole("region", { name: copy("remindersTitle") }) ?? screen.getByText(copy("remindersTitle")).closest(".MuiPaper-root")!; } function assertNoOtherWrites() { expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); } test("preserves all four server-signal groups, raw reasons, contextual destinations and legacy fallback", async () => { const items = [ job(11, { workflowSignal: signal("package-work", "tailored-cv", "Source CV reason"), tailoredCvText: "Existing text is not a client grouping rule" }), job(22, { status: "Interview", workflowSignal: signal("interview-prep", "interview-prep", "Source interview reason") }), job(33, { workflowSignal: { ...signal("follow-up", "follow-up", "Signal wins over legacy reason"), followMode: "waiting-update" }, followUpReason: "Tailored CV missing" }), job(44, { workflowSignal: signal("review-readiness", "readiness", "Source readiness reason") }), job(55, { status: "Custom hiring stage", workflowSignal: undefined, followUpReason: "Raw legacy reason — æøå" }), ]; mockedApi.get.mockResolvedValue({ data: items } as any); renderPage(); expect(await screen.findByRole("heading", { level: 2, name: copy("remindersTitle") })).toBeInTheDocument(); await screen.findByRole("article", { name: items[0].jobTitle }); expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual([ copy("remindersMissingTailoredCv"), copy("remindersMissingInterviewPrep"), copy("remindersFollowUpDue"), copy("remindersOther"), ]); const rowHeadings = screen.getAllByRole("heading", { level: 4 }); expect(rowHeadings).toHaveLength(items.length); items.forEach((item, index) => expect(rowHeadings[index]).toHaveTextContent(item.jobTitle)); expect(screen.getAllByRole("article")).toHaveLength(items.length); const paths = ["/jobs/11?section=cv", "/jobs/22?section=interview", "/jobs/33?section=communication&followMode=waiting-update", "/jobs/44?section=checklist", "/jobs/55"]; const labels = ["jobTablePackageWork", "jobTableInterviewStage", "jobTableFollowUp", "jobTableReadiness", "remindersOpen"]; items.forEach((item, index) => { const row = screen.getByRole("article", { name: item.jobTitle }); expect(within(row).getByText(item.workflowSignal?.reason ?? item.followUpReason!)).toBeInTheDocument(); for (const days of [3, 7]) expect(within(row).getByRole("button", { name: copy("remindersSnoozeForJob", { days, job: item.jobTitle }) })).toBeEnabled(); expect(within(row).getByRole("button", { name: copy("remindersClearForJob", { job: item.jobTitle }) })).toBeEnabled(); fireEvent.click(within(row).getByRole("button", { name: new RegExp(copy(labels[index]), "i") })); expect(screen.getByLabelText("Current route").textContent).toBe(paths[index]); }); expect(screen.queryByText("Tailored CV missing")).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications/reminders", readRequest); expect(mockedApi.patch).not.toHaveBeenCalled(); assertNoOtherWrites(); }); test.each([ { days: 3, expectedDate: "2026-11-18" }, { days: 7, expectedDate: "2026-11-22" }, { days: null, expectedDate: null }, ])("a held $days-day action submits one snapshot and locks every row action through refresh", async ({ days, expectedDate }) => { jest.spyOn(Date, "now").mockReturnValue(now); const target = job(71); const other = job(72); const write = deferred(); const refresh = deferred(); mockedApi.get.mockResolvedValueOnce({ data: [target, other] } as any).mockImplementationOnce(() => refresh.promise); mockedApi.patch.mockImplementation(() => write.promise); renderPage(); await screen.findByText(new RegExp(target.jobTitle)); const intended = action(target.jobTitle, days); const competing = action(other.jobTitle, days === 3 ? 7 : 3); const clear = action(target.jobTitle, null); intended.focus(); act(() => { fireEvent.click(intended); fireEvent.click(competing); fireEvent.click(clear); }); expect(mockedApi.patch).toHaveBeenCalledTimes(1); expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/71/followup", { followUpAt: expectedDate }); for (const item of [target, other]) within(card(item.jobTitle)).getAllByRole("button").forEach((button) => expect(button).toBeDisabled()); expect(region()).toHaveFocus(); await act(async () => { write.resolve({ status: 204 }); }); expect(mockedApi.get).toHaveBeenCalledTimes(2); for (const item of [target, other]) within(card(item.jobTitle)).getAllByRole("button").forEach((button) => expect(button).toBeDisabled()); await act(async () => { refresh.resolve({ data: [target, other] }); }); await waitFor(() => expect(action(target.jobTitle, days)).toBeEnabled()); expect(mockedApi.patch).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications/reminders", readRequest); assertNoOtherWrites(); }); test("a failed PATCH retains the row and durable localized error; Retry reuses its captured date while a fresh action uses the new clock", async () => { const clock = jest.spyOn(Date, "now").mockReturnValue(now); const target = job(81); mockedApi.get.mockResolvedValue({ data: [target] } as any); mockedApi.patch.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValue({ status: 204 } as any); renderPage(); await screen.findByText(new RegExp(target.jobTitle)); fireEvent.click(action(target.jobTitle, 3)); await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(1)); expect(await within(feedbackScope()).findByRole("alert")).toHaveTextContent(copy("remindersMutationFailedForJob", { job: target.jobTitle })); expect(card(target.jobTitle)).toBeVisible(); expect(mockedApi.get).toHaveBeenCalledTimes(1); changeLanguage("nb"); expect(within(feedbackScope()).getByRole("alert")).toHaveTextContent(copy("remindersMutationFailedForJob", { job: target.jobTitle })); expect(mockedApi.get).toHaveBeenCalledTimes(1); clock.mockReturnValue(Date.parse("2026-11-25T23:30:00Z")); fireEvent.click(within(feedbackScope()).getByRole("button", { name: copy("retry") })); await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(2)); expect(mockedApi.patch).toHaveBeenNthCalledWith(1, "/jobapplications/81/followup", { followUpAt: "2026-11-18" }); expect(mockedApi.patch).toHaveBeenNthCalledWith(2, "/jobapplications/81/followup", { followUpAt: "2026-11-18" }); await waitFor(() => expect(within(feedbackScope()).queryByRole("alert")).not.toBeInTheDocument()); expect(mockedApi.get).toHaveBeenCalledTimes(2); fireEvent.click(action(target.jobTitle, 3)); await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(3)); expect(mockedApi.patch).toHaveBeenNthCalledWith(3, "/jobapplications/81/followup", { followUpAt: "2026-11-28" }); assertNoOtherWrites(); }); test("after PATCH 204 and failed refresh, Retry reads only reminders and never repeats the event-producing PATCH", async () => { const target = job(91); mockedApi.get.mockResolvedValueOnce({ data: [target] } as any) .mockRejectedValueOnce({ response: { status: 503 } }) .mockResolvedValueOnce({ data: [target] } as any); mockedApi.patch.mockResolvedValue({ status: 204 } as any); renderPage(); await screen.findByText(new RegExp(target.jobTitle)); fireEvent.click(action(target.jobTitle, null)); expect(await within(feedbackScope()).findByRole("alert")).toHaveTextContent(copy("remindersLoadFailed")); expect(within(feedbackScope()).queryByText(copy("remindersMutationFailedForJob", { job: target.jobTitle }))).not.toBeInTheDocument(); expect(mockedApi.patch).toHaveBeenCalledTimes(1); expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/91/followup", { followUpAt: null }); expect(mockedApi.get).toHaveBeenCalledTimes(2); fireEvent.click(within(feedbackScope()).getByRole("button", { name: copy("retry") })); await screen.findByText(new RegExp(target.jobTitle)); expect(mockedApi.get).toHaveBeenCalledTimes(3); expect(mockedApi.get).toHaveBeenNthCalledWith(3, "/jobapplications/reminders", readRequest); expect(mockedApi.patch).toHaveBeenCalledTimes(1); assertNoOtherWrites(); }); test("live EN/Bokmål presentation uses explicit date locale and canonical statuses while preserving raw reasons without GET or writes", async () => { const originalDate = Date.prototype.toLocaleDateString; jest.spyOn(Date.prototype, "toLocaleDateString").mockImplementation(function ( this: Date, locales?: Parameters[0], options?: Parameters[1], ) { return originalDate.call(this, locales ?? "de-DE", options); }); const target = job(101, { followUpReason: "Do not replace the workflow reason", workflowSignal: signal("follow-up", "follow-up", "Raw source reason — æøå") }); const custom = job(102, { status: "Faglig vurdering", workflowSignal: undefined, followUpReason: "Custom reason stays literal" }); mockedApi.get.mockResolvedValue({ data: [target, custom] } as any); renderPage(); await screen.findByText(new RegExp(target.jobTitle)); expect(within(card(target.jobTitle)).getByText(copy("remindersFollowUpDate", { date: originalDate.call(new Date(target.followUpAt!), "en") }))).toBeInTheDocument(); changeLanguage("nb"); expect(screen.getByRole("heading", { level: 2, name: copy("remindersTitle") })).toBeInTheDocument(); expect(within(card(target.jobTitle)).getByText(copy("remindersFollowUpDate", { date: originalDate.call(new Date(target.followUpAt!), "nb-NO") }))).toBeInTheDocument(); expect(within(card(target.jobTitle)).getByText(translations.nb.statusWaiting)).toBeInTheDocument(); expect(within(card(target.jobTitle)).getByText(target.workflowSignal!.reason)).toBeInTheDocument(); expect(screen.queryByText(target.followUpReason!)).not.toBeInTheDocument(); expect(within(card(custom.jobTitle)).getByText(custom.status)).toBeInTheDocument(); expect(within(card(custom.jobTitle)).getByText(custom.followUpReason!)).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.patch).not.toHaveBeenCalled(); assertNoOtherWrites(); }); test("a generic read failure translates live without GET and explicit read Retry preserves its original request", async () => { mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ data: [] } as any); renderPage(); expect(await screen.findByRole("alert")).toHaveTextContent(copy("remindersLoadFailed")); changeLanguage("nb"); expect(screen.getByRole("alert")).toHaveTextContent(copy("remindersLoadFailed")); expect(mockedApi.get).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: copy("retry") })); expect(await screen.findByText(copy("remindersNothing"))).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications/reminders", readRequest); expect(mockedApi.patch).not.toHaveBeenCalled(); assertNoOtherWrites(); }); test("when an action removes its row, focus stays in the named results region instead of a removed button", async () => { const target = job(111, { status: "Custom stage", workflowSignal: undefined }); const write = deferred(); mockedApi.get.mockResolvedValueOnce({ data: [target] } as any).mockResolvedValueOnce({ data: [] } as any); mockedApi.patch.mockImplementationOnce(() => write.promise); renderPage(); await screen.findByText(new RegExp(target.jobTitle)); const clear = action(target.jobTitle, null); clear.focus(); fireEvent.click(clear); expect(region()).toHaveFocus(); await act(async () => { write.resolve({ status: 204 }); }); expect(await screen.findByText(copy("remindersNothing"))).toBeInTheDocument(); expect(screen.queryByRole("article", { name: target.jobTitle })).not.toBeInTheDocument(); expect(region()).toHaveFocus(); expect(mockedApi.patch).toHaveBeenCalledTimes(1); assertNoOtherWrites(); }); test("a removed reminder row does not steal focus deliberately moved outside while its PATCH is held", async () => { const target = job(121, { status: "Custom stage", workflowSignal: undefined }); const write = deferred(); mockedApi.get.mockResolvedValueOnce({ data: [target] } as any).mockResolvedValueOnce({ data: [] } as any); mockedApi.patch.mockImplementationOnce(() => write.promise); renderPage(); await screen.findByText(new RegExp(target.jobTitle)); const clear = action(target.jobTitle, null); clear.focus(); fireEvent.click(clear); const outside = screen.getByRole("button", { name: "Outside reminder controls" }); outside.focus(); await act(async () => { write.resolve({ status: 204 }); }); expect(await screen.findByText(copy("remindersNothing"))).toBeInTheDocument(); expect(outside).toHaveFocus(); expect(screen.queryByRole("article", { name: target.jobTitle })).not.toBeInTheDocument(); expect(mockedApi.patch).toHaveBeenCalledTimes(1); assertNoOtherWrites(); });