fix(ui): preserve application drafts and recovery states
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||
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 render = (ui: React.ReactElement) => rtlRender(<I18nProvider>{ui}</I18nProvider>);
|
||||
const withProviders = (ui: React.ReactElement) => <I18nProvider><ConfirmProvider>{ui}</ConfirmProvider></I18nProvider>;
|
||||
const render = (ui: React.ReactElement) => rtlRender(withProviders(ui));
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
@@ -17,7 +20,7 @@ jest.mock("./api", () => ({
|
||||
post: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
|
||||
getApiErrorMessage: (_e: unknown, fallback = "Request failed.") => fallback,
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
@@ -69,10 +72,37 @@ function routeGet(overrides: Record<string, 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"));
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem("uiLanguage");
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function copy(key: string, params: Record<string, string | number> = {}, language: UiLanguage = document.documentElement.lang === "nb-NO" ? "nb" : "en") {
|
||||
const template = (translations[language] as Record<string, string>)[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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((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: [] };
|
||||
@@ -302,7 +332,7 @@ test("a failed load surfaces an error", async () => {
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
|
||||
expect(await section("workspaceCoverLetter").findByText(/Could not load this section/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------- Application package drafts ----------
|
||||
@@ -355,3 +385,350 @@ test("application package drafts can be cleared without deleting ordinary notes"
|
||||
{ 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(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
|
||||
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(<ApplicationPackageDraftsSection jobId={7} initialApplicationAnswer="Saved answer" initialRecruiterMessage="Saved message" onDirtyChange={onDirtyChange} onSaved={onSaved} />);
|
||||
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(<ApplicationCvSection jobId={7} includePackages={false} />);
|
||||
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<any>();
|
||||
mockedApi.post.mockImplementation(() => write.promise);
|
||||
const onDirtyChange = jest.fn();
|
||||
render(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
|
||||
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<any>();
|
||||
mockedApi.put.mockImplementation(() => write.promise);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
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<any>();
|
||||
mockedApi.post.mockImplementation(() => generation.promise);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
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: "<html><body>Frozen CV fixture</body></html>", 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(<ApplicationCvSection jobId={7} />);
|
||||
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<HTMLElement>(".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(<><ApplicationCvSection jobId={7} includePackages={false} /><SubmittedPackagesSection jobId={7} allowCapture={false} /><ApplicationCoverLetterSection jobId={7} /></>);
|
||||
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<any>();
|
||||
mockedApi.post.mockReturnValueOnce(oldCopy.promise);
|
||||
const mounted = render(<ApplicationCvSection key={7} jobId={7} includePackages={false} />);
|
||||
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(<ApplicationCvSection key={8} jobId={8} includePackages={false} />));
|
||||
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(<ApplicationCvSection key={7} jobId={7} includePackages={false} />));
|
||||
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(<ApplicationCvSection key={8} jobId={8} includePackages={false} />));
|
||||
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<any>();
|
||||
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<any>();
|
||||
mockedApi.put.mockReturnValueOnce(saveA.promise);
|
||||
const onSavedA = jest.fn();
|
||||
const onSavedB = jest.fn();
|
||||
const onDirtyB = jest.fn();
|
||||
const mounted = render(<ApplicationPackageDraftsSection key={7} jobId={7}
|
||||
initialApplicationAnswer="Saved A answer" initialRecruiterMessage="Saved A message" onSaved={onSavedA} />);
|
||||
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(<ApplicationPackageDraftsSection key={8} jobId={8}
|
||||
initialApplicationAnswer="Saved B answer" initialRecruiterMessage="Saved B message" onSaved={onSavedB} onDirtyChange={onDirtyB} />));
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user