fix(correspondence): protect pasted email recording and recovery
This commit is contained in:
@@ -8,7 +8,8 @@ import ts from 'typescript';
|
||||
|
||||
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
|
||||
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
|
||||
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/correspondence-recovery');
|
||||
const pasted = process.env.A3_PASTED_EMAIL === '1';
|
||||
const out = path.resolve(import.meta.dirname, `../../docs/ui-modernisation/evidence/${pasted ? 'pasted-email-recovery' : 'correspondence-recovery'}`);
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const configurations = process.env.A3_PILOT ? [[390, 'nb', 'dark']] : [390, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(theme => [width, language, theme])));
|
||||
const report = { journeys: [], captures: [], writes: [], errors: [], unavailableReads: [], blockedExternal: [] };
|
||||
@@ -32,7 +33,7 @@ try {
|
||||
assert.equal(request.method(), 'POST'); assert.equal(url.pathname, '/api/correspondence');
|
||||
posts += 1;
|
||||
const body = request.postDataJSON();
|
||||
assert.deepEqual(body, { jobApplicationId: 42, from: 'Me', content: 'Retain the synthetic note' });
|
||||
assert.deepEqual(body, pasted ? { jobApplicationId: 42, from: 'Me', channel: 'Email', subject: 'Synthetic interview', content: 'Synthetic email body', date: '2026-11-11T12:00:00.000Z', externalFrom: 'recruiter@example.test', externalTo: 'applicant@example.test' } : { jobApplicationId: 42, from: 'Me', content: 'Retain the synthetic note' });
|
||||
report.writes.push({ name, attempt: posts, body, result: posts === 1 ? 'fixture500' : 'fixtureCommitted' });
|
||||
if (posts === 1) return route.fulfill({ status: 500, json: {} });
|
||||
assert.equal(posts, 2); await held;
|
||||
@@ -41,7 +42,7 @@ try {
|
||||
if (url.pathname === '/api/correspondence/42') {
|
||||
reads += 1;
|
||||
if (reads === 1 || reads === 3) return route.fulfill({ status: 500, json: {} });
|
||||
return route.fulfill({ json: reads > 3 ? [{ id: 91, jobApplicationId: 42, from: 'Me', content: 'Retain the synthetic note', date: '2026-11-11T12:00:00Z', channel: 'Note' }] : [] });
|
||||
return route.fulfill({ json: reads > 3 ? [{ id: 91, jobApplicationId: 42, from: 'Me', content: pasted ? 'Synthetic email body' : 'Retain the synthetic note', date: '2026-11-11T12:00:00Z', channel: pasted ? 'Email' : 'Note' }] : [] });
|
||||
}
|
||||
const fixtures = {
|
||||
'/api/auth/config': { requireAuth: true },
|
||||
@@ -66,7 +67,8 @@ try {
|
||||
await page.goto('http://localhost:4338/jobs/42?section=timeline&sub=correspondence');
|
||||
const retry = page.getByRole('button', { name: copy.retry, exact: true });
|
||||
await expect(retry).toBeVisible();
|
||||
const draft = page.getByRole('textbox', { name: copy.correspondenceLogNoteOrMessage, exact: true });
|
||||
const note = page.getByRole('textbox', { name: copy.correspondenceLogNoteOrMessage, exact: true });
|
||||
let draft = note;
|
||||
await draft.fill('Retain the synthetic note');
|
||||
await capture('read-error');
|
||||
const otherLanguage = language === 'nb' ? 'en' : 'nb';
|
||||
@@ -79,20 +81,35 @@ try {
|
||||
await expect(retry).toBeVisible(); assert.equal(reads, 1);
|
||||
await retry.press('Enter'); await expect(retry).toBeHidden();
|
||||
await expect(draft).toHaveValue('Retain the synthetic note'); assert.equal(reads, 2); assert.equal(posts, 0);
|
||||
const add = page.getByRole('button', { name: copy.correspondenceAdd, exact: true });
|
||||
let add = page.getByRole('button', { name: copy.correspondenceAdd, exact: true });
|
||||
const importDialog = page.getByRole('dialog', { name: copy.correspondenceImportTitle, exact: true });
|
||||
const rawEmail = 'From: recruiter@example.test\nTo: applicant@example.test\nSubject: Synthetic interview\nDate: Wed, 11 Nov 2026 12:00:00 +0000\n\nSynthetic email body';
|
||||
if (pasted) {
|
||||
await page.getByRole('button', { name: copy.correspondenceImportEmail, exact: true }).click();
|
||||
draft = importDialog.getByRole('textbox', { name: copy.correspondencePasteEmail, exact: true });
|
||||
await draft.fill(rawEmail);
|
||||
add = importDialog.getByRole('button', { name: copy.correspondenceLogEmail, exact: true });
|
||||
}
|
||||
await add.click();
|
||||
await expect(page.getByText(copy.correspondenceAddFailed, { exact: true })).toBeVisible();
|
||||
await expect(draft).toHaveValue('Retain the synthetic note'); await expect(add).toBeEnabled();
|
||||
await expect(page.getByText(pasted ? copy.addJobModalImportFailed : copy.correspondenceAddFailed, { exact: true })).toBeVisible();
|
||||
await expect(draft).toHaveValue(pasted ? rawEmail : 'Retain the synthetic note'); await expect(add).toBeEnabled();
|
||||
await capture('write-error');
|
||||
await page.keyboard.press('Escape');
|
||||
if (!pasted) await page.keyboard.press('Escape');
|
||||
await add.click(); await expect(add).toBeDisabled(); await expect(draft).toBeDisabled();
|
||||
await add.evaluate(button => { button.click(); button.click(); });
|
||||
if (pasted) {
|
||||
await expect(importDialog.getByRole('button', { name: copy.correspondenceClose, exact: true })).toBeDisabled();
|
||||
await expect(importDialog.getByRole('tab', { name: copy.google, exact: true })).toBeDisabled();
|
||||
await page.keyboard.press('Escape'); await expect(importDialog).toBeVisible();
|
||||
}
|
||||
assert.equal(posts, 2); await capture('write-pending');
|
||||
release();
|
||||
await expect(retry).toBeVisible(); await expect(draft).toBeEnabled(); await expect(draft).toHaveValue('');
|
||||
await expect(retry).toBeVisible();
|
||||
if (pasted) await expect(importDialog).toBeHidden();
|
||||
await expect(note).toBeEnabled(); await expect(note).toHaveValue(pasted ? 'Retain the synthetic note' : '');
|
||||
await capture('committed-read-error');
|
||||
await retry.press('Enter');
|
||||
await expect(page.getByText('Retain the synthetic note', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(pasted ? 'Synthetic email body' : 'Retain the synthetic note', { exact: true })).toBeVisible();
|
||||
await expect(retry).toBeHidden(); assert.equal(reads, 4); assert.equal(posts, 2);
|
||||
await capture('recovered');
|
||||
report.journeys.push({ name, readRetryPreservesDraft: true, rejectedWritePreservesDraft: true, pendingWriteExcludesDuplicate: true, committedReadFailureRetriesGetOnly: true, reads, posts });
|
||||
|
||||
@@ -134,8 +134,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
const messageRequest = useRef(0);
|
||||
const [from, setFrom] = useState<"Me" | "Company">("Me");
|
||||
const [text, setText] = useState("");
|
||||
const [noteSaving, setNoteSaving] = useState(false);
|
||||
const noteSavingRef = useRef(false);
|
||||
const [manualSaving, setManualSaving] = useState(false);
|
||||
const manualSavingRef = useRef(false);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
@@ -168,8 +168,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
const session = gmailOAuth.current;
|
||||
session.active = true;
|
||||
session.jobId = jobId;
|
||||
noteSavingRef.current = false;
|
||||
setNoteSaving(false);
|
||||
manualSavingRef.current = false;
|
||||
setManualSaving(false);
|
||||
return () => { session.active = false; session.generation += 1; session.starting = false; session.attempt = null; };
|
||||
}, [jobId]);
|
||||
|
||||
@@ -345,11 +345,11 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
});
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend || noteSavingRef.current) return;
|
||||
if (!canSend || manualSavingRef.current) return;
|
||||
const session = gmailOAuth.current;
|
||||
const generation = session.generation;
|
||||
noteSavingRef.current = true;
|
||||
setNoteSaving(true);
|
||||
manualSavingRef.current = true;
|
||||
setManualSaving(true);
|
||||
try {
|
||||
await api.post("/correspondence", { jobApplicationId: jobId, from, content: text });
|
||||
if (!session.active || generation !== session.generation) return;
|
||||
@@ -359,19 +359,24 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
if (session.active && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("correspondenceAddFailed")), "error");
|
||||
} finally {
|
||||
if (session.active && generation === session.generation) {
|
||||
noteSavingRef.current = false;
|
||||
setNoteSaving(false);
|
||||
manualSavingRef.current = false;
|
||||
setManualSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const importEmail = async () => {
|
||||
if (manualSavingRef.current) return;
|
||||
const parsed = parseRawEmail(rawEmail);
|
||||
if (!parsed.body && !parsed.subject && !rawEmail.trim()) {
|
||||
toast(t("addJobModalPasteUrlFirst"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const session = gmailOAuth.current;
|
||||
const generation = session.generation;
|
||||
manualSavingRef.current = true;
|
||||
setManualSaving(true);
|
||||
try {
|
||||
await api.post("/correspondence", {
|
||||
jobApplicationId: jobId,
|
||||
@@ -383,12 +388,18 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
externalFrom: parsed.from ?? null,
|
||||
externalTo: parsed.to ?? null,
|
||||
});
|
||||
if (!session.active || generation !== session.generation) return;
|
||||
setImportOpen(false);
|
||||
setRawEmail("");
|
||||
await load();
|
||||
toast(t("correspondenceLogEmail"), "success");
|
||||
await load().catch(() => undefined);
|
||||
toast(startupTranslation.current("correspondenceLogEmail"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("addJobModalImportFailed")), "error");
|
||||
if (session.active && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("addJobModalImportFailed")), "error");
|
||||
} finally {
|
||||
if (session.active && generation === session.generation) {
|
||||
manualSavingRef.current = false;
|
||||
setManualSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -603,16 +614,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
|
||||
<ToggleButtonGroup disabled={noteSaving} exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
|
||||
<ToggleButtonGroup disabled={manualSaving} exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
|
||||
<ToggleButton value="Me">{t("correspondenceMe")}</ToggleButton>
|
||||
<ToggleButton value="Company">{t("correspondenceCompany")}</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Button disabled={noteSaving} variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
|
||||
<Button disabled={manualSaving} variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
|
||||
|
||||
<TextField disabled={noteSaving} label={t("correspondenceLogNoteOrMessage")} value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: { xs: "1 1 100%", sm: "1 1 320px" }, minWidth: 0 }} slotProps={{ formHelperText: { disabled: false } }} helperText={t("correspondenceCharacters", { count: text.length })} />
|
||||
<TextField disabled={manualSaving} label={t("correspondenceLogNoteOrMessage")} value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: { xs: "1 1 100%", sm: "1 1 320px" }, minWidth: 0 }} slotProps={{ formHelperText: { disabled: false } }} helperText={t("correspondenceCharacters", { count: text.length })} />
|
||||
|
||||
<Button variant="contained" onClick={send} disabled={!canSend || noteSaving}>{t("correspondenceAdd")}</Button>
|
||||
<Button variant="contained" onClick={send} disabled={!canSend || manualSaving}>{t("correspondenceAdd")}</Button>
|
||||
</Box>
|
||||
|
||||
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm">
|
||||
@@ -650,18 +661,18 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
|
||||
<Dialog open={importOpen} onClose={() => { if (!manualSaving) setImportOpen(false); }} fullWidth maxWidth="md">
|
||||
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label={t("correspondencePasteEmail")} />
|
||||
<Tab label={t("google")} />
|
||||
<Tab disabled={manualSaving} label={t("correspondencePasteEmail")} />
|
||||
<Tab disabled={manualSaving} label={t("google")} />
|
||||
</Tabs>
|
||||
|
||||
{importTab === 0 ? (
|
||||
<>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("correspondencePasteEmailHelp")}</Typography>
|
||||
<TextField multiline minRows={10} fullWidth value={rawEmail} onChange={(e) => setRawEmail(e.target.value)} placeholder={t("correspondencePasteEmailPlaceholder")} />
|
||||
<TextField disabled={manualSaving} label={t("correspondencePasteEmail")} multiline minRows={10} fullWidth value={rawEmail} onChange={(e) => setRawEmail(e.target.value)} placeholder={t("correspondencePasteEmailPlaceholder")} />
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
@@ -799,8 +810,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setImportOpen(false)}>{t("correspondenceClose")}</Button>
|
||||
{importTab === 0 ? <Button variant="contained" onClick={importEmail}>{t("correspondenceLogEmail")}</Button> : null}
|
||||
<Button disabled={manualSaving} onClick={() => setImportOpen(false)}>{t("correspondenceClose")}</Button>
|
||||
{importTab === 0 ? <Button disabled={manualSaving} variant="contained" onClick={importEmail}>{t("correspondenceLogEmail")}</Button> : null}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import Correspondence from "./components/Correspondence";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { ToastProvider } from "./toast";
|
||||
@@ -189,3 +189,73 @@ test("a displayed read error follows UI language without another request or lost
|
||||
expect(screen.getByLabelText(translations.nb.correspondenceLogNoteOrMessage)).toHaveValue("Retain while translating error");
|
||||
expect(client.get.mock.calls).toEqual(reads); expect(client.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const rawEmail = "From: recruiter@example.test\nTo: applicant@example.test\nSubject: Synthetic interview\nDate: Wed, 11 Nov 2026 12:00:00 +0000\n\nSynthetic email body";
|
||||
async function openRawEmail() {
|
||||
fireEvent.click(screen.getByRole("button", { name: translations.en.correspondenceImportEmail }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const input = within(dialog).getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: rawEmail } });
|
||||
return { dialog, input, submit: within(dialog).getByRole("button", { name: translations.en.correspondenceLogEmail }) };
|
||||
}
|
||||
function emptyReads() {
|
||||
client.get.mockImplementation(url => Promise.resolve({ data: String(url).startsWith("/correspondence/") ? [] : { connected: false } } as any));
|
||||
}
|
||||
|
||||
test("pasted-email submission locks its draft, tabs and close actions and excludes duplicate writes", async () => {
|
||||
emptyReads(); const pending = deferred(); client.post.mockReturnValue(pending.promise);
|
||||
render(view()); const { dialog, input, submit } = await openRawEmail();
|
||||
fireEvent.click(submit); fireEvent.click(submit);
|
||||
expect(client.post).toHaveBeenCalledTimes(1); expect(input).toBeDisabled(); expect(submit).toBeDisabled();
|
||||
expect(within(dialog).getByRole("tab", { name: translations.en.google })).toBeDisabled();
|
||||
expect(within(dialog).getByRole("button", { name: translations.en.correspondenceClose })).toBeDisabled();
|
||||
fireEvent.keyDown(dialog, { key: "Escape" }); expect(dialog).toBeVisible();
|
||||
expect(client.post).toHaveBeenCalledWith("/correspondence", { jobApplicationId: 42, from: "Me", channel: "Email", subject: "Synthetic interview", content: "Synthetic email body", date: "2026-11-11T12:00:00.000Z", externalFrom: "recruiter@example.test", externalTo: "applicant@example.test" });
|
||||
await act(async () => pending.resolve({ data: {} }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("rejected pasted-email recording retains the complete source and supports a deliberate retry", async () => {
|
||||
emptyReads(); client.post.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: {} } as any);
|
||||
render(view()); const { input, submit } = await openRawEmail(); fireEvent.click(submit);
|
||||
expect(await screen.findByText(translations.en.addJobModalImportFailed)).toBeVisible();
|
||||
expect(input).toHaveValue(rawEmail); expect(submit).toBeEnabled();
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("committed pasted email with failed refresh offers read retry without a failed-import alert", async () => {
|
||||
let reads = 0;
|
||||
client.get.mockImplementation(url => String(url).startsWith("/correspondence/")
|
||||
? ++reads === 2 ? Promise.reject(new Error("read offline")) : Promise.resolve({ data: reads > 2 ? [message(42, "Recorded email")] : [] } as any)
|
||||
: Promise.resolve({ data: { connected: false } } as any));
|
||||
client.post.mockResolvedValue({ data: {} } as any);
|
||||
render(view()); await waitFor(() => expect(reads).toBe(1));
|
||||
const { submit } = await openRawEmail(); fireEvent.click(submit);
|
||||
const retry = await screen.findByRole("button", { name: translations.en.retry });
|
||||
expect(screen.queryByText(translations.en.addJobModalImportFailed)).not.toBeInTheDocument();
|
||||
fireEvent.click(retry); await screen.findByText("Recorded email");
|
||||
expect(client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each(["success", "failure"])("old-job pasted-email %s cannot close or clear a new draft", async outcome => {
|
||||
emptyReads(); const pending = deferred(); client.post.mockReturnValue(pending.promise);
|
||||
const rendered = render(view()); const { submit } = await openRawEmail(); fireEvent.click(submit);
|
||||
rendered.rerender(view(43));
|
||||
const next = within(screen.getByRole("dialog")).getByRole("textbox");
|
||||
fireEvent.change(next, { target: { value: "Next application email draft" } });
|
||||
const reads = [...client.get.mock.calls];
|
||||
await act(async () => {
|
||||
if (outcome === "success") pending.resolve({ data: {} });
|
||||
else pending.reject(new Error("old import failed"));
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeVisible(); expect(next).toHaveValue("Next application email draft");
|
||||
expect(client.get.mock.calls).toEqual(reads);
|
||||
expect(screen.queryByText(translations.en.addJobModalImportFailed)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("pasted-email source has a visible accessible label", async () => {
|
||||
emptyReads(); render(view()); const { dialog } = await openRawEmail();
|
||||
expect(within(dialog).getByRole("textbox", { name: translations.en.correspondencePasteEmail })).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user