288 lines
17 KiB
TypeScript
288 lines
17 KiB
TypeScript
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.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<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 + 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();
|
|
});
|