fix(rd21): expose committed revisions and refresh answer sources
This commit is contained in:
@@ -148,6 +148,7 @@ builder.Services.AddCors(options =>
|
||||
policy.WithOrigins(corsOrigins.Select(x => x.Trim().TrimEnd('/')).Where(x => x.Length > 0).ToArray())
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.WithExposedHeaders("X-Job-Version")
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +89,72 @@ async function createJob(request: APIRequestContext, owner: Owner) {
|
||||
data: { companyId: company.id, jobTitle: 'Original job title', status: 'Saved', notes: 'Retain original notes', location: 'Trondheim' } }), 201);
|
||||
}
|
||||
|
||||
test('RD21 cross-origin browser exposes committed revisions and native Answers saves advance consecutively', async ({ page, owner }, info) => {
|
||||
const job = await createJob(page.request, owner);
|
||||
await page.goto(`/jobs/${job.id}?section=application&sub=answers`);
|
||||
const answer = page.getByRole('textbox', { name: t.assetsApplicationAnswer, exact: true });
|
||||
await expect(answer).toBeEditable();
|
||||
const browserCommit = await page.evaluate(async ({ api, job, headers }) => {
|
||||
const response = await fetch(`${api}/jobapplications/${job.id}/application-drafts`, { method: 'PUT', credentials: 'include',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ applicationAnswerDraft: 'Browser-visible commit', recruiterMessageDraft: '', expectedVersion: job.version }) });
|
||||
return { status: response.status, revision: response.headers.get('X-Job-Version') };
|
||||
}, { api, job, headers: owner.headers });
|
||||
expect(browserCommit.status).toBe(204);
|
||||
expect(db(info, owner, job.id).job).toMatchObject({ Version: job.version + 1, Notes: expect.stringContaining('<<<APPLICATION_ANSWER_DRAFT>>>\nBrowser-visible commit\n<<<END_APPLICATION_ANSWER_DRAFT>>>') });
|
||||
expect(browserCommit.revision).toBe(String(job.version + 1));
|
||||
await page.reload();
|
||||
await expect(answer).toHaveValue('Browser-visible commit');
|
||||
for (const [index, text] of ['First native answer', 'Second native answer'].entries()) {
|
||||
await answer.fill(text);
|
||||
const responsePromise = page.waitForResponse(response => response.url().endsWith(`/jobapplications/${job.id}/application-drafts`) && response.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: t.assetsSaveDrafts, exact: true }).click();
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(204);
|
||||
expect(Number(response.headers()['x-job-version'])).toBe(job.version + index + 2);
|
||||
await expect(page.getByRole('button', { name: t.assetsSaveDrafts, exact: true })).toBeDisabled();
|
||||
await expect(page.getByText('Missing committed job revision.', { exact: true })).toHaveCount(0);
|
||||
expect(db(info, owner, job.id).job).toMatchObject({ Version: job.version + index + 2, Notes: expect.stringContaining(`<<<APPLICATION_ANSWER_DRAFT>>>\n${text}\n<<<END_APPLICATION_ANSWER_DRAFT>>>`) });
|
||||
}
|
||||
await attach(info, 'rd21-cross-origin-native-answers', { browserCommit, database: db(info, owner, job.id) });
|
||||
});
|
||||
|
||||
test('RD21 cover-letter save then Answers entry waits for fresh coherent source before a native save', async ({ page, owner }, info) => {
|
||||
const job = await createJob(page.request, owner);
|
||||
await page.goto(`/jobs/${job.id}?section=application&sub=letter`);
|
||||
const letter = page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
|
||||
await expect(letter).toBeEditable();
|
||||
await letter.fill('Acknowledged cover letter retained');
|
||||
const saved = page.waitForResponse(response => response.url().endsWith(`/jobapplications/${job.id}/cover-letter`) && response.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: t.save, exact: true }).click();
|
||||
const letterResponse = await saved;
|
||||
expect(letterResponse.status()).toBe(200);
|
||||
const letterRevision = (await letterResponse.json()).revision;
|
||||
await expect(page.getByRole('button', { name: t.save, exact: true })).toBeDisabled();
|
||||
expect(db(info, owner, job.id).job).toMatchObject({ Version: letterRevision, CoverLetterText: 'Acknowledged cover letter retained' });
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
let started!: () => void;
|
||||
const requested = new Promise<void>(resolve => { started = resolve; });
|
||||
await page.route(`**/api/jobapplications/${job.id}/workspace`, async route => { started(); await gate; await route.continue(); });
|
||||
await page.getByRole('tab', { name: t.workspaceAnswers, exact: true }).click();
|
||||
await expect(page.getByRole('tab', { name: t.workspaceAnswers, exact: true })).toHaveAttribute('aria-selected', 'true');
|
||||
const answer = page.getByRole('textbox', { name: t.assetsApplicationAnswer, exact: true });
|
||||
try {
|
||||
await expect(answer).toHaveCount(0);
|
||||
await requested;
|
||||
} finally { release(); }
|
||||
await expect(answer).toBeEditable();
|
||||
await answer.fill('Answer after cover letter');
|
||||
const answerSaved = page.waitForResponse(response => response.url().endsWith(`/jobapplications/${job.id}/application-drafts`) && response.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: t.assetsSaveDrafts, exact: true }).click();
|
||||
const response = await answerSaved;
|
||||
expect(response.request().postDataJSON().expectedVersion).toBe(letterRevision);
|
||||
expect(response.status()).toBe(204);
|
||||
await expect(page.getByRole('button', { name: t.assetsSaveDrafts, exact: true })).toBeDisabled();
|
||||
expect(db(info, owner, job.id).job).toMatchObject({ Version: letterRevision + 1, CoverLetterText: 'Acknowledged cover letter retained', Notes: expect.stringContaining('<<<APPLICATION_ANSWER_DRAFT>>>\nAnswer after cover letter\n<<<END_APPLICATION_ANSWER_DRAFT>>>') });
|
||||
await attach(info, 'rd21-cover-letter-answers-entry', { letterRevision, committedRevision: response.headers()['x-job-version'], database: db(info, owner, job.id) });
|
||||
});
|
||||
|
||||
test('RD21 original two-session ordering rejects stale snapshots and preserves each acknowledged SQLite commit across navigation and a fresh session', async ({ page, owner, second, playwright, email }, info) => {
|
||||
await seedCareer(page.request, owner);
|
||||
const careerA = await json<Career>(await page.request.get(`${api}/career/profile`));
|
||||
|
||||
@@ -27,7 +27,7 @@ jest.mock("./components/ApplicationAssets", () => ({
|
||||
</div>
|
||||
),
|
||||
ApplicationCvSection: () => <div>CV section</div>,
|
||||
ApplicationPackageDraftsSection: ({ jobId, initialApplicationAnswer, initialRecruiterMessage, onDirtyChange }: { jobId: number; initialApplicationAnswer: string; initialRecruiterMessage: string; onDirtyChange?: (dirty: boolean) => void }) => <div>Application drafts section<output data-testid="answer-props">{jobId}|{initialApplicationAnswer}|{initialRecruiterMessage}</output><button onClick={() => onDirtyChange?.(true)}>Make answers dirty</button></div>,
|
||||
ApplicationPackageDraftsSection: ({ jobId, initialApplicationAnswer, initialRecruiterMessage, initialRevision, onDirtyChange }: { jobId: number; initialApplicationAnswer: string; initialRecruiterMessage: string; initialRevision: number; onDirtyChange?: (dirty: boolean) => void }) => <div>Application drafts section<output data-testid="answer-props">{jobId}|{initialApplicationAnswer}|{initialRecruiterMessage}|{initialRevision}</output><button onClick={() => onDirtyChange?.(true)}>Make answers dirty</button></div>,
|
||||
SubmittedPackagesSection: () => <div>Frozen packages section</div>,
|
||||
}));
|
||||
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
|
||||
@@ -77,6 +77,7 @@ const job = {
|
||||
|
||||
const overview = {
|
||||
id: 42,
|
||||
revision: 7,
|
||||
jobTitle: "Backend Developer",
|
||||
company: "Acme",
|
||||
location: "Oslo",
|
||||
@@ -151,6 +152,7 @@ function renderTable(path = "/jobs") {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockReset();
|
||||
window.localStorage.removeItem("uiLanguage");
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === "/companies") return Promise.resolve({ data: [{ id: 1, name: "Acme" }] } as any);
|
||||
@@ -411,3 +413,56 @@ test("holds answers until their aggregate matches the route and ignores a late p
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("entering Answers waits for a fresh coherent snapshot after sibling writes", async () => {
|
||||
renderTable("/jobs/42?section=application&sub=letter");
|
||||
await screen.findByRole("heading", { name: "Backend Developer" });
|
||||
let resolve!: (value: any) => void;
|
||||
const pending = new Promise<any>(done => { resolve = done; });
|
||||
mockedApi.get.mockReturnValueOnce(pending);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Answers & message draft" }));
|
||||
expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument();
|
||||
await act(async () => { resolve({ data: { ...overview, revision: 8, applicationAnswerDraft: "Fresh answer", recruiterMessageDraft: "Fresh message" } }); await pending; });
|
||||
expect(await screen.findByTestId("answer-props")).toHaveTextContent("42|Fresh answer|Fresh message|8");
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/jobapplications/42/workspace")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a late previous Answers-entry read cannot enable the current editor", async () => {
|
||||
const { router } = renderTable("/jobs/42?section=application&sub=letter");
|
||||
await screen.findByRole("heading", { name: "Backend Developer" });
|
||||
let resolveOld!: (value: any) => void;
|
||||
let resolveNew!: (value: any) => void;
|
||||
const oldRead = new Promise<any>(done => { resolveOld = done; });
|
||||
const newRead = new Promise<any>(done => { resolveNew = done; });
|
||||
mockedApi.get.mockReturnValueOnce(oldRead).mockReturnValueOnce(newRead);
|
||||
await act(async () => { await router.navigate("/jobs/42?section=application&sub=answers"); });
|
||||
await act(async () => { await router.navigate("/jobs/42?section=application&sub=letter"); });
|
||||
await act(async () => { await router.navigate("/jobs/42?section=application&sub=answers"); });
|
||||
await act(async () => { resolveOld({ data: { ...overview, revision: 8 } }); await oldRead; });
|
||||
expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument();
|
||||
await act(async () => { resolveNew({ data: { ...overview, revision: 9, applicationAnswerDraft: "Current answer" } }); await newRead; });
|
||||
expect(await screen.findByTestId("answer-props")).toHaveTextContent("42|Current answer|Saved recruiter message|9");
|
||||
});
|
||||
|
||||
test("a failed Answers-entry read stays closed until a successful retry", async () => {
|
||||
renderTable("/jobs/42?section=application&sub=letter");
|
||||
await screen.findByRole("heading", { name: "Backend Developer" });
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Answers & message draft" }));
|
||||
const retry = await screen.findByRole("button", { name: "Retry" });
|
||||
expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument();
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { ...overview, revision: 9, applicationAnswerDraft: "Recovered answer" } });
|
||||
fireEvent.click(retry);
|
||||
expect(await screen.findByTestId("answer-props")).toHaveTextContent("42|Recovered answer|Saved recruiter message|9");
|
||||
});
|
||||
|
||||
test("leaving a pending direct Answers entry still loads the workspace header", async () => {
|
||||
let resolve!: (value: any) => void;
|
||||
const pending = new Promise<any>(done => { resolve = done; });
|
||||
mockedApi.get.mockReturnValueOnce(pending);
|
||||
const { router } = renderTable("/jobs/42?section=application&sub=answers");
|
||||
await act(async () => { await router.navigate("/jobs/42?section=application&sub=letter"); });
|
||||
expect(await screen.findByRole("heading", { name: "Backend Developer" })).toBeInTheDocument();
|
||||
await act(async () => { resolve({ data: { ...overview, jobTitle: "Late header" } }); await pending; });
|
||||
expect(screen.queryByRole("heading", { name: "Late header" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -78,6 +78,9 @@ export function ApplicationWorkspace({
|
||||
const [overlaySubsection, setOverlaySubsection] = useState<{ section: WorkspaceSectionKey; value: string } | null>(null);
|
||||
const subsection = onSectionChange && overlaySubsection?.section === section
|
||||
? overlaySubsection.value : workspaceSubsection(sectionOverride ?? params.get("section"), params.get("sub"));
|
||||
const answersActive = section === "application" && subsection === "answers";
|
||||
const answersEntry = useMemo(() => ({ jobId, answersActive }), [jobId, answersActive]);
|
||||
const [readyAnswersEntry, setReadyAnswersEntry] = useState<object | null>(null);
|
||||
const sectionId = useId();
|
||||
const secondaryId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -85,6 +88,7 @@ export function ApplicationWorkspace({
|
||||
const [loadedOverview, setOverview] = useState<WorkspaceOverview | null>(null);
|
||||
const overview = loadedOverview?.id === jobId ? loadedOverview : null;
|
||||
const overviewRequest = useRef(0);
|
||||
const overviewLoadedJob = useRef<number | null>(null);
|
||||
const [error, setError] = useState<ViewResourceError | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -136,7 +140,11 @@ export function ApplicationWorkspace({
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const next = await applicationWorkspaceApi.overview(jobId);
|
||||
if (request === overviewRequest.current) setOverview(next);
|
||||
if (request === overviewRequest.current) {
|
||||
setOverview(next);
|
||||
overviewLoadedJob.current = next.id;
|
||||
setReadyAnswersEntry(answersEntry);
|
||||
}
|
||||
} catch (err) {
|
||||
if (request !== overviewRequest.current) return;
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
@@ -144,12 +152,17 @@ export function ApplicationWorkspace({
|
||||
} finally {
|
||||
if (request === overviewRequest.current) setLoading(false);
|
||||
}
|
||||
}, [jobId, t]);
|
||||
}, [jobId, t, answersEntry]);
|
||||
|
||||
const previousOverviewSource = useRef<{ jobId: number; t: typeof t; answersActive: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const previous = previousOverviewSource.current;
|
||||
previousOverviewSource.current = { jobId, t, answersActive };
|
||||
// Each Answers entry needs a coherent source; leaving it does not need another aggregate read.
|
||||
if (answersActive || !previous?.answersActive || previous.jobId !== jobId || previous.t !== t || overviewLoadedJob.current !== jobId) void load();
|
||||
else setLoading(false);
|
||||
return () => { overviewRequest.current += 1; };
|
||||
}, [load]);
|
||||
}, [load, answersActive, jobId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Direct entry gets a named target; keyboard tab activation keeps tab focus.
|
||||
@@ -261,7 +274,7 @@ export function ApplicationWorkspace({
|
||||
<Box role="tabpanel" id={`${secondaryId}-panel`} aria-labelledby={`${secondaryId}-${subsection}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||
{section === "application" && subsection === "cv" && jobId > 0 && <ApplicationCvSection key={jobId} jobId={jobId} includePackages={false} />}
|
||||
{section === "application" && subsection === "letter" && jobId > 0 && <ApplicationCoverLetterSection key={jobId} jobId={jobId} onDirtyChange={setCoverLetterDirty} />}
|
||||
{section === "application" && subsection === "answers" && overview && jobId > 0 && <ApplicationPackageDraftsSection
|
||||
{answersActive && readyAnswersEntry === answersEntry && overview && jobId > 0 && <ApplicationPackageDraftsSection
|
||||
key={jobId}
|
||||
jobId={jobId}
|
||||
initialRevision={overview.revision}
|
||||
|
||||
Reference in New Issue
Block a user