From c751b4272ecaadc66ef8c8e3306faf00b06853be Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 11 Nov 2026 14:00:44 +0100 Subject: [PATCH] fix(correspondence): preserve current reads and note recovery --- .../verify-correspondence-recovery.mjs | 107 ++++++++++ .../src/components/Correspondence.tsx | 49 +++-- .../src/correspondence-recovery.test.tsx | 191 ++++++++++++++++++ 3 files changed, 334 insertions(+), 13 deletions(-) create mode 100644 job-tracker-ui/scripts/verify-correspondence-recovery.mjs create mode 100644 job-tracker-ui/src/correspondence-recovery.test.tsx diff --git a/job-tracker-ui/scripts/verify-correspondence-recovery.mjs b/job-tracker-ui/scripts/verify-correspondence-recovery.mjs new file mode 100644 index 00000000..7f7e5c12 --- /dev/null +++ b/job-tracker-ui/scripts/verify-correspondence-recovery.mjs @@ -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(); +} diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index d5a57273..d13e48d2 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -130,9 +130,12 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j }; const [messages, setMessages] = useState([]); const [messagesLoading, setMessagesLoading] = useState(true); - const [messagesError, setMessagesError] = useState(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(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(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(`/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 ( - load().catch(() => undefined)} compact /> + load().catch(() => undefined)} compact /> {messages.length === 0 && !messagesLoading && !messagesError ? ( {t("correspondenceNoMessages")} @@ -580,16 +603,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j - v && setFrom(v)} size="small"> + v && setFrom(v)} size="small"> {t("correspondenceMe")} {t("correspondenceCompany")} - + - 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 })} /> + 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 })} /> - + setManageThreadId(null)} fullWidth maxWidth="sm"> diff --git a/job-tracker-ui/src/correspondence-recovery.test.tsx b/job-tracker-ui/src/correspondence-recovery.test.tsx new file mode 100644 index 00000000..4cbd9700 --- /dev/null +++ b/job-tracker-ui/src/correspondence-recovery.test.tsx @@ -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; +function deferred() { + let resolve!: (value: any) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; +} +function view(jobId = 42) { + return + + ; +} +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(); +});