feat(ui): migrate Career Profile structured editor
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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<typeof api>;
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((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(<ToastProvider><I18nProvider><CareerProfilePage /></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
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<any>();
|
||||
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<any>();
|
||||
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.profileCvVersionRestore]) {
|
||||
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<any>();
|
||||
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);
|
||||
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.profileCvVersionRestore, 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();
|
||||
});
|
||||
Reference in New Issue
Block a user