import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render as rtlRender, screen, waitFor, within } from "@testing-library/react"; import { ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, SubmittedPackagesSection, } from "./components/ApplicationAssets"; import { api } from "./api"; import { I18nProvider } from "./i18n/I18nProvider"; import { ConfirmProvider } from "./confirm"; import { translations, type UiLanguage } from "./i18n/translations"; const withProviders = (ui: React.ReactElement) => {ui}; const render = (ui: React.ReactElement) => rtlRender(withProviders(ui)); jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, }, getApiErrorMessage: (_e: unknown, fallback = "Request failed.") => fallback, })); const mockedApi = api as jest.Mocked; const cv = { attachedVariantId: 3, attachedVariantName: "Backend CV", attachedThemeId: "nordic", attachedVersion: 4, attachedUpdatedAtUtc: "2026-07-19T10:00:00Z", attachedIsPublic: false, hasTailoredCvText: false, availableVariants: [ { id: 3, name: "Backend CV", themeId: "nordic", publicSlug: "abc", isPublic: false, version: 4, jobApplicationId: 7, updatedAtUtc: "2026-07-19T10:00:00Z" }, { id: 5, name: "Generalist CV", themeId: "modern", publicSlug: "def", isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: "2026-07-18T10:00:00Z" }, ], }; const tailoring = { hasJobDescription: true, hasCareerProfile: true, hasAttachedVariant: true, matchScore: 72, suggestions: [ { kind: "highlight-skills", title: "Skills to highlight", detail: "The advert asks for these.", items: ["C#", "SQL"] }, { kind: "gaps", title: "Gaps to address", detail: null, items: ["Kubernetes"] }, ], aiSuggestionCount: 0, }; const coverLetter = { text: "Dear team", currentVersion: 2, versions: [ { version: 2, source: "ai", aiAction: "improve", length: 9, createdAtUtc: "2026-07-19T10:00:00Z", isCurrent: true }, { version: 1, source: "manual", aiAction: null, length: 40, createdAtUtc: "2026-07-19T09:00:00Z", isCurrent: false }, ], aiSuggestionCount: 1, }; function routeGet(overrides: Record = {}) { mockedApi.get.mockImplementation((url: string) => { if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any); if (url.endsWith("/cover-letter")) return Promise.resolve({ data: overrides.coverLetter ?? coverLetter } as any); if (url.endsWith("/submitted-packages")) return Promise.resolve({ data: overrides.submittedPackages ?? [] } as any); return Promise.resolve({ data: overrides.cv ?? cv } as any); }); } beforeEach(() => { window.localStorage.removeItem("uiLanguage"); document.documentElement.lang = "en"; jest.clearAllMocks(); mockedApi.get.mockReset(); mockedApi.put.mockReset(); mockedApi.post.mockReset(); }); afterEach(() => { window.localStorage.removeItem("uiLanguage"); jest.restoreAllMocks(); }); function copy(key: string, params: Record = {}, language: UiLanguage = document.documentElement.lang === "nb-NO" ? "nb" : "en") { const template = (translations[language] as Record)[key] ?? key; return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`)); } function section(key: string) { return within(screen.getByRole("region", { name: copy(key) })); } function changeLanguage(language: UiLanguage) { act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: language })); }); } 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("document package history is read-only and separates captured notes from answers", async () => { const summary = { id: 91, version: 1, cvVariantId: 3, cvVariantName: "Original CV", cvVariantVersion: 2, cvThemeId: "nordic", hasCv: false, hasCoverLetter: true, createdAtUtc: "2026-07-19T10:00:00Z", attachments: [] }; mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/91') ? { summary, renderedCvHtml: null, coverLetterText: "Frozen letter", applicationMaterialJson: JSON.stringify({ Notes: "Human notes\n\n<<>>\nFrozen answer\n<<>>" }), } : [summary] } as any)); render(); fireEvent.click(await screen.findByRole("button", { name: "View preserved package" })); expect(await screen.findByRole("dialog")).toHaveTextContent("immutable submission snapshot"); expect(screen.getByText("Human notes", { exact: true })).toBeInTheDocument(); expect(screen.getByText("Frozen answer", { exact: true })).toBeInTheDocument(); expect(screen.queryByText(/<< { routeGet(); render(); expect(await screen.findByText("Backend CV")).toBeInTheDocument(); expect(screen.getByText(/Theme nordic · version 4/)).toBeInTheDocument(); // Must be the builder's real route (/career/builder/:id). An href the router does not serve // silently dead-ends the user, which is exactly what shipped before this test existed. expect(screen.getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute( "href", "/career/builder/3"); }); test("attaching a different variant only re-points the application", async () => { routeGet(); mockedApi.put.mockResolvedValue({ data: { ...cv, attachedVariantId: 5, attachedVariantName: "Generalist CV" } } as any); render(); fireEvent.mouseDown(await screen.findByRole("combobox", { name: /Attached CV variant/i })); fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cv", { variantId: 5 })); }); test("cv section points at the builder when there are no variants", async () => { routeGet({ cv: { ...cv, attachedVariantId: null, attachedVariantName: null, availableVariants: [] } }); render(); expect(await screen.findByText(/No CV variants yet/i)).toBeInTheDocument(); }); test("tailoring renders suggestions grouped by kind", async () => { routeGet(); render(); expect(await screen.findByText("Skills to highlight")).toBeInTheDocument(); expect(screen.getByText("Gaps to address")).toBeInTheDocument(); expect(screen.getByText("Kubernetes")).toBeInTheDocument(); }); test("tailoring asks for a career profile when there is none", async () => { routeGet({ tailoring: { ...tailoring, hasCareerProfile: false, suggestions: [] } }); render(); expect(await screen.findByText(/Build your career profile/i)).toBeInTheDocument(); }); test("submitted package capture appends an immutable history entry", async () => { routeGet(); mockedApi.post.mockResolvedValue({ data: { id: 12, version: 1, cvVariantId: 3, cvVariantName: "Backend CV", cvVariantVersion: 4, cvThemeId: "nordic", hasCv: true, hasCoverLetter: true, createdAtUtc: "2026-08-31T12:00:00Z", attachments: [], } } as any); render(); fireEvent.click(await screen.findByRole("button", { name: /Freeze current package/i })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/submitted-packages")); expect(await screen.findByText("Submission package v1")).toBeInTheDocument(); expect(screen.getByText(/Backend CV · v4/)).toBeInTheDocument(); }); // ---------- Cover letter ---------- test("cover letter loads the current text and its history", async () => { routeGet(); render(); expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument(); expect(screen.getByText("v2")).toBeInTheDocument(); expect(screen.getByText("ai · improve")).toBeInTheDocument(); expect(screen.getByText("Current")).toBeInTheDocument(); }); test("editing marks the draft dirty and saving sends the new text", async () => { routeGet(); mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Dear hiring team" } } as any); const onDirtyChange = jest.fn(); render(); fireEvent.change(await screen.findByRole("textbox", { name: "Cover letter" }), { target: { value: "Dear hiring team" } }); expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); expect(onDirtyChange).toHaveBeenLastCalledWith(true); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( "/jobapplications/7/cover-letter", { text: "Dear hiring team", source: "manual", aiAction: undefined }, )); }); test("discarding returns to the saved text", async () => { routeGet(); render(); fireEvent.change(await screen.findByRole("textbox", { name: "Cover letter" }), { target: { value: "scratch" } }); fireEvent.click(screen.getByRole("button", { name: /Discard changes/i })); expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument(); expect(mockedApi.put).not.toHaveBeenCalled(); }); test("restoring an old version calls the restore endpoint", async () => { routeGet(); mockedApi.post.mockResolvedValue({ data: coverLetter } as any); render(); fireEvent.click(await screen.findByRole("button", { name: /Restore version 1/i })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith( "/jobapplications/7/cover-letter/versions/1/restore")); }); test("an empty cover letter offers the template and an empty history", async () => { routeGet({ coverLetter: { text: null, currentVersion: 0, versions: [], aiSuggestionCount: 0 } }); render(); expect(await screen.findByText(/No versions yet/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /Start from template/i })); expect((screen.getByRole("textbox", { name: "Cover letter" }) as HTMLTextAreaElement).value) .toContain("Dear Hiring Manager"); }); test("the empty cover-letter template and rich-text controls follow Bokmål UI language", async () => { window.localStorage.setItem("uiLanguage", "nb"); routeGet({ coverLetter: { text: null, currentVersion: 0, versions: [], aiSuggestionCount: 0 } }); render(); fireEvent.click(await screen.findByRole("button", { name: /start fra mal/i })); expect((screen.getByRole("textbox", { name: "Søknadsbrev" }) as HTMLTextAreaElement).value) .toContain("Kjære rekrutteringsansvarlig"); expect(screen.getByRole("button", { name: "Fet" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Lenke" })).toBeInTheDocument(); }); test("AI cover letter suggestions use the linked CV and require explicit apply", async () => { routeGet(); mockedApi.post.mockResolvedValue({ data: { id: 9, module: "cover-letter", mode: "professional", title: "Cover letter · Generate · Professional", provider: "local", result: { text: "A tailored, truthful suggestion." }, createdAtUtc: "2026-07-19T11:00:00Z", }, } as any); render(); expect(await screen.findByText(/application's full job advert and analysis/i)).toBeInTheDocument(); expect(screen.getByRole("combobox", { name: "Action" })).toBeVisible(); expect(screen.getByRole("combobox", { name: "Tone" })).toBeVisible(); fireEvent.mouseDown(screen.getByRole("combobox", { name: "Document language" })); fireEvent.click(await screen.findByRole("option", { name: /Norsk bokmål/i })); fireEvent.click(screen.getByRole("button", { name: "Generate" })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith( "/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", targetLanguage: "nb-NO", currentText: "Dear team", action: "generate", }), )); expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Dear team"); fireEvent.click(await screen.findByRole("button", { name: /Apply to editor/i })); expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("A tailored, truthful suggestion."); mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "A tailored, truthful suggestion." } } as any); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( "/jobapplications/7/cover-letter", { text: "A tailored, truthful suggestion.", source: "ai", aiAction: "generate" }, )); }); test("[PRES-W06] rejecting an AI cover letter preserves the saved draft without a write", async () => { routeGet(); mockedApi.post.mockResolvedValue({ data: { result: { text: "Unaccepted letter" } } } as never); const onDirtyChange = jest.fn(); render(); await screen.findByText(/application's full job advert and analysis/i); expect(screen.getByText(/Using Backend CV and this application's full job advert and analysis/i)).toBeInTheDocument(); expect(screen.queryByText(/\{name\}/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Generate" })); await screen.findByText("Unaccepted letter"); expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Dear team"); fireEvent.click(screen.getByRole("button", { name: "Reject" })); expect(screen.queryByText("Unaccepted letter")).not.toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Dear team"); expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); expect(onDirtyChange).not.toHaveBeenCalledWith(true); expect(mockedApi.put).not.toHaveBeenCalled(); }); test("a failed load surfaces an error", async () => { mockedApi.get.mockRejectedValue(new Error("boom")); render(); expect(await section("workspaceCoverLetter").findByText(/Could not load this section/i)).toBeInTheDocument(); }); // ---------- Application package drafts ---------- test("application answer and recruiter drafts save from the dedicated workspace", async () => { mockedApi.put.mockResolvedValue({ data: undefined } as any); const onSaved = jest.fn(); render( , ); fireEvent.change(screen.getByLabelText("Application answer"), { target: { value: "Edited answer" } }); fireEvent.change(screen.getByLabelText("Recruiter message"), { target: { value: "Edited recruiter note" } }); expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Save application drafts" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( "/jobapplications/7/application-drafts", { applicationAnswerDraft: "Edited answer", recruiterMessageDraft: "Edited recruiter note", }, )); expect(onSaved).toHaveBeenCalled(); }); test("application package drafts can be cleared without deleting ordinary notes", async () => { mockedApi.put.mockResolvedValue({ data: undefined } as any); render( , ); fireEvent.change(screen.getByLabelText("Application answer"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("Recruiter message"), { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: "Save application drafts" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( "/jobapplications/7/application-drafts", { applicationAnswerDraft: "", recruiterMessageDraft: "" }, )); }); // ---------- Phase 7 retained-owner recovery ---------- test("failed cover-letter saving retains editable dirty text and original Save recovery across a live locale change", async () => { routeGet(); const text = "Retained letter draft — æøå"; mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }) .mockResolvedValueOnce({ data: { ...coverLetter, text } } as any); const onDirtyChange = jest.fn(); render(); fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: text } }); fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") })); expect(await section("workspaceCoverLetter").findByRole("alert")).toHaveTextContent(copy("assetsCoverSaveFailed")); expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue(text); expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toBeEnabled(); expect(section("workspaceCoverLetter").getByRole("button", { name: copy("save") })).toBeEnabled(); expect(section("workspaceCoverLetter").getByRole("button", { name: copy("assetsDiscardChanges") })).toBeEnabled(); expect(section("workspaceCoverLetter").queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument(); expect(onDirtyChange).toHaveBeenLastCalledWith(true); const reads = mockedApi.get.mock.calls.length; changeLanguage("nb"); expect(section("workspaceCoverLetter").getByRole("alert")).toHaveTextContent(copy("assetsCoverSaveFailed")); expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue(text); expect(mockedApi.get).toHaveBeenCalledTimes(reads); fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2)); const payload = { text, source: "manual", aiAction: undefined }; expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/cover-letter", payload); expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/cover-letter", payload); await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false)); expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue(text); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("failed application-answer saving keeps both dirty drafts editable and retries only the original save payload", async () => { mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ status: 204 } as any); const onDirtyChange = jest.fn(); const onSaved = jest.fn(); render(); fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Retained answer" } }); fireEvent.change(screen.getByLabelText(copy("assetsRecruiterMessage")), { target: { value: "Retained recruiter draft" } }); fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") })); expect(await section("assetsApplicationDrafts").findByRole("alert")).toHaveTextContent(copy("assetsDraftsSaveFailed")); expect(screen.getByLabelText(copy("assetsApplicationAnswer"))).toHaveValue("Retained answer"); expect(screen.getByLabelText(copy("assetsRecruiterMessage"))).toHaveValue("Retained recruiter draft"); expect(screen.getByLabelText(copy("assetsApplicationAnswer"))).toBeEnabled(); expect(screen.getByLabelText(copy("assetsRecruiterMessage"))).toBeEnabled(); expect(onDirtyChange).toHaveBeenLastCalledWith(true); expect(onSaved).not.toHaveBeenCalled(); changeLanguage("nb"); expect(section("assetsApplicationDrafts").getByRole("alert")).toHaveTextContent(copy("assetsDraftsSaveFailed")); expect(mockedApi.get).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") })); await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); const payload = { applicationAnswerDraft: "Retained answer", recruiterMessageDraft: "Retained recruiter draft" }; expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/application-drafts", payload); expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/application-drafts", payload); expect(onDirtyChange).toHaveBeenLastCalledWith(false); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("failed CV attachment leaves the current association and selector available for the original explicit action", async () => { routeGet(); mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }) .mockResolvedValueOnce({ data: { ...cv, attachedVariantId: 5, attachedVariantName: "Generalist CV" } } as any); render(); const select = () => screen.getByRole("combobox", { name: copy("assetsAttachedCv") }); fireEvent.mouseDown(await screen.findByRole("combobox", { name: /Attached CV variant/i })); fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ })); expect(await section("workspaceCv").findByRole("alert")).toHaveTextContent(copy("assetsAttachCvFailed")); expect(select()).toHaveTextContent("Backend CV"); expect(select()).not.toHaveAttribute("aria-disabled", "true"); expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/3"); expect(section("workspaceCv").queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument(); fireEvent.mouseDown(select()); fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2)); expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/cv", { variantId: 5 }); expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/cv", { variantId: 5 }); await waitFor(() => expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/5")); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("dirty version restore cancels without a write and confirmed held restore appends history exactly once", async () => { routeGet(); const write = deferred(); mockedApi.post.mockImplementation(() => write.promise); const onDirtyChange = jest.fn(); render(); fireEvent.change(await screen.findByRole("textbox", { name: "Cover letter" }), { target: { value: "Unsaved text before restore" } }); fireEvent.click(screen.getByRole("button", { name: /Restore version 1/i })); expect(mockedApi.post).not.toHaveBeenCalled(); const dialog = await screen.findByRole("dialog", { name: copy("assetsRestoreDiscardTitle") }); fireEvent.click(within(dialog).getByRole("button", { name: copy("cancel") })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Unsaved text before restore"); expect(onDirtyChange).toHaveBeenLastCalledWith(true); expect(mockedApi.post).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: /Restore version 1/i })); const confirmed = await screen.findByRole("dialog", { name: copy("assetsRestoreDiscardTitle") }); const confirm = within(confirmed).getByRole("button", { name: copy("assetsRestoreConfirm") }); act(() => { fireEvent.click(confirm); fireEvent.click(confirm); }); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1)); expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore"); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); expect(screen.getByRole("button", { name: /Restore version 1/i })).toBeDisabled(); await act(async () => { write.resolve({ data: { ...coverLetter, text: "Restored old text", currentVersion: 3, versions: [{ version: 3, source: "restore", aiAction: null, length: 17, createdAtUtc: "2026-07-20T10:00:00Z", isCurrent: true }, ...coverLetter.versions.map((version) => ({ ...version, isCurrent: false }))], } }); }); expect(await screen.findByDisplayValue("Restored old text")).toBeInTheDocument(); expect(screen.getByText("v3")).toBeInTheDocument(); expect(screen.getByText("v2")).toBeInTheDocument(); expect(screen.getByText("v1")).toBeInTheDocument(); expect(onDirtyChange).toHaveBeenLastCalledWith(false); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.put).not.toHaveBeenCalled(); }); test("held letter Save submits one snapshot and prevents an AI Apply from replacing its pending draft", async () => { routeGet(); mockedApi.post.mockResolvedValue({ data: { result: { text: "Unaccepted AI suggestion" } } } as any); const write = deferred(); mockedApi.put.mockImplementation(() => write.promise); render(); fireEvent.click(await screen.findByRole("button", { name: "Generate" })); const apply = await screen.findByRole("button", { name: /Apply to editor/i }); fireEvent.change(screen.getByRole("textbox", { name: "Cover letter" }), { target: { value: "Explicit manual save snapshot" } }); const save = section("workspaceCoverLetter").getByRole("button", { name: "Save" }); act(() => { fireEvent.click(save); fireEvent.click(save); }); expect(mockedApi.put).toHaveBeenCalledTimes(1); expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined }); expect(apply).toBeDisabled(); fireEvent.click(apply); expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot"); await act(async () => { write.resolve({ data: { ...coverLetter, text: "Explicit manual save snapshot" } }); }); expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot"); expect(mockedApi.put).toHaveBeenCalledTimes(1); }); test("held AI generation locks its controls and preserves captured action attribution after a later action selection", async () => { routeGet(); const generation = deferred(); mockedApi.post.mockImplementation(() => generation.promise); render(); await screen.findByText(/application's full job advert and analysis/i); fireEvent.mouseDown(screen.getByRole("combobox", { name: "Document language" })); fireEvent.click(await screen.findByRole("option", { name: /Norsk bokmål/i })); fireEvent.change(screen.getByRole("textbox", { name: "Additional instructions" }), { target: { value: "Use only recorded context" } }); const generate = screen.getByRole("button", { name: "Generate" }); act(() => { fireEvent.click(generate); fireEvent.click(generate); }); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", action: "generate", mode: "professional", targetLanguage: "nb-NO", currentText: "Dear team", extraContext: "Use only recorded context" })); for (const name of ["Action", "Tone", "Document language"]) expect(screen.getByRole("combobox", { name })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("textbox", { name: "Additional instructions" })).toBeDisabled(); await act(async () => { generation.resolve({ data: { result: { text: "Captured generated suggestion" } } }); }); fireEvent.mouseDown(screen.getByRole("combobox", { name: "Action" })); fireEvent.click(await screen.findByRole("option", { name: "Improve" })); fireEvent.click(screen.getByRole("button", { name: /Apply to editor/i })); mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Captured generated suggestion" } } as any); fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: "Save" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate" })); expect(mockedApi.post).toHaveBeenCalledTimes(1); }); test("capture, detail and download failures keep owned frozen history and recover only through their original explicit actions", async () => { const attachment = { id: 17, fileName: "frozen-letter.txt", fileSize: 1024, contentType: "text/plain", sha256: "fixture-hash" }; const summary = { id: 91, version: 1, cvVariantId: 3, cvVariantName: "Original CV", cvVariantVersion: 2, cvThemeId: "nordic", hasCv: true, hasCoverLetter: true, createdAtUtc: "2026-07-19T10:00:00Z", attachments: [attachment] }; const created = { ...summary, id: 92, version: 2, cvVariantName: "Backend CV", cvVariantVersion: 4, attachments: [] }; const detail = { summary, renderedCvHtml: "Frozen CV fixture", coverLetterText: "Frozen original letter", applicationMaterialJson: "{}" }; const blob = new Blob(["Frozen file fixture"], { type: "text/plain" }); let detailReads = 0; let downloads = 0; mockedApi.get.mockImplementation((url: string) => { if (url === "/jobapplications/7/submitted-packages/91") return ++detailReads === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: detail } as any); if (url === "/jobapplications/7/submitted-packages/91/attachments/17") return ++downloads === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: blob } as any); if (url.endsWith("/submitted-packages")) return Promise.resolve({ data: [summary] } as any); if (url.endsWith("/tailoring")) return Promise.resolve({ data: tailoring } as any); return Promise.resolve({ data: cv } as any); }); mockedApi.post.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ data: created } as any); const originalCreate = Object.getOwnPropertyDescriptor(URL, "createObjectURL"); const originalRevoke = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL"); const createUrl = jest.fn(() => "blob:owned-frozen-fixture"); const revokeUrl = jest.fn(); Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createUrl }); Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeUrl }); const downloadedNames: string[] = []; jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { downloadedNames.push(this.download); }); try { render(); fireEvent.click(await screen.findByRole("button", { name: /Freeze current package/i })); expect(await section("assetsSubmittedPackages").findByRole("alert")).toHaveTextContent(copy("assetsPackageCaptureFailed")); expect(screen.getByText("Submission package v1")).toBeInTheDocument(); expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/3"); expect(section("assetsSubmittedPackages").queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument(); expect(mockedApi.post).toHaveBeenCalledTimes(1); fireEvent.click(section("assetsSubmittedPackages").getByRole("button", { name: /Freeze current package/i })); await screen.findByText("Submission package v2"); const packageRow = () => screen.getByText("Submission package v1").closest(".MuiPaper-root")!; fireEvent.click(within(packageRow()).getByRole("button", { name: "View preserved package" })); expect(await section("assetsSubmittedPackages").findByRole("alert")).toHaveTextContent(copy("assetsPackageLoadFailed")); expect(screen.getByText("Submission package v1")).toBeInTheDocument(); expect(screen.getByText("Submission package v2")).toBeInTheDocument(); expect(detailReads).toBe(1); expect(mockedApi.post).toHaveBeenCalledTimes(2); fireEvent.click(within(packageRow()).getByRole("button", { name: "View preserved package" })); const preserved = await screen.findByRole("dialog"); expect(preserved).toHaveTextContent("Frozen original letter"); expect(detailReads).toBe(2); fireEvent.click(within(preserved).getByRole("button", { name: "Close" })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); fireEvent.click(within(packageRow()).getByRole("button", { name: /frozen-letter\.txt/ })); expect(await section("assetsSubmittedPackages").findByRole("alert")).toHaveTextContent(copy("assetsPackageDownloadFailed")); expect(screen.getByText("Original CV · v2")).toBeInTheDocument(); expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/3"); expect(downloads).toBe(1); fireEvent.click(within(packageRow()).getByRole("button", { name: /frozen-letter\.txt/ })); await waitFor(() => expect(createUrl).toHaveBeenCalledWith(blob)); expect(downloads).toBe(2); expect(downloadedNames).toEqual(["frozen-letter.txt"]); expect(revokeUrl).toHaveBeenCalledWith("blob:owned-frozen-fixture"); expect(mockedApi.post.mock.calls).toEqual([["/jobapplications/7/submitted-packages"], ["/jobapplications/7/submitted-packages"]]); expect(mockedApi.put).not.toHaveBeenCalled(); } finally { if (originalCreate) Object.defineProperty(URL, "createObjectURL", originalCreate); else Reflect.deleteProperty(URL, "createObjectURL"); if (originalRevoke) Object.defineProperty(URL, "revokeObjectURL", originalRevoke); else Reflect.deleteProperty(URL, "revokeObjectURL"); } }); test("all five asset read owners expose GET-only recovery without false empty history or locale refetch", async () => { mockedApi.get.mockRejectedValue({ response: { status: 503 } }); render(<>); const owners = ["workspaceCv", "assetsTailoring", "assetsSubmittedPackages", "workspaceCoverLetter", "coverAiTitle"]; await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(5)); for (const key of owners) expect(await section(key).findByRole("alert")).toHaveTextContent(copy("assetsLoadFailed")); expect(screen.queryByText(copy("assetsNoVersions"))).not.toBeInTheDocument(); expect(screen.queryByText(copy("assetsNoSubmittedPackages"))).not.toBeInTheDocument(); expect(screen.queryByText(copy("coverAiSelectCv"))).not.toBeInTheDocument(); changeLanguage("nb"); for (const key of owners) expect(section(key).getByRole("alert")).toHaveTextContent(copy("assetsLoadFailed")); expect(mockedApi.get).toHaveBeenCalledTimes(5); routeGet(); const endpoints = ["cv", "tailoring", "submitted-packages", "cover-letter", "cv"]; for (let index = 0; index < owners.length; index++) { fireEvent.click(section(owners[index]).getByRole("button", { name: copy("retry") })); await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(6 + index)); expect(mockedApi.get).toHaveBeenNthCalledWith(6 + index, `/jobapplications/7/${endpoints[index]}`); await waitFor(() => expect(section(owners[index]).queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument()); } expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Dear team"); expect(screen.getByText("v2")).toBeInTheDocument(); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("keyed job changes stop a late CV copy from attaching and never reuse another job's saved-copy recovery", async () => { const jobBCv = { ...cv, attachedVariantId: 9, attachedVariantName: "Job B CV", attachedVersion: 6, availableVariants: [{ ...cv.availableVariants[0], id: 9, name: "Job B CV", version: 6, jobApplicationId: 8 }], }; mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith("/tailoring") ? tailoring : url === "/jobapplications/8/cv" ? jobBCv : cv, } as any)); const oldCopy = deferred(); mockedApi.post.mockReturnValueOnce(oldCopy.promise); const mounted = render(); const initialLocation = window.location.href; fireEvent.click(await screen.findByRole("button", { name: copy("assetsDuplicateForJob") })); expect(mockedApi.post).toHaveBeenCalledWith("/cv/variants/3/duplicate", { name: copy("assetsTailoredCopyName", { name: "Backend CV" }), }); mounted.rerender(withProviders()); await waitFor(() => expect(screen.getByRole("combobox", { name: copy("assetsAttachedCv") })).toHaveTextContent("Job B CV")); await act(async () => { oldCopy.resolve({ data: { id: 83 } }); await oldCopy.promise; }); expect(mockedApi.put).not.toHaveBeenCalled(); expect(window.location.href).toBe(initialLocation); expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/9"); expect(screen.getByRole("button", { name: copy("assetsDuplicateForJob") })).toBeEnabled(); expect(screen.queryByRole("button", { name: copy("assetsAttachCreatedCopy") })).not.toBeInTheDocument(); mounted.rerender(withProviders()); mockedApi.post.mockResolvedValueOnce({ data: { id: 84 } } as any); mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }); fireEvent.click(await screen.findByRole("button", { name: copy("assetsDuplicateForJob") })); await waitFor(() => expect(screen.getByRole("button", { name: copy("assetsAttachCreatedCopy") })).toBeEnabled()); expect(await section("workspaceCv").findByText(copy("assetsAttachCvFailed"))).toBeInTheDocument(); expect(mockedApi.put).toHaveBeenCalledTimes(1); expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cv", { variantId: 84 }); mounted.rerender(withProviders()); await waitFor(() => expect(screen.getByRole("combobox", { name: copy("assetsAttachedCv") })).toHaveTextContent("Job B CV")); expect(screen.queryByRole("button", { name: copy("assetsAttachCreatedCopy") })).not.toBeInTheDocument(); expect(screen.queryByText(copy("assetsCopyCreatedHelp"))).not.toBeInTheDocument(); const jobBCopy = deferred(); mockedApi.post.mockReturnValueOnce(jobBCopy.promise); fireEvent.click(screen.getByRole("button", { name: copy("assetsDuplicateForJob") })); expect(mockedApi.post).toHaveBeenCalledTimes(3); expect(mockedApi.post).toHaveBeenLastCalledWith("/cv/variants/9/duplicate", { name: copy("assetsTailoredCopyName", { name: "Job B CV" }), }); expect(mockedApi.put).toHaveBeenCalledTimes(1); expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/9"); expect(window.location.href).toBe(initialLocation); mounted.unmount(); }); test("keyed job changes keep job B's answer drafts and callbacks independent of job A's late save", async () => { const saveA = deferred(); mockedApi.put.mockReturnValueOnce(saveA.promise); const onSavedA = jest.fn(); const onSavedB = jest.fn(); const onDirtyB = jest.fn(); const mounted = render(); fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Pending A answer" } }); fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Pending A message" } }); fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") })); expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/application-drafts", { applicationAnswerDraft: "Pending A answer", recruiterMessageDraft: "Pending A message", }); mounted.rerender(withProviders()); fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Unsaved B answer" } }); fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Unsaved B message" } }); await act(async () => { saveA.resolve({ data: undefined }); await saveA.promise; }); expect(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") })).toHaveValue("Unsaved B answer"); expect(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") })).toHaveValue("Unsaved B message"); expect(screen.getByRole("button", { name: copy("assetsSaveDrafts") })).toBeEnabled(); expect(onDirtyB).toHaveBeenLastCalledWith(true); expect(onSavedA).not.toHaveBeenCalled(); expect(onSavedB).not.toHaveBeenCalled(); expect(mockedApi.put).toHaveBeenCalledTimes(1); mockedApi.put.mockResolvedValueOnce({ data: undefined } as any); fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") })); await waitFor(() => expect(onSavedB).toHaveBeenCalledTimes(1)); expect(mockedApi.put).toHaveBeenLastCalledWith("/jobapplications/8/application-drafts", { applicationAnswerDraft: "Unsaved B answer", recruiterMessageDraft: "Unsaved B message", }); expect(onSavedA).not.toHaveBeenCalled(); });