fix(ui): preserve compose draft identity and delivery safeguards

This commit is contained in:
cesnimda
2026-10-14 13:03:05 +02:00
parent a791e353b0
commit 223dcf57d2
18 changed files with 4947 additions and 267 deletions
@@ -66,6 +66,42 @@ function HistoryBackControl() {
return <button onClick={() => navigate(-1)}>History Back</button>;
}
const composeUuid = '00000000-0000-4000-8000-000000000101';
const replacementUuid = '00000000-0000-4000-8000-000000000202';
function savedComposeDraft(overrides: Record<string, unknown> = {}) {
return { id: 'compose-owned', revision: 4, jobApplicationId: 42, provider: 'gmail', to: 'maria@acme.test', subject: 'Owned compose draft', bodyText: 'Owned saved body', threadId: 'thread-1', clientRequestId: 'server-owned-attempt', createdAtUtc: '2026-02-13T11:30:00Z', updatedAtUtc: '2026-02-13T11:30:00Z', ...overrides };
}
function composeReads(stored: ReturnType<typeof savedComposeDraft>[] = []) {
const original = mockedApi.get.getMockImplementation();
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === '/email/providers') return Promise.resolve({ data: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }] } as any);
if (url === '/email/drafts') return Promise.resolve({ data: stored } as any);
if (url === '/jobapplications/choices') return Promise.resolve({ data: [{ id: 42, jobTitle: 'Backend Engineer', companyName: 'Acme Systems' }] } as any);
return original!(url, config);
});
const uuid = globalThis.crypto.randomUUID as jest.Mock;
uuid.mockReset().mockReturnValueOnce(composeUuid).mockReturnValue(replacementUuid);
return uuid;
}
function composeField(key: string) {
return within(screen.getByRole('region', { name: copy('emailInboxDraftTitle') })).getByRole('textbox', { name: copy(key) });
}
async function openReply(bodyText = '') {
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxViewMessage') }));
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxReply') }));
if (bodyText) fireEvent.change(composeField('emailInboxMessage'), { target: { value: bodyText } });
}
async function closeConfirmation(actionKey: string) {
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: copy(actionKey) }));
await waitFor(() => expect(screen.queryByRole('dialog', { hidden: true })).not.toBeInTheDocument());
}
function renderPage(initialPath = '/') {
return render(
<ToastProvider>
@@ -834,4 +870,299 @@ describe('CorrespondenceInboxPage', () => {
expect(mockedApi.patch).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('held draft Save captures one write and locks edits, Send, Discard and every draft replacement entry', async () => {
composeReads([savedComposeDraft()]);
const save = deferred<any>();
mockedApi.post.mockReturnValue(save.promise);
renderPage();
await openReply('Captured save body');
await waitFor(() => expect(screen.getByRole('button', { name: copy('emailInboxCompose') })).toBeEnabled());
const button = screen.getByRole('button', { name: copy('emailInboxSaveDraft') });
act(() => { fireEvent.click(button); fireEvent.click(button); });
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', { jobApplicationId: 42, provider: 'gmail', to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Captured save body', threadId: 'thread-1' });
for (const key of ['emailInboxRecipient', 'emailInboxSubject', 'emailInboxMessage']) expect(composeField(key)).toBeDisabled();
for (const key of ['emailInboxReviewSend', 'emailInboxDiscardDraft', 'emailInboxCompose', 'emailInboxReply']) expect(screen.getByRole('button', { name: copy(key) })).toBeDisabled();
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) })).toBeDisabled();
await act(async () => { save.resolve({ data: savedComposeDraft({ to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Captured save body' }) }); await save.promise; });
await waitFor(() => expect(screen.getByRole('button', { name: copy('profileSaveChanges') })).toBeEnabled());
expect(composeField('emailInboxMessage')).toHaveValue('Captured save body');
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('double Review creates one confirmation, Cancel sends nothing and explicit double Send owns one captured attempt', async () => {
const uuid = composeReads();
const send = deferred<any>();
mockedApi.post.mockReturnValue(send.promise);
renderPage();
await openReply('Captured confirmed reply');
const review = screen.getByRole('button', { name: copy('emailInboxReviewSend') });
review.focus();
act(() => { fireEvent.click(review); fireEvent.click(review); });
expect(await screen.findAllByRole('dialog')).toHaveLength(1);
expect(mockedApi.post).not.toHaveBeenCalled();
await closeConfirmation('cancel');
expect(mockedApi.post).not.toHaveBeenCalled();
await waitFor(() => expect(review).toHaveFocus());
expect(composeField('emailInboxMessage')).toHaveValue('Captured confirmed reply');
fireEvent.click(review);
const dialog = await screen.findByRole('dialog');
const confirm = within(dialog).getByRole('button', { name: copy('emailInboxSendEmail') });
act(() => { fireEvent.click(confirm); fireEvent.click(confirm); });
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
expect(mockedApi.post).toHaveBeenCalledWith('/email/send', { jobApplicationId: 42, provider: 'gmail', clientRequestId: composeUuid, to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Captured confirmed reply', threadId: 'thread-1', confirmed: true });
await waitFor(() => expect(screen.queryByRole('dialog', { hidden: true })).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: copy('emailInboxSending') })).toBeDisabled();
expect(uuid).toHaveBeenCalledTimes(1);
await act(async () => { send.resolve({ data: { attemptId: 'server-pending', status: 'pending', duplicate: false } }); await send.promise; });
expect(await screen.findByText(copy('emailInboxDeliveryUncertain'))).toBeInTheDocument();
expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeDisabled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
test('recipient, subject and body edits cannot erase any recorded delivery state or rotate its attempt identity', async () => {
for (const status of ['uncertain', 'pending', 'sending', 'sent', 'failed'] as const) {
const uuid = composeReads();
mockedApi.post.mockReset();
const result = { attemptId: `recorded-${status}`, status, duplicate: false, failureCategory: status === 'failed' ? 'provider_rejected' : null };
mockedApi.post.mockImplementation(() => status === 'failed' ? Promise.reject({ response: { status: 502, data: result } }) : Promise.resolve({ data: result } as any));
const mounted = renderPage();
await openReply('Recorded attempt body');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
const stateKey = status === 'sent' ? 'emailInboxSentSuccess' : status === 'failed' ? 'emailInboxDeliveryFailed' : 'emailInboxDeliveryUncertain';
await screen.findByText(copy(stateKey));
for (const [key, value] of [['emailInboxRecipient', 'changed@acme.test'], ['emailInboxSubject', 'Changed subject'], ['emailInboxMessage', 'Changed body']]) {
fireEvent.change(composeField(key), { target: { value } });
expect(screen.getByText(copy(stateKey))).toBeInTheDocument();
expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeDisabled();
expect(uuid).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
}
if (status === 'failed') expect(screen.getByRole('button', { name: copy('emailInboxPrepareAttempt') })).toBeInTheDocument();
else expect(screen.queryByRole('button', { name: copy('emailInboxPrepareAttempt') })).not.toBeInTheDocument();
mounted.unmount();
}
});
test('generic 409 cannot prepare a new attempt while an authoritative failed result requires one explicit server rotation', async () => {
let uuid = composeReads([savedComposeDraft()]);
mockedApi.post.mockRejectedValue({ response: { status: 409, data: { detail: 'Draft or delivery conflict' } } });
let mounted = renderPage();
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
await screen.findByText(copy('emailInboxDeliveryUncertain'));
expect(screen.queryByRole('button', { name: copy('emailInboxPrepareAttempt') })).not.toBeInTheDocument();
expect(uuid).not.toHaveBeenCalled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith('/email/send', expect.objectContaining({ clientRequestId: 'server-owned-attempt' }));
mounted.unmount();
uuid = composeReads([savedComposeDraft()]);
mockedApi.post.mockReset();
const rotate = deferred<any>();
let sends = 0;
mockedApi.post.mockImplementation((url: string) => {
if (url === '/email/send') return ++sends === 1 ? Promise.reject({ response: { status: 502, data: { attemptId: 'failed-server-attempt', status: 'failed', duplicate: false, failureCategory: 'provider_rejected' } } }) : Promise.resolve({ data: { attemptId: 'new-server-attempt', status: 'pending', duplicate: false } } as any);
if (url === '/email/drafts/compose-owned/new-attempt') return rotate.promise;
return Promise.reject(new Error(`Unexpected POST ${url}`));
});
mounted = renderPage();
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
const prepare = await screen.findByRole('button', { name: copy('emailInboxPrepareAttempt') });
expect(mockedApi.post).toHaveBeenCalledTimes(1);
act(() => { fireEvent.click(prepare); fireEvent.click(prepare); });
expect(mockedApi.post).toHaveBeenCalledTimes(2);
expect(mockedApi.post).toHaveBeenLastCalledWith('/email/drafts/compose-owned/new-attempt', { revision: 4 });
expect(uuid).not.toHaveBeenCalled();
await act(async () => { rotate.resolve({ data: savedComposeDraft({ revision: 5, clientRequestId: 'server-rotated-attempt' }) }); await rotate.promise; });
await waitFor(() => expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
expect(mockedApi.post).toHaveBeenLastCalledWith('/email/send', expect.objectContaining({ clientRequestId: 'server-rotated-attempt', confirmed: true }));
expect(uuid).not.toHaveBeenCalled();
mounted.unmount();
});
test('recipient-only and subject-only unsaved changes require replacement and discard confirmation whose Cancel retains the draft', async () => {
for (const [key, value] of [['emailInboxRecipient', 'edited-recipient@acme.test'], ['emailInboxSubject', 'Edited subject only']]) {
composeReads([savedComposeDraft()]);
const mounted = renderPage();
await openReply();
fireEvent.change(composeField(key), { target: { value } });
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
expect(await screen.findByRole('dialog', { name: copy('emailInboxReplaceDraftTitle') })).toBeInTheDocument();
await closeConfirmation('cancel');
expect(composeField(key)).toHaveValue(value);
expect(composeField('emailInboxMessage')).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxDiscardDraft') }));
expect(await screen.findByRole('dialog', { name: copy('emailInboxDiscardDraftTitle') })).toBeInTheDocument();
await closeConfirmation('cancel');
expect(composeField(key)).toHaveValue(value);
expect(composeField('emailInboxMessage')).toHaveValue('');
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
mounted.unmount();
}
});
test('saved-draft initial read and post-save refresh failures recover with GET-only Retry without replaying the successful Save', async () => {
composeReads();
const original = mockedApi.get.getMockImplementation();
const retry = deferred<any>();
let draftReads = 0;
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === '/email/drafts') {
draftReads += 1;
if (draftReads === 1 || draftReads === 3) return Promise.reject({ response: { status: 503 } });
if (draftReads === 2) return retry.promise;
return Promise.resolve({ data: [savedComposeDraft({ to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'New captured save' })] } as any);
}
return original!(url, config);
});
mockedApi.post.mockResolvedValue({ data: savedComposeDraft({ to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'New captured save' }) } as any);
renderPage();
await screen.findByText(copy('emailInboxDraftsLoadFailed'));
const button = retryNotice('emailInboxDraftsLoadFailed').getByRole('button', { name: copy('retry') });
act(() => { fireEvent.click(button); fireEvent.click(button); });
expect(draftReads).toBe(2);
expect(mockedApi.post).not.toHaveBeenCalled();
await act(async () => { retry.resolve({ data: [] }); await retry.promise; });
await openReply('New captured save');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxSaveDraft') }));
await waitFor(() => expect(draftReads).toBe(3));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(composeField('emailInboxMessage')).toHaveValue('New captured save');
expect(screen.getByRole('button', { name: copy('profileSaveChanges') })).toBeInTheDocument();
fireEvent.click(retryNotice('emailInboxDraftsLoadFailed').getByRole('button', { name: copy('retry') }));
await waitFor(() => expect(draftReads).toBe(4));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Re: Interview invite' }) })).toBeInTheDocument();
});
test('a late Save after inbox unmount cannot refresh drafts or attach its result to another page', async () => {
composeReads();
const save = deferred<any>();
mockedApi.post.mockReturnValue(save.promise);
const mounted = renderPage();
await openReply('Old owner save');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxSaveDraft') }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', expect.anything()));
const reads = mockedApi.get.mock.calls.length;
mounted.unmount();
await act(async () => { save.resolve({ data: savedComposeDraft({ bodyText: 'Old owner save' }) }); await save.promise; });
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(screen.queryByRole('region', { name: copy('emailInboxDraftTitle') })).not.toBeInTheDocument();
expect(screen.queryByText(copy('emailInboxDraftSaved'))).not.toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('a held managed-draft 409 retains recipient, subject, body and revision with current-language original Save recovery', async () => {
const uuid = composeReads([savedComposeDraft()]);
const save = deferred<any>();
mockedApi.put.mockReturnValueOnce(save.promise).mockRejectedValueOnce({ response: { status: 409 } });
renderPage();
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
fireEvent.change(composeField('emailInboxRecipient'), { target: { value: 'local@acme.test' } });
fireEvent.change(composeField('emailInboxSubject'), { target: { value: 'Local conflict subject' } });
fireEvent.change(composeField('emailInboxMessage'), { target: { value: 'Local conflict body' } });
fireEvent.click(screen.getByRole('button', { name: copy('profileSaveChanges') }));
expect(mockedApi.put).toHaveBeenCalledWith('/email/drafts/compose-owned', { revision: 4, to: 'local@acme.test', subject: 'Local conflict subject', bodyText: 'Local conflict body' });
await act(async () => { save.reject({ response: { status: 409 } }); await save.promise.catch(() => undefined); });
await screen.findByText(copy('emailInboxDraftChangedElsewhere'));
const reads = mockedApi.get.mock.calls.length;
await changeLanguage('nb');
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(screen.getByText(copy('emailInboxDraftChangedElsewhere'))).toBeInTheDocument();
expect(composeField('emailInboxRecipient')).toHaveValue('local@acme.test');
expect(composeField('emailInboxSubject')).toHaveValue('Local conflict subject');
expect(composeField('emailInboxMessage')).toHaveValue('Local conflict body');
expect(uuid).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: copy('profileSaveChanges') }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
expect(mockedApi.put).toHaveBeenLastCalledWith('/email/drafts/compose-owned', { revision: 4, to: 'local@acme.test', subject: 'Local conflict subject', bodyText: 'Local conflict body' });
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('server attempt rotation preserves three unsaved fields and their dirty state while the next send uses the new identity', async () => {
const uuid = composeReads([savedComposeDraft()]);
const original = mockedApi.get.getMockImplementation();
let serverDraft = savedComposeDraft();
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/email/drafts' ? Promise.resolve({ data: [serverDraft] } as any) : original!(url, config));
let sends = 0;
mockedApi.post.mockImplementation((url: string) => {
if (url === '/email/send') return ++sends === 1
? Promise.reject({ response: { status: 502, data: { attemptId: 'failed-owned-attempt', status: 'failed', duplicate: false, failureCategory: 'provider_rejected' } } })
: Promise.resolve({ data: { attemptId: 'rotated-owned-attempt', status: 'pending', duplicate: false } } as any);
if (url === '/email/drafts/compose-owned/new-attempt') {
serverDraft = savedComposeDraft({ revision: 5, clientRequestId: 'server-rotated-owned-key' });
return Promise.resolve({ data: serverDraft } as any);
}
return Promise.reject(new Error(`Unexpected POST ${url}`));
});
renderPage();
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
fireEvent.change(composeField('emailInboxRecipient'), { target: { value: 'edited-recipient@acme.test' } });
fireEvent.change(composeField('emailInboxSubject'), { target: { value: 'Unsaved edited subject' } });
fireEvent.change(composeField('emailInboxMessage'), { target: { value: 'Unsaved edited message' } });
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxPrepareAttempt') }));
await waitFor(() => expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeEnabled());
expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts/compose-owned/new-attempt', { revision: 4 });
expect(composeField('emailInboxRecipient')).toHaveValue('edited-recipient@acme.test');
expect(composeField('emailInboxSubject')).toHaveValue('Unsaved edited subject');
expect(composeField('emailInboxMessage')).toHaveValue('Unsaved edited message');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
const dialog = await screen.findByRole('dialog', { name: copy('emailInboxReplaceDraftTitle') });
expect(within(dialog).getByText(copy('emailInboxUnsavedDraftLost'))).toBeInTheDocument();
await closeConfirmation('cancel');
expect(composeField('emailInboxMessage')).toHaveValue('Unsaved edited message');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
expect(mockedApi.post).toHaveBeenLastCalledWith('/email/send', { jobApplicationId: 42, provider: 'gmail', clientRequestId: 'server-rotated-owned-key', to: 'edited-recipient@acme.test', subject: 'Unsaved edited subject', bodyText: 'Unsaved edited message', threadId: 'thread-1', confirmed: true });
expect(mockedApi.post).toHaveBeenCalledTimes(3);
expect(uuid).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('a committed Save starts a fresh draft-list read despite an older pending GET and rejects the late old snapshot', async () => {
composeReads();
const original = mockedApi.get.getMockImplementation();
const oldRead = deferred<any>();
const freshRead = deferred<any>();
let reads = 0;
const created = savedComposeDraft({ id: 'newly-saved-owned', revision: 1, clientRequestId: 'newly-saved-server-key', to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Freshly saved message' });
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/email/drafts' ? ++reads === 1 ? oldRead.promise : freshRead.promise : original!(url, config));
mockedApi.post.mockResolvedValue({ data: created } as any);
renderPage();
await openReply('Freshly saved message');
expect(reads).toBe(1);
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxSaveDraft') }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
await waitFor(() => expect(reads).toBe(2));
expect(mockedApi.get).toHaveBeenLastCalledWith('/email/drafts');
await act(async () => { freshRead.resolve({ data: [created] }); await freshRead.promise; });
expect(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Re: Interview invite' }) })).toBeInTheDocument();
await act(async () => { oldRead.resolve({ data: [savedComposeDraft({ id: 'outdated-owned', subject: 'Stale old draft' })] }); await oldRead.promise; });
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Re: Interview invite' }) })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Stale old draft' }) })).not.toBeInTheDocument();
expect(composeField('emailInboxMessage')).toHaveValue('Freshly saved message');
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
});