import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { CoverLetterDocumentFields } from "./components/CoverLetterDocument"; import { ApplicationCoverLetterSection } from "./components/ApplicationAssets"; import { CoverLetterDocumentSettings } from "./applicationWorkspace"; import { api } from "./api"; import { I18nProvider } from "./i18n/I18nProvider"; import { ConfirmProvider } from "./confirm"; const mockedApi = api as jest.Mocked; const settings: CoverLetterDocumentSettings = { schemaVersion: 1, senderName: "Saved sender", senderTitle: "Saved title", senderEmail: "saved@example.test", senderPhone: "123", senderLocation: "Oslo", recipient: "Preserve recipient", subject: "Preserve subject", date: "2026-10-25", themeId: "modern", accentColor: "#2563eb", bodyFont: "Arial, Helvetica, sans-serif", baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4", }; const letter = { revision: 7, text: "Preserve letter body", currentVersion: 2, aiSuggestionCount: 0, documentSettings: settings, versions: [{ version: 1, source: "manual", aiAction: null, length: 20, createdAtUtc: "2026-10-25T10:00:00Z", isCurrent: false }] }; const variant = { id: 3, name: "Chosen CV", settings: { themeId: "modern", headline: null, accentColor: null, bodyFont: null, baseFontSizePt: null, lineHeight: null, pageMarginMm: null, pageSize: null, density: "compact" } }; const wrap = (ui: React.ReactNode) => {ui}; function deferred() { let resolve!: (value: T) => void; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; } beforeEach(() => { localStorage.removeItem("uiLanguage"); jest.clearAllMocks(); mockedApi.get.mockReset(); mockedApi.post.mockReset(); mockedApi.put.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/cv/variants" ? [{ id: 3, name: "Chosen CV", themeId: "modern" }] : url === "/cv/variants/3" ? variant : url === "/cv/themes" ? [{ id: "modern", available: true, accent: "#112233", bodyFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 11, lineHeight: 1.5, pageMarginMm: 16 }] : url === "/career/profile" ? { profile: { contact: { fullName: "Canonical name", headline: "Canonical title", email: "canonical@example.test", phone: "+47 555", location: "Bergen", website: "https://private.test", dateOfBirth: "private" } } } : url.endsWith("/cover-letter") ? letter : { availableVariants: [] }, } as any)); }); afterEach(() => jest.restoreAllMocks()); async function selectSource() { const select = await screen.findByRole("combobox", { name: "Source CV" }); await waitFor(() => expect(select).not.toHaveAttribute("aria-disabled", "true")); fireEvent.mouseDown(select); fireEvent.click(await screen.findByRole("option", { name: "Chosen CV" })); } test("contact copy is a reviewed canonical snapshot; cancel is inert and apply preserves recipient and design", async () => { const onChange = jest.fn(); render(wrap()); fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" })); await selectSource(); const snapshot = await screen.findByRole("region", { name: "Contact snapshot" }); expect(snapshot).toHaveTextContent("Canonical name"); expect(snapshot).toHaveTextContent("Canonical title"); expect(snapshot).not.toHaveTextContent("private"); expect(onChange).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); expect(onChange).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" })); await selectSource(); await screen.findByRole("region", { name: "Contact snapshot" }); fireEvent.click(screen.getByRole("button", { name: "Use this copy" })); expect(onChange).toHaveBeenCalledWith({ ...settings, senderName: "Canonical name", senderTitle: "Canonical title", senderEmail: "canonical@example.test", senderPhone: "+47 555", senderLocation: "Bergen", sourceCvId: 3 }); expect(mockedApi.get).not.toHaveBeenCalledWith("/auth/me"); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("design copy resolves null overrides through actual theme defaults and effective CV density", async () => { const onChange = jest.fn(); render(wrap()); fireEvent.click(screen.getByRole("button", { name: "Match CV design" })); await selectSource(); await screen.findByRole("region", { name: "Design snapshot" }); expect(onChange).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Use this copy" })); expect(onChange).toHaveBeenCalledWith({ ...settings, accentColor: "#112233", bodyFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 11, lineHeight: 1.5, pageMarginMm: 13.12, sourceCvId: 3 }); expect(mockedApi.get).not.toHaveBeenCalledWith("/career/profile"); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("closing a copy dialog ignores a late selected CV read", async () => { const selected = deferred(); const originalGet = mockedApi.get.getMockImplementation()!; mockedApi.get.mockImplementation(((url: string) => url === "/cv/variants/3" ? selected.promise : originalGet(url)) as any); const onChange = jest.fn(); render(wrap()); fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" })); await selectSource(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await act(async () => { selected.resolve({ data: variant }); await selected.promise; }); expect(onChange).not.toHaveBeenCalled(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(mockedApi.get).not.toHaveBeenCalledWith("/career/profile"); }); test("failed copy reads are retryable and cannot apply or save a partial snapshot", async () => { const originalGet = mockedApi.get.getMockImplementation()!; let reads = 0; mockedApi.get.mockImplementation(((url: string) => url === "/cv/variants/3" && ++reads === 1 ? Promise.reject({ response: { status: 503 } }) : originalGet(url)) as any); const onChange = jest.fn(); render(wrap()); fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" })); await selectSource(); await screen.findByText("Could not load the CV snapshot. Try again."); expect(screen.getByRole("button", { name: "Use this copy" })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: "Retry" })); await screen.findByRole("region", { name: "Contact snapshot" }); expect(screen.getByRole("button", { name: "Use this copy" })).toBeEnabled(); expect(onChange).not.toHaveBeenCalled(); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("restoring a historical letter restores saved details with text after explicit dirty confirmation", async () => { mockedApi.post.mockResolvedValue({ data: { ...letter, revision: 8, text: "Historical body", documentSettings: { ...settings, subject: "Historical subject", senderName: "Historical sender" } } } as any); render(wrap()); fireEvent.change(await screen.findByRole("textbox", { name: "Subject" }), { target: { value: "Local unsaved subject" } }); expect(screen.queryByRole("region", { name: "Letter text preview" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Restore version 1" })); const dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByRole("button", { name: "Discard and restore" })); await waitFor(() => expect(screen.getByRole("textbox", { name: "Subject" })).toHaveValue("Historical subject")); expect(screen.getByRole("textbox", { name: "Sender name" })).toHaveValue("Historical sender"); expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Historical body"); expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 }); expect(mockedApi.put).not.toHaveBeenCalled(); }); test("successful export waits for the committed local snapshot and uses its returned revision once", async () => { const write = deferred(); const pdf = deferred(); mockedApi.put.mockReturnValueOnce(write.promise); mockedApi.post.mockReturnValueOnce(pdf.promise); const create = jest.fn(() => "blob:letter-test"); const revoke = jest.fn(); const oldCreate = Object.getOwnPropertyDescriptor(URL, "createObjectURL"); const oldRevoke = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL"); Object.defineProperty(URL, "createObjectURL", { configurable: true, value: create }); Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revoke }); jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); try { render(wrap()); fireEvent.change(await screen.findByRole("textbox", { name: "Subject" }), { target: { value: "Durable subject" } }); const button = screen.getByRole("button", { name: "Export letter PDF" }); act(() => { fireEvent.click(button); fireEvent.click(button); }); expect(mockedApi.put).toHaveBeenCalledTimes(1); expect(mockedApi.post).not.toHaveBeenCalled(); await act(async () => { write.resolve({ data: { ...letter, revision: 9, documentSettings: { ...settings, subject: "Durable subject" } } }); await write.promise; }); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/export-pdf?expectedVersion=9", undefined, { responseType: "blob" })); expect(screen.getByRole("textbox", { name: "Subject" })).toBeDisabled(); expect(create).not.toHaveBeenCalled(); const blob = new Blob(["pdf"], { type: "application/pdf" }); await act(async () => { pdf.resolve({ data: blob }); await pdf.promise; }); expect(create).toHaveBeenCalledWith(blob); expect(revoke).toHaveBeenCalledWith("blob:letter-test"); expect(mockedApi.post).toHaveBeenCalledTimes(1); } finally { if (oldCreate) Object.defineProperty(URL, "createObjectURL", oldCreate); else Reflect.deleteProperty(URL, "createObjectURL"); if (oldRevoke) Object.defineProperty(URL, "revokeObjectURL", oldRevoke); else Reflect.deleteProperty(URL, "revokeObjectURL"); } }); test("a late letter PDF from a previous keyed job never downloads or replaces the current draft", async () => { const pdf = deferred(); mockedApi.post.mockReturnValueOnce(pdf.promise); const create = jest.fn(); const oldCreate = Object.getOwnPropertyDescriptor(URL, "createObjectURL"); Object.defineProperty(URL, "createObjectURL", { configurable: true, value: create }); try { const mounted = render(wrap()); fireEvent.click(await screen.findByRole("button", { name: "Export letter PDF" })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1)); mounted.rerender(wrap()); fireEvent.change(await screen.findByRole("textbox", { name: "Subject" }), { target: { value: "Job B private draft" } }); await act(async () => { pdf.resolve({ data: new Blob(["Old job A PDF"]) }); await pdf.promise; }); expect(create).not.toHaveBeenCalled(); expect(screen.getByRole("textbox", { name: "Subject" })).toHaveValue("Job B private draft"); expect(mockedApi.put).not.toHaveBeenCalled(); } finally { if (oldCreate) Object.defineProperty(URL, "createObjectURL", oldCreate); else Reflect.deleteProperty(URL, "createObjectURL"); } });