fix(correspondence): preserve current reads and note recovery
This commit is contained in:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user