fix(correspondence): preserve current reads and note recovery

This commit is contained in:
cesnimda
2026-11-11 14:00:44 +01:00
parent b9bb3c44de
commit c751b4272e
3 changed files with 334 additions and 13 deletions
@@ -0,0 +1,107 @@
// Real exported application; intercepted synthetic API failures/writes, no mail provider.
import { chromium, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
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');
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: [] };
const browser = await chromium.launch();
try {
for (const [width, language, theme] of configurations) {
const name = `${width}-${language}-${theme}`, copy = translations[language];
console.log(`Checking ${name}`);
const context = await browser.newContext({ viewport: { width, height: 1000 }, hasTouch: width === 390, isMobile: width === 390, reducedMotion: 'reduce' });
const page = await context.newPage();
page.on('pageerror', error => report.errors.push({ name, error: error.message }));
await page.addInitScript(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, { language, theme });
let reads = 0, posts = 0, release;
const held = new Promise(resolve => { release = resolve; });
const job = { id: 42, version: 7, jobTitle: 'Synthetic teacher role', companyId: 1, status: 'Applied', dateApplied: '2026-11-01', company: { id: 1, name: 'Synthetic school' }, description: 'Support learners in a synthetic teaching fixture.' };
await page.route('**/*', async route => {
const request = route.request(), url = new URL(request.url());
if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && url.pathname.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!url.pathname.startsWith('/api/')) return route.continue();
if (request.method() !== 'GET') {
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' });
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;
return route.fulfill({ json: { id: 91 } });
}
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' }] : [] });
}
const fixtures = {
'/api/auth/config': { requireAuth: true },
'/api/auth/me': { id: 'a3-correspondence-owner', sessionContext: 'a3-correspondence-session', firstName: 'Alex', displayName: 'Alex Sample', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en' },
'/api/notifications/unread-count': { count: 0 },
'/api/companies': [job.company],
'/api/jobapplications/reminders': [],
'/api/jobapplications/42/workspace': { ...job, revision: 7, company: job.company.name, stageGroup: 'Applied', stageOrder: 1, tags: [], notes: '', applicationAnswerDraft: '', recruiterMessageDraft: '', hasJobDescription: true, cv: { variantId: null, variantName: null, themeId: null, hasTailoredCvText: false, updatedAtUtc: null }, hasCoverLetter: false, documentCount: 0, hasPortfolio: false, aiInteractionCount: 0, lastAiAtUtc: null, recentActivity: [], nextStep: null, checklistProgress: { total: 0, completed: 0, dismissed: 0, percent: 0 } },
'/api/jobapplications/42/status-suggestion': null,
'/api/gmail/status': { connected: false },
};
if (Object.hasOwn(fixtures, url.pathname)) return route.fulfill({ json: fixtures[url.pathname] });
report.unavailableReads.push(url.pathname); return route.fulfill({ status: 404, json: {} });
});
const capture = async state => {
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze();
report.captures.push({ name, state, violations: scan.violations });
assert.deepEqual(scan.violations, []);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(out, `${name}-${state}.png`) });
};
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 });
await draft.fill('Retain the synthetic note');
await capture('read-error');
const otherLanguage = language === 'nb' ? 'en' : 'nb';
await page.evaluate(value => { localStorage.setItem('uiLanguage', value); window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: value })); }, otherLanguage);
await expect(page.getByRole('textbox', { name: translations[otherLanguage].correspondenceLogNoteOrMessage, exact: true })).toHaveValue('Retain the synthetic note');
await expect(page.getByRole('button', { name: translations[otherLanguage].retry, exact: true })).toBeVisible();
await expect(page.getByText(copy.correspondenceLoadFailed, { exact: true })).toHaveCount(0);
assert.equal(reads, 1); assert.equal(posts, 0);
await page.evaluate(value => { localStorage.setItem('uiLanguage', value); window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: value })); }, language);
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 });
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 capture('write-error');
await page.keyboard.press('Escape');
await add.click(); await expect(add).toBeDisabled(); await expect(draft).toBeDisabled();
await add.evaluate(button => { button.click(); button.click(); });
assert.equal(posts, 2); await capture('write-pending');
release();
await expect(retry).toBeVisible(); await expect(draft).toBeEnabled(); await expect(draft).toHaveValue('');
await capture('committed-read-error');
await retry.press('Enter');
await expect(page.getByText('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 });
await context.close();
}
assert.equal(report.journeys.length, configurations.length);
assert.deepEqual(report.errors, []); assert.deepEqual(report.unavailableReads, []);
console.log(`PASS ${report.journeys.length} recovery journeys, ${report.captures.length} accessibility captures, ${report.writes.length} intercepted write attempts`);
} finally {
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify(report, null, 2));
await browser.close();
}
@@ -130,9 +130,12 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
};
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
const [messagesLoading, setMessagesLoading] = useState(true);
const [messagesError, setMessagesError] = useState<string | null>(null);
const [messagesError, setMessagesError] = useState<{ cause: unknown } | null>(null);
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 scrollRef = useRef<HTMLDivElement | null>(null);
const [importOpen, setImportOpen] = useState(false);
@@ -156,7 +159,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const [manageNote, setManageNote] = useState("");
const [manageSaving, setManageSaving] = useState(false);
const autoRefreshKeyRef = useRef<string | null>(null);
const gmailOAuth = useRef<{ active: boolean; generation: number; starting: boolean; attempt: EmailOAuthAttempt | null }>({ active: true, generation: 0, starting: false, attempt: null });
const gmailOAuth = useRef<{ active: boolean; jobId: number; generation: number; starting: boolean; attempt: EmailOAuthAttempt | null }>({ active: true, jobId, generation: 0, starting: false, attempt: null });
const startupTranslation = useRef(t);
useEffect(() => { startupTranslation.current = t; }, [t]);
@@ -164,22 +167,29 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
useEffect(() => {
const session = gmailOAuth.current;
session.active = true;
session.jobId = jobId;
noteSavingRef.current = false;
setNoteSaving(false);
return () => { session.active = false; session.generation += 1; session.starting = false; session.attempt = null; };
}, [jobId]);
const load = useCallback(async () => {
if (!gmailOAuth.current.active || gmailOAuth.current.jobId !== jobId) return;
const request = ++messageRequest.current;
setMessagesLoading(true);
try {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
if (request !== messageRequest.current) return;
setMessages(res.data);
setMessagesError(null);
} catch (error) {
setMessagesError(getApiErrorMessage(error, t("correspondenceLoadFailed")));
if (request !== messageRequest.current) return;
setMessagesError({ cause: error });
throw error; // Existing mutation callers still handle their own operation failure.
} finally {
setMessagesLoading(false);
if (request === messageRequest.current) setMessagesLoading(false);
}
}, [jobId, t]);
}, [jobId]);
const loadGmailStatus = useCallback(async () => {
try {
@@ -265,7 +275,10 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
}, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, t, toast]);
useEffect(() => {
setMessages([]);
setMessagesError(null);
void load().catch(() => undefined);
return () => { ++messageRequest.current; };
}, [load]);
useEffect(() => {
@@ -332,13 +345,23 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
});
const send = async () => {
if (!canSend) return;
if (!canSend || noteSavingRef.current) return;
const session = gmailOAuth.current;
const generation = session.generation;
noteSavingRef.current = true;
setNoteSaving(true);
try {
await api.post("/correspondence", { jobApplicationId: jobId, from, content: text });
if (!session.active || generation !== session.generation) return;
setText("");
await load();
await load().catch(() => undefined); // The read notice owns refresh failure after a committed write.
} catch (error) {
toast(getApiErrorMessage(error, t("correspondenceAddFailed")), "error");
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);
}
}
};
@@ -498,7 +521,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
return (
<Box>
<ViewStateNotice title={t("correspondenceLoadFailed")} loading={messagesLoading} error={messagesError ? { kind: "error", message: messagesError, retryable: true } : null} onRetry={() => load().catch(() => undefined)} compact />
<ViewStateNotice title={t("correspondenceLoadFailed")} loading={messagesLoading} error={messagesError ? { kind: "error", message: getApiErrorMessage(messagesError.cause, t("correspondenceLoadFailed")), retryable: true } : null} onRetry={() => load().catch(() => undefined)} compact />
<Paper variant="outlined" ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", bgcolor: "background.paper", borderRadius: 1 }}>
{messages.length === 0 && !messagesLoading && !messagesError ? (
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("correspondenceNoMessages")}</Typography>
@@ -580,16 +603,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 exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
<ToggleButtonGroup disabled={noteSaving} exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
<ToggleButton value="Me">{t("correspondenceMe")}</ToggleButton>
<ToggleButton value="Company">{t("correspondenceCompany")}</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
<Button disabled={noteSaving} variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
<TextField 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 }} helperText={t("correspondenceCharacters", { count: text.length })} />
<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 })} />
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
<Button variant="contained" onClick={send} disabled={!canSend || noteSaving}>{t("correspondenceAdd")}</Button>
</Box>
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm">
@@ -0,0 +1,191 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import Correspondence from "./components/Correspondence";
import { I18nProvider } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
import { api } from "./api";
import { translations } from "./i18n/translations";
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn() }, getApiErrorMessage: (_: unknown, fallback: string) => fallback }));
const client = api as jest.Mocked<typeof api>;
function deferred() {
let resolve!: (value: any) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<any>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
function view(jobId = 42) {
return <ToastProvider><I18nProvider><ConfirmProvider><PromptProvider>
<Correspondence jobId={jobId} jobContext={{ jobTitle: `Synthetic role ${jobId}`, companyName: "Synthetic school" }} />
</PromptProvider></ConfirmProvider></I18nProvider></ToastProvider>;
}
const message = (id: number, content: string) => ({ id, jobApplicationId: id, from: "Company", content, date: "2026-11-11T12:00:00Z", channel: "Note" });
beforeEach(() => {
localStorage.clear(); jest.resetAllMocks();
client.get.mockImplementation(() => Promise.resolve({ data: { connected: false } } as any));
});
test.each(["success", "failure"])("a late previous-job %s cannot overwrite current messages or error state", async outcome => {
const old = deferred();
client.get.mockImplementation(url => String(url) === "/correspondence/42" ? old.promise : Promise.resolve({ data: String(url) === "/correspondence/43" ? [message(43, "Current application message")] : { connected: false } } as any));
const rendered = render(view());
await waitFor(() => expect(client.get).toHaveBeenCalledWith("/correspondence/42"));
rendered.rerender(view(43));
await screen.findByText("Current application message");
await act(async () => {
if (outcome === "success") old.resolve({ data: [message(42, "Stale previous application")] });
else old.reject(new Error("previous job unavailable"));
});
expect(screen.getByText("Current application message")).toBeVisible();
expect(screen.queryByText("Stale previous application")).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.correspondenceLoadFailed)).not.toBeInTheDocument();
expect(client.post).not.toHaveBeenCalled();
});
test("changing application clears displayed messages while the next read is pending", async () => {
const next = deferred();
client.get.mockImplementation(url => String(url) === "/correspondence/43" ? next.promise : Promise.resolve({ data: String(url) === "/correspondence/42" ? [message(42, "Previous application only")] : { connected: false } } as any));
const rendered = render(view()); await screen.findByText("Previous application only");
rendered.rerender(view(43));
await waitFor(() => expect(client.get).toHaveBeenCalledWith("/correspondence/43"));
expect(screen.queryByText("Previous application only")).not.toBeInTheDocument();
await act(async () => next.resolve({ data: [message(43, "Next application only")] }));
expect(await screen.findByText("Next application only")).toBeVisible();
});
test.each(["en", "nb"] as const)("%s read failure retains a note draft and Retry only reloads messages", async language => {
localStorage.setItem("uiLanguage", language);
let reads = 0;
client.get.mockImplementation(url => String(url).startsWith("/correspondence/")
? ++reads === 1 ? Promise.reject(new Error("offline")) : Promise.resolve({ data: [message(42, "Recovered message")] } as any)
: Promise.resolve({ data: { connected: false } } as any));
render(view());
const draft = screen.getByLabelText(translations[language].correspondenceLogNoteOrMessage);
fireEvent.change(draft, { target: { value: "Retain my unsaved note" } });
const retry = await screen.findByRole("button", { name: translations[language].retry });
fireEvent.click(retry);
expect(await screen.findByText("Recovered message")).toBeVisible();
expect(draft).toHaveValue("Retain my unsaved note");
expect(reads).toBe(2); expect(client.post).not.toHaveBeenCalled();
});
test("one pending note submission excludes duplicate writes and draft edits", async () => {
const pending = deferred(); client.post.mockReturnValue(pending.promise);
client.get.mockImplementation(url => Promise.resolve({ data: String(url).startsWith("/correspondence/") ? [] : { connected: false } } as any));
render(view());
const input = screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage);
fireEvent.change(input, { target: { value: "One synthetic note" } });
const log = screen.getByRole("button", { name: translations.en.correspondenceAdd });
fireEvent.click(log); fireEvent.click(log);
expect(client.post).toHaveBeenCalledTimes(1);
expect(input).toBeDisabled(); expect(log).toBeDisabled();
expect(document.getElementById(input.getAttribute("aria-describedby")!)).not.toHaveClass("Mui-disabled");
await act(async () => pending.resolve({ data: {} }));
expect(input).toHaveValue(""); expect(input).toBeEnabled();
});
test("a rejected note keeps the draft and permits a deliberate retry", async () => {
client.post.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: {} } as any);
client.get.mockImplementation(url => Promise.resolve({ data: String(url).startsWith("/correspondence/") ? [] : { connected: false } } as any));
render(view());
const input = screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage);
fireEvent.change(input, { target: { value: "Recoverable synthetic note" } });
const log = screen.getByRole("button", { name: translations.en.correspondenceAdd });
fireEvent.click(log);
expect(await screen.findByText(translations.en.correspondenceAddFailed)).toBeVisible();
expect(input).toHaveValue("Recoverable synthetic note"); expect(log).toBeEnabled();
fireEvent.click(log);
await waitFor(() => expect(input).toHaveValue(""));
expect(client.post).toHaveBeenCalledTimes(2);
});
test("a committed note followed by a failed refresh is retried as a read, not reported as a failed write", 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, "Committed note")] : [] } as any)
: Promise.resolve({ data: { connected: false } } as any));
client.post.mockResolvedValue({ data: {} } as any);
render(view());
await waitFor(() => expect(reads).toBe(1));
const input = screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage);
fireEvent.change(input, { target: { value: "Committed note" } });
fireEvent.click(screen.getByRole("button", { name: translations.en.correspondenceAdd }));
const retry = await screen.findByRole("button", { name: translations.en.retry });
expect(screen.queryByText(translations.en.correspondenceAddFailed)).not.toBeInTheDocument();
expect(input).toHaveValue("");
fireEvent.click(retry); await screen.findByText("Committed note");
expect(client.post).toHaveBeenCalledTimes(1);
});
test.each(["success", "failure"])("an old job's pending note %s cannot clear the next draft or reload the previous application", async outcome => {
const pending = deferred(); client.post.mockReturnValue(pending.promise);
client.get.mockImplementation(url => Promise.resolve({ data: String(url).startsWith("/correspondence/") ? [] : { connected: false } } as any));
const rendered = render(view());
fireEvent.change(screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage), { target: { value: "Previous note" } });
fireEvent.click(screen.getByRole("button", { name: translations.en.correspondenceAdd }));
rendered.rerender(view(43));
fireEvent.change(screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage), { target: { value: "Next draft" } });
const reads = [...client.get.mock.calls];
await act(async () => {
if (outcome === "success") pending.resolve({ data: {} });
else pending.reject(new Error("old note failed"));
});
expect(screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage)).toHaveValue("Next draft");
expect(screen.queryByText(translations.en.correspondenceAddFailed)).not.toBeInTheDocument();
expect(client.get.mock.calls).toEqual(reads);
});
test("a newer refresh after note commitment wins over the original delayed read", async () => {
const initial = deferred(); let reads = 0;
client.get.mockImplementation(url => String(url).startsWith("/correspondence/")
? ++reads === 1 ? initial.promise : Promise.resolve({ data: [message(42, "Fresh committed note")] } as any)
: Promise.resolve({ data: { connected: false } } as any));
client.post.mockResolvedValue({ data: {} } as any);
render(view());
fireEvent.change(screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage), { target: { value: "Fresh committed note" } });
fireEvent.click(screen.getByRole("button", { name: translations.en.correspondenceAdd }));
await screen.findByText("Fresh committed note");
await act(async () => initial.resolve({ data: [message(42, "Stale initial snapshot")] }));
expect(screen.getByText("Fresh committed note")).toBeVisible();
expect(screen.queryByText("Stale initial snapshot")).not.toBeInTheDocument();
});
test("a committed note after unmount does not reload or write again", async () => {
const pending = deferred(); client.post.mockReturnValue(pending.promise);
client.get.mockImplementation(url => Promise.resolve({ data: String(url).startsWith("/correspondence/") ? [] : { connected: false } } as any));
const rendered = render(view());
fireEvent.change(screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage), { target: { value: "Pending note" } });
fireEvent.click(screen.getByRole("button", { name: translations.en.correspondenceAdd }));
rendered.unmount(); const reads = [...client.get.mock.calls];
await act(async () => pending.resolve({ data: {} }));
expect(client.get.mock.calls).toEqual(reads); expect(client.post).toHaveBeenCalledTimes(1);
});
test("pending note rejection follows the current UI language and retains its draft", async () => {
const pending = deferred(); client.post.mockReturnValue(pending.promise);
client.get.mockImplementation(url => Promise.resolve({ data: String(url).startsWith("/correspondence/") ? [] : { connected: false } } as any));
render(view());
fireEvent.change(screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage), { target: { value: "Retain during language change" } });
fireEvent.click(screen.getByRole("button", { name: translations.en.correspondenceAdd }));
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
await act(async () => pending.reject(new Error("offline")));
expect(await screen.findByText(translations.nb.correspondenceAddFailed)).toBeVisible();
expect(screen.queryByText(translations.en.correspondenceAddFailed)).not.toBeInTheDocument();
expect(screen.getByLabelText(translations.nb.correspondenceLogNoteOrMessage)).toHaveValue("Retain during language change");
});
test("a displayed read error follows UI language without another request or lost draft", async () => {
client.get.mockImplementation(url => String(url).startsWith("/correspondence/") ? Promise.reject(new Error("offline")) : Promise.resolve({ data: { connected: false } } as any));
render(view());
await screen.findByRole("button", { name: translations.en.retry });
fireEvent.change(screen.getByLabelText(translations.en.correspondenceLogNoteOrMessage), { target: { value: "Retain while translating error" } });
const reads = [...client.get.mock.calls];
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
expect(screen.getAllByText(translations.nb.correspondenceLoadFailed)[0]).toBeVisible();
expect(screen.queryByText(translations.en.correspondenceLoadFailed)).not.toBeInTheDocument();
expect(screen.getByLabelText(translations.nb.correspondenceLogNoteOrMessage)).toHaveValue("Retain while translating error");
expect(client.get.mock.calls).toEqual(reads); expect(client.post).not.toHaveBeenCalled();
});