import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter, useLocation, useNavigate } from "react-router-dom"; import { api } from "./api"; import CompaniesTable from "./components/CompaniesTable"; import { I18nProvider } from "./i18n/I18nProvider"; import { ToastProvider } from "./toast"; jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn() }, getApiErrorMessage: (_error: unknown, fallback: string) => fallback, })); const mockedApi = api as jest.Mocked; beforeEach(() => { mockedApi.get.mockReset(); mockedApi.put.mockReset(); window.localStorage.clear(); }); test("opens one company relationship view with applications and recent contact", async () => { const company = { id: 3, name: "Acme", recruiterName: "Rita", recruiterEmail: "rita@example.test" }; mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("/relationship") ? { company: { ...company, name: "Acme refreshed" }, applications: [{ id: 9, jobTitle: "Backend Developer", status: "Interview", messageCount: 1 }], recentContacts: [{ id: 12, jobApplicationId: 9, jobTitle: "Backend Developer", date: "2026-08-10T10:00:00Z", channel: "email", subject: "Interview", from: "Rita" }], } : [company] } as any)); render(); fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" })); expect(await screen.findByText("Applications at this company")).toBeInTheDocument(); expect(screen.getAllByText("Backend Developer").length).toBeGreaterThan(0); expect(screen.getByText(/Interview · 1 messages/)).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledWith("/companies/3/relationship"); expect(await screen.findByRole("dialog", { name: "Acme refreshed" })).toBeInTheDocument(); }); test.each(["en", "nb"])("company contact dates follow the %s UI locale", async (language) => { window.localStorage.setItem("uiLanguage", language); const nextContactAt = "2026-11-23T12:00:00Z"; mockedApi.get.mockResolvedValue({ data: [{ id: 3, name: "Acme", nextContactAt, pipelineStage: "Interview" }] }); render(); const expected = new Date(nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en"); await screen.findByRole("button", { name: `${language === "nb" ? "Rediger" : "Edit"}: Acme` }); expect(screen.getByRole("row", { name: /Acme/ })).toHaveTextContent(expected); expect(screen.getByRole("row", { name: /Acme/ })).toHaveTextContent("Interview"); }); test.each([ { language: "en", edit: "Edit: Acme", name: "Name", save: "Save", failure: "Failed to update company." }, { language: "nb", edit: "Rediger: Acme", name: "Navn", save: "Lagre", failure: "Kunne ikke oppdatere selskap." }, ])("[PRES-J08 E08] $language company save failure remains inside the dialog with the draft available for retry", async ({ language, edit, name, save, failure }) => { window.localStorage.setItem("uiLanguage", language); const company = { id: 3, name: "Acme", location: "Oslo", source: "User", recruiterName: "Rita", recruiterEmail: "rita@example.test" }; mockedApi.get.mockResolvedValue({ data: [company] }); mockedApi.put.mockRejectedValueOnce(new Error("offline")); mockedApi.put.mockResolvedValueOnce({ data: { ...company, name: "Acme updated" } }); render(); fireEvent.click(await screen.findByRole("button", { name: edit })); const dialog = await screen.findByRole("dialog"); fireEvent.change(within(dialog).getByRole("textbox", { name }), { target: { value: "Acme updated" } }); fireEvent.click(within(dialog).getByRole("button", { name: save })); expect(await within(dialog).findByRole("alert")).toHaveTextContent(failure); expect(within(dialog).getByRole("textbox", { name })).toHaveValue("Acme updated"); expect(dialog).toBeVisible(); expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/companies/3", expect.objectContaining({ name: "Acme updated" })); fireEvent.click(within(dialog).getByRole("button", { name: save })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); expect(await screen.findByRole("button", { name: `${language === "en" ? "Edit" : "Rediger"}: Acme updated` })).toBeVisible(); expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/companies/3", expect.objectContaining({ name: "Acme updated" })); }); const companyA = { id: 3, name: "Acme", location: "Oslo", source: "User", recruiterName: "Rita", recruiterEmail: "rita@example.test" }; const companyB = { id: 4, name: "Northstar", location: "Bergen", source: "User" }; function relationship(company: { id: number; name: string } = companyA) { return { data: { company, applications: [{ id: company.id + 10, jobTitle: `${company.name} role`, status: "Interview", messageCount: 1 }], recentContacts: [] } }; } function RouteContext() { const location = useLocation(); const navigate = useNavigate(); return <>{JSON.stringify({ pathname: location.pathname, search: location.search, hash: location.hash, state: location.state })}; } function renderPage(initialEntries: any[] = ["/companies"]) { return render(); } 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 }; } test("a held company save locks all nine fields and cancellation, submits one exact snapshot, and retains a failed draft", async () => { const write = deferred(); mockedApi.get.mockResolvedValue({ data: [companyA] } as any); mockedApi.put.mockImplementationOnce(() => write.promise); renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Edit: Acme" })); const dialog = await screen.findByRole("dialog", { name: "Edit Company" }); const draft = { Name: "Acme updated", Location: "Bergen", Source: "User", "Pipeline stage": " Interview ", "Recruiter name": " Updated recruiter ", "Recruiter email": " updated@example.test ", "Recruiter LinkedIn": " https://example.test/recruiter ", "Last contacted": "2026-11-21", "Next contact": "2026-11-23", }; Object.entries(draft).forEach(([label, value]) => fireEvent.change(within(dialog).getByLabelText(label), { target: { value } })); const save = within(dialog).getByRole("button", { name: "Save" }); fireEvent.click(save); expect(mockedApi.put).toHaveBeenCalledWith("/companies/3", { name: "Acme updated", location: "Bergen", source: "User", recruiterName: "Updated recruiter", recruiterEmail: "updated@example.test", recruiterLinkedIn: "https://example.test/recruiter", pipelineStage: "Interview", lastContactedAt: "2026-11-21", nextContactAt: "2026-11-23", }); expect(save).toBeDisabled(); Object.keys(draft).forEach((label) => expect(within(dialog).getByLabelText(label)).toBeDisabled()); expect(within(dialog).getByRole("button", { name: "Cancel" })).toBeDisabled(); fireEvent.click(save); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); expect(mockedApi.put).toHaveBeenCalledTimes(1); expect(dialog).toBeVisible(); await act(async () => { write.reject(new Error("synthetic save failure")); }); expect(await within(dialog).findByRole("alert")).toHaveTextContent("Failed to update company."); Object.entries(draft).forEach(([label, value]) => expect(within(dialog).getByLabelText(label)).toHaveValue(value)); expect(save).toBeEnabled(); expect(within(dialog).getByRole("button", { name: "Cancel" })).toBeEnabled(); expect(mockedApi.put).toHaveBeenCalledTimes(1); }); test("a locale change translates an existing company-save failure without refetching or replacing its draft", async () => { mockedApi.get.mockResolvedValue({ data: [companyA] } as any); mockedApi.put.mockRejectedValueOnce(new Error("synthetic save failure")); renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Edit: Acme" })); const dialog = await screen.findByRole("dialog"); fireEvent.change(within(dialog).getByRole("textbox", { name: "Name" }), { target: { value: "Acme unsaved" } }); fireEvent.change(within(dialog).getByRole("textbox", { name: "Recruiter name" }), { target: { value: "Unsaved recruiter" } }); fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); expect(await within(dialog).findByRole("alert")).toHaveTextContent("Failed to update company."); const reads = mockedApi.get.mock.calls.length; act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); expect(within(dialog).getByRole("alert")).toHaveTextContent("Kunne ikke oppdatere selskap."); expect(within(dialog).getByRole("textbox", { name: "Navn" })).toHaveValue("Acme unsaved"); expect(within(dialog).getByRole("textbox", { name: "Navn på rekrutterer" })).toHaveValue("Unsaved recruiter"); expect(mockedApi.get).toHaveBeenCalledTimes(reads); expect(mockedApi.put).toHaveBeenCalledTimes(1); }); test("retrying a failed relationship read loads the same company without reloading the company list", async () => { const retry = deferred(); let reads = 0; mockedApi.get.mockImplementation((url) => url === "/companies/3/relationship" ? ++reads === 1 ? Promise.reject(new Error("synthetic relationship failure")) : retry.promise : Promise.resolve({ data: [companyA] }) as any); renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" })); const dialog = await screen.findByRole("dialog"); expect(await within(dialog).findByRole("alert")).toHaveTextContent("Could not load the relationship history."); fireEvent.click(within(dialog).getByRole("button", { name: "Retry" })); expect(reads).toBe(2); expect(mockedApi.get.mock.calls.filter(([url]) => url === "/companies")).toHaveLength(1); expect(within(dialog).queryByText("Acme role")).not.toBeInTheDocument(); await act(async () => { retry.resolve(relationship()); }); expect(await within(dialog).findByText("Acme role")).toBeInTheDocument(); expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument(); expect(reads).toBe(2); }); test.each(["success", "failure"])("a late %s for relationship A cannot replace the opened relationship B", async (outcome) => { const previous = deferred(); mockedApi.get.mockImplementation((url) => url === "/companies/3/relationship" ? previous.promise : Promise.resolve(url === "/companies/4/relationship" ? relationship(companyB) : { data: [companyA, companyB] }) as any); renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" })); fireEvent.click(await screen.findByRole("button", { name: "Close" })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: "Relationship history: Northstar" })); const dialog = await screen.findByRole("dialog"); expect(await within(dialog).findByText("Northstar role")).toBeInTheDocument(); await act(async () => { if (outcome === "success") previous.resolve(relationship()); else previous.reject(new Error("late A relationship failure")); }); expect(within(dialog).getByText("Northstar role")).toBeInTheDocument(); expect(within(dialog).queryByText("Acme role")).not.toBeInTheDocument(); expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument(); expect(mockedApi.get.mock.calls.filter(([url]) => String(url).endsWith("/relationship"))).toHaveLength(2); }); test("a relationship response received after closing stays closed and does not populate another held relationship", async () => { const previous = deferred(); const next = deferred(); mockedApi.get.mockImplementation((url) => url === "/companies/3/relationship" ? previous.promise : url === "/companies/4/relationship" ? next.promise : Promise.resolve({ data: [companyA, companyB] }) as any); renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" })); fireEvent.click(await screen.findByRole("button", { name: "Close" })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); await act(async () => { previous.resolve(relationship()); }); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Relationship history: Northstar" })); const dialog = await screen.findByRole("dialog"); expect(within(dialog).queryByText("Acme role")).not.toBeInTheDocument(); expect(within(dialog).queryByText("Northstar role")).not.toBeInTheDocument(); await act(async () => { next.resolve(relationship(companyB)); }); expect(await within(dialog).findByText("Northstar role")).toBeInTheDocument(); }); test("the company edit deep link removes only edit while preserving query, hash, state and replace navigation", async () => { mockedApi.get.mockResolvedValue({ data: [companyA] } as any); renderPage([ { pathname: "/jobs/9", search: "?keep=true", hash: "#source", state: { previous: true } }, { pathname: "/companies", search: "?edit=3&return=jobs%2F9", hash: "#activity", state: { returnTo: "/jobs/9" } }, ]); const route = screen.getByLabelText("Route context"); expect(await screen.findByRole("dialog", { name: "Edit Company" })).toBeInTheDocument(); await waitFor(() => expect(JSON.parse(route.textContent!).search).toBe("?return=jobs%2F9")); expect(JSON.parse(route.textContent!)).toEqual({ pathname: "/companies", search: "?return=jobs%2F9", hash: "#activity", state: { returnTo: "/jobs/9" } }); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: "Previous route" })); expect(JSON.parse(route.textContent!)).toEqual({ pathname: "/jobs/9", search: "?keep=true", hash: "#source", state: { previous: true } }); expect(mockedApi.get).toHaveBeenCalledTimes(1); });