fix(rd21): retain draft revisions and qualify conflict recovery

This commit is contained in:
cesnimda
2026-10-23 12:34:34 +02:00
parent 12f342c09c
commit 05a738693e
53 changed files with 813 additions and 192 deletions
+52 -22
View File
@@ -52,6 +52,7 @@ const tailoring = {
};
const coverLetter = {
revision: 7,
text: "Dear team",
currentVersion: 2,
versions: [
@@ -232,7 +233,7 @@ test("editing marks the draft dirty and saving sends the new text", async () =>
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/cover-letter",
{ text: "Dear hiring team", source: "manual", aiAction: undefined },
{ text: "Dear hiring team", source: "manual", aiAction: undefined, expectedVersion: 7 },
));
});
@@ -255,7 +256,7 @@ test("restoring an old version calls the restore endpoint", async () => {
fireEvent.click(await screen.findByRole("button", { name: /Restore version 1/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
"/jobapplications/7/cover-letter/versions/1/restore"));
"/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 }));
});
test("an empty cover letter offers the template and an empty history", async () => {
@@ -321,7 +322,7 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
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" },
{ text: "A tailored, truthful suggestion.", source: "ai", aiAction: "generate", expectedVersion: 7 },
));
});
@@ -355,11 +356,11 @@ test("a failed load surfaces an error", async () => {
// ---------- Application package drafts ----------
test("application answer and recruiter drafts save from the dedicated workspace", async () => {
mockedApi.put.mockResolvedValue({ data: undefined } as any);
mockedApi.put.mockResolvedValue({ data: undefined, headers: { "x-job-version": "8" } } as any);
const onSaved = jest.fn();
render(
<ApplicationPackageDraftsSection
<ApplicationPackageDraftsSection initialRevision={7}
jobId={7}
initialApplicationAnswer="Saved answer"
initialRecruiterMessage="Saved recruiter note"
@@ -376,17 +377,17 @@ test("application answer and recruiter drafts save from the dedicated workspace"
"/jobapplications/7/application-drafts",
{
applicationAnswerDraft: "Edited answer",
recruiterMessageDraft: "Edited recruiter note",
recruiterMessageDraft: "Edited recruiter note", expectedVersion: 7,
},
));
expect(onSaved).toHaveBeenCalled();
});
test("application package drafts can be cleared without deleting ordinary notes", async () => {
mockedApi.put.mockResolvedValue({ data: undefined } as any);
mockedApi.put.mockResolvedValue({ data: undefined, headers: { "x-job-version": "8" } } as any);
render(
<ApplicationPackageDraftsSection
<ApplicationPackageDraftsSection initialRevision={7}
jobId={7}
initialApplicationAnswer="Saved answer"
initialRecruiterMessage="Saved recruiter note"
@@ -399,7 +400,7 @@ test("application package drafts can be cleared without deleting ordinary notes"
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/application-drafts",
{ applicationAnswerDraft: "", recruiterMessageDraft: "" },
{ applicationAnswerDraft: "", recruiterMessageDraft: "", expectedVersion: 7 },
));
});
@@ -428,7 +429,7 @@ test("failed cover-letter saving retains editable dirty text and original Save r
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 };
const payload = { text, source: "manual", aiAction: undefined, expectedVersion: 7 };
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));
@@ -437,10 +438,10 @@ test("failed cover-letter saving retains editable dirty text and original Save r
});
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);
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ status: 204, headers: { "x-job-version": "8" } } as any);
const onDirtyChange = jest.fn();
const onSaved = jest.fn();
render(<ApplicationPackageDraftsSection jobId={7} initialApplicationAnswer="Saved answer" initialRecruiterMessage="Saved message" onDirtyChange={onDirtyChange} onSaved={onSaved} />);
render(<ApplicationPackageDraftsSection initialRevision={7} 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") }));
@@ -456,7 +457,7 @@ test("failed application-answer saving keeps both dirty drafts editable and retr
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" };
const payload = { applicationAnswerDraft: "Retained answer", recruiterMessageDraft: "Retained recruiter draft", expectedVersion: 7 };
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/application-drafts", payload);
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/application-drafts", payload);
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
@@ -505,7 +506,7 @@ test("dirty version restore cancels without a write and confirmed held restore a
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");
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 });
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: /Restore version 1/i })).toBeDisabled();
await act(async () => { write.resolve({ data: {
@@ -533,7 +534,7 @@ test("held letter Save submits one snapshot and prevents an AI Apply from replac
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(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined, expectedVersion: 7 });
expect(apply).toBeDisabled();
fireEvent.click(apply);
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot");
@@ -563,7 +564,7 @@ test("held AI generation locks its controls and preserves captured action attrib
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" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate", expectedVersion: 7 }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
@@ -719,20 +720,20 @@ test("keyed job changes keep job B's answer drafts and callbacks independent of
const onSavedA = jest.fn();
const onSavedB = jest.fn();
const onDirtyB = jest.fn();
const mounted = render(<ApplicationPackageDraftsSection key={7} jobId={7}
const mounted = render(<ApplicationPackageDraftsSection initialRevision={7} 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",
applicationAnswerDraft: "Pending A answer", recruiterMessageDraft: "Pending A message", expectedVersion: 7,
});
mounted.rerender(withProviders(<ApplicationPackageDraftsSection key={8} jobId={8}
mounted.rerender(withProviders(<ApplicationPackageDraftsSection initialRevision={7} 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; });
await act(async () => { saveA.resolve({ data: undefined, headers: { "x-job-version": "8" } }); 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();
@@ -741,11 +742,40 @@ test("keyed job changes keep job B's answer drafts and callbacks independent of
expect(onSavedB).not.toHaveBeenCalled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
mockedApi.put.mockResolvedValueOnce({ data: undefined } as any);
mockedApi.put.mockResolvedValueOnce({ data: undefined, headers: { "x-job-version": "8" } } 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",
applicationAnswerDraft: "Unsaved B answer", recruiterMessageDraft: "Unsaved B message", expectedVersion: 7,
});
expect(onSavedA).not.toHaveBeenCalled();
});
test("a parent refresh cannot attach a newer revision to dirty application drafts", async () => {
mockedApi.put.mockResolvedValue({ data: undefined, headers: { "x-job-version": "8" } } as any);
const mounted = render(<ApplicationPackageDraftsSection jobId={7} initialRevision={7} initialApplicationAnswer="Original answer" initialRecruiterMessage="Original message" />);
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Local answer" } });
mounted.rerender(withProviders(<ApplicationPackageDraftsSection jobId={7} initialRevision={11} initialApplicationAnswer="Remote answer" initialRecruiterMessage="Remote message" />));
expect(screen.getByLabelText(copy("assetsApplicationAnswer"))).toHaveValue("Local answer");
expect(screen.getByLabelText(copy("assetsRecruiterMessage"))).toHaveValue("Original message");
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/application-drafts", { applicationAnswerDraft: "Local answer", recruiterMessageDraft: "Original message", expectedVersion: 7 }));
await waitFor(() => expect(screen.queryByText(copy("assetsUnsavedChanges"))).not.toBeInTheDocument());
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Next answer" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith("/jobapplications/7/application-drafts", { applicationAnswerDraft: "Next answer", recruiterMessageDraft: "Original message", expectedVersion: 8 }));
});
test.each([409, 0])("cover-letter rejection (%s) retains draft and source revision without a fresh read", async (status) => {
routeGet(); mockedApi.put.mockRejectedValue({ response: { status, data: { code: "revision_conflict" } } });
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Retained conflict draft" } });
const reads = mockedApi.get.mock.calls.length;
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
await section("workspaceCoverLetter").findByRole("alert");
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Retained conflict draft");
expect(screen.getByText(copy("assetsUnsavedChanges"))).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Retained conflict draft", source: "manual", aiAction: undefined, expectedVersion: 7 });
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
if (status === 409) expect(section("workspaceCoverLetter").getByRole("alert")).toHaveTextContent(copy("revisionConflict"));
});