import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { api } from "./api"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations } from "./i18n/translations"; import { emptyStructuredCv, StructuredCvProfile } from "./profileCv"; import { ToastProvider } from "./toast"; import CareerProfilePage from "./views/CareerProfilePage"; jest.mock("./api", () => ({ getApiErrorMessage: jest.requireActual("./api").getApiErrorMessage, api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn() }, })); jest.mock("./components/CropImageDialog", () => () => null); const mockedApi = api as jest.Mocked; const en = translations.en; const nb = translations.nb; const rawText = "Original stored source\nNo silent import acceptance"; let profile: StructuredCvProfile; let provider: "local" | "external"; function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function sourceResponse() { return { data: { profile, cvText: rawText, completeness: { percent: 72, missing: [], sections: [] } } }; } function renderPage() { return render(); } async function ready() { await waitFor(() => expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Recorded owner")); await waitFor(() => expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeEnabled()); } async function changeLocale() { await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" }))); } function readCount(url: string) { return mockedApi.get.mock.calls.filter(([requested]) => requested === url).length; } function editor() { const element = document.getElementById("career-profile-editor"); expect(element).not.toBeNull(); return within(element!); } beforeEach(() => { jest.resetAllMocks(); window.localStorage.clear(); window.history.replaceState({}, "", "/career?section=profile&retained=1#editor"); provider = "local"; profile = { ...emptyStructuredCv(), metadata: { profileVersion: 4, fields: {} }, contact: { fullName: "Recorded owner", headline: "Backend engineer", email: "career@example.test", phone: "+47 12345678", location: "Oslo", website: "https://example.test", linkedIn: "https://example.test/linkedin", gitHub: "https://example.test/github", links: [{ label: "Portfolio", url: "https://example.test/portfolio" }], }, summary: ["Recorded summary"], skills: ["C#", "SQL"], interests: ["Walking"], awards: ["Recorded award"], publications: ["Recorded publication"], organisations: ["Recorded organisation"], references: ["Recorded reference"], jobs: [{ id: "opaque-job-key", title: "Recorded role", company: "Recorded company", location: "Oslo", start: "2020", end: "Current", isCurrent: true, bullets: ["Recorded outcome"], skills: ["C#"] }], education: [{ id: "opaque-education-key", qualification: "Recorded degree", qualificationLevel: "Master", institution: "Recorded school", location: "Oslo", start: "2016", end: "2019", details: ["Recorded distinction"] }], certifications: [{ id: "opaque-certification-key", name: "Recorded certification", issuer: "Recorded issuer", date: "2024", details: ["Recorded credential detail"] }], projects: [{ id: "opaque-project-key", name: "Recorded project", role: "Owner", start: "2023", end: "2024", bullets: ["Recorded project outcome"], skills: ["SQL"] }], languages: [{ name: "English", level: "Professional", notes: "Recorded language note" }], otherSections: [{ title: "Volunteering", items: ["Recorded community work"] }], sections: [{ name: "Original source", content: "No silent import acceptance", wordCount: 5 }], }; mockedApi.get.mockImplementation((url: string) => { if (url === "/career/profile") return Promise.resolve(sourceResponse() as any); if (url === "/auth/me") return Promise.resolve({ data: { id: "editor-owner", provider, email: "account@example.test", displayName: "Recorded owner" } } as any); if (url === "/career/profile/versions") return Promise.resolve({ data: [ { version: 4, source: "manual", createdAtUtc: "2026-10-12T01:00:00Z", isCurrent: true }, { version: 3, source: "manual", createdAtUtc: "2026-10-11T01:00:00Z", isCurrent: false }, ] } as any); if (url === "/profile-cv/runs") return Promise.resolve({ data: [] } as any); return Promise.resolve({ data: {} } as any); }); mockedApi.put.mockResolvedValue({ data: {} } as any); mockedApi.post.mockResolvedValue({ data: {} } as any); }); test("a locale update retains dirty structured and raw drafts without reading their source again", async () => { renderPage(); await ready(); fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Unsaved canonical owner" } }); fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab })); fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) })); fireEvent.change(screen.getByLabelText(en.profileCvTextLabel), { target: { value: "Unsaved raw source" } }); fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab })); const before = [readCount("/career/profile"), readCount("/auth/me"), readCount("/career/profile/versions"), readCount("/profile-cv/runs")]; await changeLocale(); expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Unsaved canonical owner"); expect([readCount("/career/profile"), readCount("/auth/me"), readCount("/career/profile/versions"), readCount("/profile-cv/runs")]).toEqual(before); fireEvent.click(screen.getByRole("tab", { name: nb.careerWorkspaceImportTab })); fireEvent.click(screen.getByRole("button", { name: new RegExp(nb.profileCvRawPanelTitle) })); expect(screen.getByLabelText(nb.profileCvTextLabel)).toHaveValue("Unsaved raw source"); expect(window.location.search).toContain("retained=1"); expect(window.location.hash).toBe("#editor"); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("a pending initial read keeps one request and reports its failure in the latest locale without language writes", async () => { const pending = deferred(); const read = mockedApi.get.getMockImplementation()!; mockedApi.get.mockImplementation((url: string) => url === "/career/profile" ? pending.promise : read(url)); renderPage(); expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeDisabled(); expect(screen.queryByRole("button", { name: en.saving })).not.toBeInTheDocument(); await changeLocale(); await act(async () => { pending.reject({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } }); }); expect(readCount("/career/profile")).toBe(1); expect(readCount("/auth/me")).toBe(1); const failure = await editor().findByRole("alert"); expect(failure).toHaveTextContent(nb.profileLoadFailed); expect(failure).not.toHaveTextContent("Request failed with status code 500"); expect(screen.queryByRole("button", { name: nb.saving })).not.toBeInTheDocument(); await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "en" }))); expect(editor().getByRole("alert")).toHaveTextContent(en.profileLoadFailed); expect(editor().getByRole("alert")).not.toHaveTextContent(nb.profileLoadFailed); expect(readCount("/career/profile")).toBe(1); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("a held Save locks every editor mutation, raw input and history restore until its snapshot settles", async () => { const pending = deferred(); mockedApi.put.mockReturnValue(pending.promise); renderPage(); await ready(); fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Snapshot owner" } }); fireEvent.click(screen.getByRole("button", { name: /Version history/ })); fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges })); try { expect(mockedApi.put).toHaveBeenCalledTimes(1); const snapshot = mockedApi.put.mock.calls[0][1] as { profile: StructuredCvProfile; cvText: string }; expect(snapshot.profile.contact.fullName).toBe("Snapshot owner"); expect(snapshot.cvText).toBe(rawText); expect(screen.getAllByRole("textbox").filter((field) => !(field as HTMLInputElement).disabled).map((field) => field.getAttribute("aria-label") || field.id)).toEqual([]); for (const name of [en.profileCvAddLink, en.profileCvStructuredAddLanguage, en.profileCvStructuredAddJob, en.profileCvStructuredAddEducation, en.profileCvStructuredAddOtherSection, en.careerRestoreVersion.replace("{version}", "3")]) { expect(screen.getByRole("button", { name })).toBeDisabled(); } for (const button of screen.getAllByRole("button", { name: /^(Remove|Delete custom link|Bold|Italic|Underline|Link$)/ })) expect(button).toBeDisabled(); expect(screen.getByRole("button", { name: en.saving })).toBeDisabled(); fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab })); fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) })); expect(screen.getByLabelText(en.profileCvTextLabel)).toBeDisabled(); expect(mockedApi.post).not.toHaveBeenCalled(); } finally { await act(async () => { pending.resolve({ data: {} }); await pending.promise; }); } fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab })); expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Snapshot owner"); expect(screen.getByLabelText(en.profileCvContactFullName)).toBeEnabled(); expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeEnabled(); expect(mockedApi.put).toHaveBeenCalledTimes(1); }); test("two Save activations before React commits pending state send only one canonical PUT", async () => { const pending = deferred(); mockedApi.put.mockReturnValue(pending.promise); renderPage(); await ready(); const save = screen.getByRole("button", { name: en.profileSaveChanges }); try { act(() => { save.dispatchEvent(new MouseEvent("click", { bubbles: true })); save.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(mockedApi.put).toHaveBeenCalledTimes(1); expect(mockedApi.post).not.toHaveBeenCalled(); } finally { await act(async () => { pending.resolve({ data: {} }); await pending.promise; }); } }); test("a failed Save retains a scoped localized error and draft for retry through the same Save button", async () => { localStorage.setItem("uiLanguage", "nb"); mockedApi.put.mockRejectedValueOnce({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } }); renderPage(); await waitFor(() => expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Recorded owner")); fireEvent.change(screen.getByLabelText(nb.profileCvContactFullName), { target: { value: "Beholdt utkast" } }); fireEvent.click(screen.getByRole("button", { name: nb.profileSaveChanges })); const failure = await editor().findByRole("alert"); expect(failure).toHaveTextContent(nb.profileUpdateFailed); expect(failure).not.toHaveTextContent("Request failed with status code 500"); expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Beholdt utkast"); expect(screen.getByText(nb.assetsUnsavedChanges)).toBeInTheDocument(); await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "en" }))); expect(editor().getByRole("alert")).toHaveTextContent(en.profileUpdateFailed); expect(editor().getByRole("alert")).not.toHaveTextContent(nb.profileUpdateFailed); await changeLocale(); const reads = mockedApi.get.mock.calls.length; const save = screen.getByRole("button", { name: nb.profileSaveChanges }); expect(save).toBeEnabled(); expect(mockedApi.put).toHaveBeenCalledTimes(1); fireEvent.click(save); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2)); expect(mockedApi.put.mock.calls[1]).toEqual(mockedApi.put.mock.calls[0]); await waitFor(() => expect(editor().queryByRole("alert")).not.toBeInTheDocument()); expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Beholdt utkast"); expect(screen.queryByText(nb.assetsUnsavedChanges)).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(reads + 1); expect(readCount("/career/profile/versions")).toBe(2); expect(readCount("/career/profile")).toBe(1); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("the editor exposes named H2/H3 groups and contextual repeated-item remove controls", async () => { renderPage(); await ready(); expect(screen.getByRole("heading", { name: en.profileCvStructuredEditor, level: 2 })).toBeInTheDocument(); expect(screen.getByRole("region", { name: en.profileCvStructuredEditor })).toBeInTheDocument(); for (const name of [en.careerProfileContactSection, en.profileCvStructuredLanguages, en.profileCvStructuredJobs, en.profileCvStructuredEducation, en.profileCvStructuredOtherSections]) { expect(screen.getByRole("heading", { name, level: 3 })).toBeInTheDocument(); expect(screen.getByRole("region", { name })).toBeInTheDocument(); } for (const name of [en.careerProfileRemoveJob, en.careerProfileRemoveEducation, en.careerProfileRemoveLanguage, en.careerProfileRemoveOtherSection]) { expect(screen.getByRole("button", { name: name.replace("{index}", "1") })).toBeEnabled(); } expect(screen.getByRole("button", { name: en.profileCvDeleteCustomLink.replace("{index}", "1") })).toBeEnabled(); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("manual edits preserve long-tail data, custom links, opaque IDs and modeled fields without editor controls", async () => { renderPage(); await ready(); fireEvent.change(screen.getByLabelText(en.profileCvStructuredAwards), { target: { value: "Recorded award\nManual award" } }); fireEvent.change(screen.getByLabelText(en.profileCvLinkLabel), { target: { value: "Edited portfolio" } }); fireEvent.change(screen.getByLabelText(en.profileCvJobTitle), { target: { value: "Edited role" } }); fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab })); fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) })); fireEvent.change(screen.getByLabelText(en.profileCvTextLabel), { target: { value: "Manually retained source\nNo extraction requested" } }); fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab })); fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1)); const saved = mockedApi.put.mock.calls[0][1] as { profile: StructuredCvProfile; cvText: string }; expect(saved).toEqual({ profile: { ...profile, awards: ["Recorded award", "Manual award"], contact: { ...profile.contact, links: [{ label: "Edited portfolio", url: "https://example.test/portfolio" }] }, jobs: [{ ...profile.jobs[0], title: "Edited role" }], }, cvText: "Manually retained source\nNo extraction requested" }); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.patch).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); test("a read-only provider cannot edit canonical fields or trigger canonical writes", async () => { provider = "external"; renderPage(); await waitFor(() => expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Recorded owner")); fireEvent.click(screen.getByRole("button", { name: /Version history/ })); expect(screen.getAllByRole("textbox").filter((field) => !(field as HTMLInputElement).disabled)).toEqual([]); for (const name of [en.profileSaveChanges, en.careerRestoreVersion.replace("{version}", "3"), en.profileCvAddLink, en.profileCvStructuredAddLanguage, en.profileCvStructuredAddJob, en.profileCvStructuredAddEducation, en.profileCvStructuredAddOtherSection]) { const control = screen.getByRole("button", { name }); expect(control).toBeDisabled(); control.click(); } for (const button of screen.getAllByRole("button", { name: /^(Remove|Delete custom link|Bold|Italic|Underline|Link$)/ })) expect(button).toBeDisabled(); fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab })); fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) })); expect(screen.getByLabelText(en.profileCvTextLabel)).toBeDisabled(); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.patch).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); });