fix(rd21): retain draft revisions and qualify conflict recovery
This commit is contained in:
@@ -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"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user