1169 lines
71 KiB
TypeScript
1169 lines
71 KiB
TypeScript
import React from 'react';
|
|
import '@testing-library/jest-dom';
|
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { MemoryRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
|
import { ToastProvider } from './toast';
|
|
import { ConfirmProvider } from './confirm';
|
|
import { I18nProvider } from './i18n/I18nProvider';
|
|
import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
|
|
import { api } from './api';
|
|
import { translations, type UiLanguage } from './i18n/translations';
|
|
|
|
jest.mock('./api', () => ({
|
|
api: {
|
|
get: jest.fn(),
|
|
post: jest.fn(),
|
|
put: jest.fn(),
|
|
patch: jest.fn(),
|
|
delete: jest.fn(),
|
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
|
},
|
|
getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.',
|
|
}));
|
|
|
|
const mockedApi = api as jest.Mocked<typeof api>;
|
|
|
|
function copy(key: string, params: Record<string, string | number> = {}, language: UiLanguage = document.documentElement.lang === 'nb-NO' ? 'nb' : 'en') {
|
|
return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason: unknown) => void;
|
|
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function changeLanguage(language: UiLanguage) {
|
|
await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
|
|
}
|
|
|
|
function retryNotice(key: string) {
|
|
const alert = screen.getAllByRole('alert').find((node) => node.textContent?.includes(copy(key)) && within(node).queryByRole('button', { name: copy('retry') }));
|
|
expect(alert).toBeDefined();
|
|
return within(alert!);
|
|
}
|
|
|
|
function messageItem(overrides: Record<string, unknown> = {}) {
|
|
return { id: 1, jobApplicationId: 42, companyName: 'Acme Systems', jobTitle: 'Backend Engineer', from: 'Recruiter', direction: 'inbound', subject: 'Interview invite', channel: 'Email', date: '2026-02-13T11:30:00Z', contentPreview: 'Saved preview', externalThreadId: 'thread-1', externalMessageId: 'message-1', provider: 'gmail', externalFrom: 'Maria <maria@acme.test>', externalTo: 'owner@gmail.test', labelCount: 0, attachmentCount: 0, ...overrides };
|
|
}
|
|
|
|
function pageData(items: ReturnType<typeof messageItem>[], page = 1, total = items.length, totalPages = total > 50 ? 2 : 1) {
|
|
return { items, page, pageSize: 50, total, totalPages };
|
|
}
|
|
|
|
function messageRow(subject: string) {
|
|
return within(screen.getByText(subject, { exact: true }).closest('.MuiPaper-root') as HTMLElement);
|
|
}
|
|
|
|
function LocationProbe() {
|
|
const location = useLocation();
|
|
return <output data-testid="inbox-location">{location.pathname}{location.search}{location.hash}</output>;
|
|
}
|
|
|
|
function HistoryBackControl() {
|
|
const navigate = useNavigate();
|
|
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>
|
|
<ConfirmProvider>
|
|
<I18nProvider>
|
|
<MemoryRouter initialEntries={[initialPath]}>
|
|
<LocationProbe />
|
|
<CorrespondenceInboxPage />
|
|
</MemoryRouter>
|
|
</I18nProvider>
|
|
</ConfirmProvider>
|
|
</ToastProvider>,
|
|
);
|
|
}
|
|
|
|
describe('CorrespondenceInboxPage', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
document.documentElement.lang = 'en';
|
|
jest.clearAllMocks();
|
|
mockedApi.get.mockReset();
|
|
mockedApi.post.mockReset();
|
|
mockedApi.put.mockReset();
|
|
mockedApi.patch.mockReset();
|
|
mockedApi.delete.mockReset();
|
|
Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: jest.fn(() => '00000000-0000-4000-8000-000000000123') });
|
|
mockedApi.get.mockImplementation((url: string) => {
|
|
if (url === '/email/providers') return Promise.resolve({ data: [
|
|
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false },
|
|
{ provider: 'microsoft', displayName: 'Outlook', connected: false, address: null, canRead: false, canSend: false },
|
|
] } as any);
|
|
if (url === '/correspondence/page') return Promise.resolve({ data: { items: [
|
|
{
|
|
id: 1,
|
|
jobApplicationId: 42,
|
|
companyName: 'Acme Systems',
|
|
jobTitle: 'Backend Engineer',
|
|
from: 'Company',
|
|
direction: 'inbound',
|
|
subject: 'Interview invite',
|
|
channel: 'Email',
|
|
date: new Date().toISOString(),
|
|
contentPreview: 'We would like to schedule an interview.',
|
|
externalThreadId: 'thread-1',
|
|
externalMessageId: 'message-1',
|
|
provider: 'gmail',
|
|
externalFrom: 'Maria Recruiter <maria@acme.test>',
|
|
externalTo: 'user@example.test',
|
|
labelCount: 2,
|
|
attachmentCount: 1,
|
|
},
|
|
], page: 1, pageSize: 50, total: 1, totalPages: 1 } } as any);
|
|
if (url === '/email/drafts') return Promise.resolve({ data: [] } as any);
|
|
if (url === '/jobapplications/choices') return Promise.resolve({ data: [] } as any);
|
|
if (url === '/email/message') return Promise.resolve({ data: {
|
|
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter <maria@acme.test>', to: 'user@example.test',
|
|
date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }],
|
|
} } as any);
|
|
if (url === '/gmail/review-candidates') return Promise.resolve({ data: {
|
|
queries: [], candidateThreadCount: 0, autoLinkThreadCount: 0, reviewThreadCount: 0, unmatchedThreadCount: 0, threads: [],
|
|
} } as any);
|
|
if (url === '/gmail/suggested-jobs') return Promise.resolve({ data: { count: 0, items: [] } } as any);
|
|
return Promise.resolve({ data: {} } as any);
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
test('renders correspondence inbox items and reloads with filters', async () => {
|
|
renderPage();
|
|
|
|
expect(await screen.findByRole('heading', { name: /job email/i })).toBeInTheDocument();
|
|
expect(await screen.findByText(/1 items/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/acme systems/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/2 labels/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/1 attachments/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/Gmail: owner@gmail\.test · Read only; reconnect to enable send/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/^Outlook: Not connected$/i)).toBeInTheDocument();
|
|
expect(screen.queryByText(/Outlook: Not connected.*Read only/i)).not.toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByLabelText(/search/i), { target: { value: 'Maria' } });
|
|
fireEvent.mouseDown(screen.getAllByRole('combobox')[0]);
|
|
fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', expect.objectContaining({
|
|
params: expect.objectContaining({
|
|
q: 'Maria',
|
|
direction: 'inbound',
|
|
page: 1,
|
|
pageSize: 50,
|
|
}),
|
|
}));
|
|
});
|
|
});
|
|
|
|
test('keeps saved correspondence visible when provider status is unavailable', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/email/providers') return Promise.reject(new Error('provider status unavailable'));
|
|
return original!(url, config);
|
|
});
|
|
|
|
renderPage();
|
|
|
|
expect(await screen.findByText(/email provider status is temporarily unavailable/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/acme systems/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/saved jobtracker correspondence remains available/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test('opens provider-backed plain-text message detail in the hub', async () => {
|
|
renderPage();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
|
|
|
|
expect(await screen.findByText(/please choose an interview time/i)).toBeInTheDocument();
|
|
expect(screen.getByText('INBOX')).toBeInTheDocument();
|
|
expect(screen.getByText('agenda.pdf')).toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledWith('/email/message', {
|
|
params: { provider: 'gmail', messageId: 'message-1' },
|
|
});
|
|
});
|
|
|
|
test('navigates beyond the first correspondence page', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/correspondence/page') {
|
|
const page = config?.params?.page ?? 1;
|
|
return Promise.resolve({ data: {
|
|
items: [{
|
|
id: page,
|
|
jobApplicationId: 42,
|
|
companyName: page === 2 ? 'Second page company' : 'First page company',
|
|
jobTitle: 'Engineer',
|
|
from: 'Recruiter',
|
|
direction: 'inbound',
|
|
subject: `Page ${page}`,
|
|
channel: 'Email',
|
|
date: new Date().toISOString(),
|
|
contentPreview: `Page ${page} message`,
|
|
labelCount: 0,
|
|
attachmentCount: 0,
|
|
}],
|
|
page,
|
|
pageSize: 50,
|
|
total: 51,
|
|
totalPages: 2,
|
|
} } as any);
|
|
}
|
|
return original!(url, config);
|
|
});
|
|
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: /go to page 2/i }));
|
|
|
|
expect(await screen.findByText(/second page company/i)).toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.objectContaining({
|
|
params: expect.objectContaining({ page: 2, pageSize: 50 }),
|
|
}));
|
|
});
|
|
|
|
test('unlinks a Gmail thread only after confirmation and returns it to review', async () => {
|
|
mockedApi.post.mockResolvedValue({ data: { threadId: 'thread-1', jobApplicationId: 42, removedMessages: 1, decision: 'review' } } as any);
|
|
renderPage();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /^unlink thread$/i }));
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
|
|
const dialog = await screen.findByRole('dialog');
|
|
expect(within(dialog).getByText(/provider copy is not deleted/i)).toBeInTheDocument();
|
|
fireEvent.click(within(dialog).getByRole('button', { name: /^unlink thread$/i }));
|
|
|
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/gmail/unlink-thread', {
|
|
jobApplicationId: 42,
|
|
threadId: 'thread-1',
|
|
note: 'Unlinked from Job email hub',
|
|
nextDecision: 'review',
|
|
}));
|
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.anything()));
|
|
expect(await screen.findByText(/returned to recruitment review/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test('falls back to the saved copy when provider detail is unavailable', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/email/message') return Promise.reject(new Error('reauthorization required'));
|
|
if (url === '/correspondence/message/1') return Promise.resolve({ data: {
|
|
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter', to: 'user@example.test',
|
|
date: new Date().toISOString(), snippet: 'Saved', bodyText: 'Saved interview message.', labels: [], attachments: [],
|
|
} } as any);
|
|
return original!(url, config);
|
|
});
|
|
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
|
|
|
|
expect(await screen.findByText(/showing the saved jobtracker copy/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/saved interview message/i)).toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/message/1');
|
|
});
|
|
|
|
test('keeps replies editable and requires final send confirmation', async () => {
|
|
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);
|
|
return original!(url, config);
|
|
});
|
|
mockedApi.post.mockResolvedValue({ data: {
|
|
attemptId: 'attempt-1', status: 'sent', duplicate: false, externalMessageId: 'sent-1', externalThreadId: 'thread-1', failureCategory: null,
|
|
} } as any);
|
|
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i }));
|
|
|
|
expect(screen.getByRole('textbox', { name: /^provider$/i })).toHaveValue('Gmail · owner@gmail.test');
|
|
expect(screen.getByLabelText(/recipient/i)).toHaveValue('Maria Recruiter <maria@acme.test>');
|
|
expect(screen.getByLabelText(/subject/i)).toHaveValue('Re: Interview invite');
|
|
expect(screen.getByLabelText(/thread/i)).toHaveValue('thread-1');
|
|
fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Thank you. Tuesday works well.' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
|
|
|
expect(await screen.findByRole('heading', { name: /confirm email send/i })).toBeInTheDocument();
|
|
expect(screen.getByText(/owner@gmail\.test.*maria@acme\.test/i)).toBeInTheDocument();
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Thank you. Tuesday works well.');
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
|
fireEvent.click(screen.getByRole('button', { name: /^send email$/i }));
|
|
|
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/send', {
|
|
jobApplicationId: 42,
|
|
provider: 'gmail',
|
|
clientRequestId: '00000000-0000-4000-8000-000000000123',
|
|
to: 'Maria Recruiter <maria@acme.test>',
|
|
subject: 'Re: Interview invite',
|
|
bodyText: 'Thank you. Tuesday works well.',
|
|
threadId: 'thread-1',
|
|
confirmed: true,
|
|
}));
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
expect(await screen.findByText(/sent successfully/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled();
|
|
});
|
|
|
|
test('saves an incomplete reply with its server delivery identity', async () => {
|
|
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);
|
|
return original!(url, config);
|
|
});
|
|
mockedApi.post.mockImplementation((url: string) => {
|
|
if (url === '/email/drafts') return Promise.resolve({ data: {
|
|
id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: '',
|
|
threadId: 'thread-1', clientRequestId: 'server-request-id', revision: 1, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(),
|
|
} } as any);
|
|
return Promise.reject(new Error(`Unexpected POST ${url}`));
|
|
});
|
|
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i }));
|
|
fireEvent.click(screen.getByRole('button', { name: /^save draft$/i }));
|
|
|
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', {
|
|
jobApplicationId: 42,
|
|
provider: 'gmail',
|
|
to: 'Maria Recruiter <maria@acme.test>',
|
|
subject: 'Re: Interview invite',
|
|
bodyText: '',
|
|
threadId: 'thread-1',
|
|
}));
|
|
expect(await screen.findByText(/email draft saved/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /^save changes$/i })).toBeInTheDocument();
|
|
});
|
|
|
|
test('resumes a saved draft and surfaces revision conflicts without overwriting it', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/email/drafts') return Promise.resolve({ data: [{
|
|
id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'maria@acme.test', subject: 'Saved subject', bodyText: 'Saved private draft.',
|
|
threadId: 'thread-1', clientRequestId: 'server-request-id', revision: 4, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(),
|
|
}] } as 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);
|
|
return original!(url, config);
|
|
});
|
|
mockedApi.put.mockRejectedValue({ response: { status: 409 } });
|
|
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: /resume saved subject/i }));
|
|
expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Saved private draft.');
|
|
fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Conflicting local edit.' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^save changes$/i }));
|
|
|
|
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/email/drafts/draft-1', {
|
|
revision: 4,
|
|
to: 'maria@acme.test',
|
|
subject: 'Saved subject',
|
|
bodyText: 'Conflicting local edit.',
|
|
}));
|
|
expect(await screen.findByText(/changed in another tab/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Conflicting local edit.');
|
|
});
|
|
|
|
test('persists a new delivery identity only after a definitive failed attempt', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
const stored = {
|
|
id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'maria@acme.test', subject: 'Saved subject', bodyText: 'Saved private draft.',
|
|
threadId: 'thread-1', clientRequestId: 'failed-request-id', revision: 4, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(),
|
|
};
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/email/drafts') return Promise.resolve({ data: [stored] } as 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);
|
|
return original!(url, config);
|
|
});
|
|
mockedApi.post.mockImplementation((url: string) => {
|
|
if (url === '/email/send') return Promise.reject({ response: { status: 502, data: {
|
|
attemptId: 'attempt-1', status: 'failed', duplicate: false, failureCategory: 'provider_rejected',
|
|
} } });
|
|
if (url === '/email/drafts/draft-1/new-attempt') return Promise.resolve({ data: {
|
|
...stored, revision: 5, clientRequestId: 'new-server-request-id', updatedAtUtc: new Date().toISOString(),
|
|
} } as any);
|
|
return Promise.reject(new Error(`Unexpected POST ${url}`));
|
|
});
|
|
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: /resume saved subject/i }));
|
|
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /^send email$/i }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /prepare new attempt/i }));
|
|
|
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts/draft-1/new-attempt', { revision: 4 }));
|
|
await waitFor(() => expect(screen.queryByText(/provider confirmed that this attempt did not complete/i)).not.toBeInTheDocument());
|
|
expect(screen.getByRole('button', { name: /review and send/i })).toBeEnabled();
|
|
});
|
|
|
|
test('starts a new-message draft only from an owned job and send-capable provider choice', async () => {
|
|
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 },
|
|
{ provider: 'microsoft', displayName: 'Outlook', connected: true, address: 'owner@outlook.test', canRead: true, canSend: false },
|
|
] } as any);
|
|
if (url === '/jobapplications/choices') return Promise.resolve({ data: [
|
|
{ id: 42, jobTitle: 'Backend Engineer', companyName: 'Acme Systems' },
|
|
] } as any);
|
|
return original!(url, config);
|
|
});
|
|
mockedApi.post.mockImplementation((url: string) => {
|
|
if (url === '/email/drafts') return Promise.resolve({ data: {
|
|
id: 'draft-new', jobApplicationId: 42, provider: 'gmail', to: 'recruiter@acme.test', subject: 'Application question', bodyText: 'Hello from a synthetic draft.',
|
|
threadId: null, clientRequestId: 'server-request-id', revision: 1, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(),
|
|
} } as any);
|
|
return Promise.reject(new Error(`Unexpected POST ${url}`));
|
|
});
|
|
|
|
renderPage();
|
|
const compose = await screen.findByRole('button', { name: /compose new email/i });
|
|
await waitFor(() => expect(compose).toBeEnabled());
|
|
fireEvent.click(compose);
|
|
expect(await screen.findByRole('heading', { name: /compose new email/i })).toBeInTheDocument();
|
|
const providerSelect = screen.getByRole('combobox', { name: /sending provider/i });
|
|
expect(providerSelect).toHaveTextContent(/Gmail/i);
|
|
fireEvent.mouseDown(providerSelect);
|
|
const gmailOption = await screen.findByRole('option', { name: /Gmail/i });
|
|
expect(screen.queryByRole('option', { name: /Outlook/i })).not.toBeInTheDocument();
|
|
fireEvent.click(gmailOption);
|
|
fireEvent.click(screen.getByRole('button', { name: /start draft/i }));
|
|
|
|
expect(screen.getByRole('textbox', { name: /^provider$/i })).toHaveValue('Gmail · owner@gmail.test');
|
|
expect(screen.getByLabelText(/thread/i)).toHaveValue('New message');
|
|
fireEvent.change(screen.getByLabelText(/recipient/i), { target: { value: 'recruiter@acme.test' } });
|
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Application question' } });
|
|
fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Hello from a synthetic draft.' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^save draft$/i }));
|
|
|
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', {
|
|
jobApplicationId: 42,
|
|
provider: 'gmail',
|
|
to: 'recruiter@acme.test',
|
|
subject: 'Application question',
|
|
bodyText: 'Hello from a synthetic draft.',
|
|
threadId: null,
|
|
}));
|
|
});
|
|
|
|
test('searches the complete server-side job choice set while composing', async () => {
|
|
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 === '/jobapplications/choices') {
|
|
return Promise.resolve({ data: config?.params?.q === 'Historic'
|
|
? [{ id: 7, jobTitle: 'Historic target role', companyName: 'Archive Co' }]
|
|
: [{ id: 42, jobTitle: 'Recent role', companyName: 'Acme Systems' }] } as any);
|
|
}
|
|
return original!(url, config);
|
|
});
|
|
|
|
renderPage();
|
|
const compose = await screen.findByRole('button', { name: /compose new email/i });
|
|
await waitFor(() => expect(compose).toBeEnabled());
|
|
fireEvent.click(compose);
|
|
fireEvent.change(screen.getByRole('combobox', { name: /^job$/i }), { target: { value: 'Historic' } });
|
|
|
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', {
|
|
params: { q: 'Historic', limit: 20 },
|
|
}));
|
|
fireEvent.click(await screen.findByRole('option', { name: /Archive Co.*Historic target role/i }));
|
|
fireEvent.click(screen.getByRole('button', { name: /start draft/i }));
|
|
expect(screen.getByText(/Archive Co.*Historic target role/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test('does not offer retry when delivery is uncertain', async () => {
|
|
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);
|
|
return original!(url, config);
|
|
});
|
|
mockedApi.post.mockRejectedValue({ response: { status: 409, data: {
|
|
attemptId: 'attempt-1', status: 'uncertain', duplicate: false, failureCategory: 'transport_interrupted',
|
|
} } });
|
|
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i }));
|
|
fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Synthetic reply.' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /^send email$/i }));
|
|
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
expect(await screen.findByText(/delivery status is uncertain/i)).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /prepare new attempt/i })).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled();
|
|
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('uses one hub for linked messages and recruitment suggestions', async () => {
|
|
renderPage();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /review suggestions/i }));
|
|
expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates');
|
|
expect(screen.getByRole('button', { name: /linked messages/i })).toBeInTheDocument();
|
|
});
|
|
|
|
test('localizes inbox controls while preserving correspondence content', async () => {
|
|
window.localStorage.setItem('uiLanguage', 'nb');
|
|
renderPage();
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Jobb-e-post' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Tilknyttede meldinger' })).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Retning')).toBeInTheDocument();
|
|
expect(screen.getByText('Interview invite')).toBeInTheDocument();
|
|
expect(screen.getByText(/Gmail: owner@gmail\.test · Kun lesing/)).toBeInTheDocument();
|
|
});
|
|
|
|
test('legacy Gmail review route redirects into the consolidated hub filter', async () => {
|
|
render(
|
|
<ToastProvider>
|
|
<ConfirmProvider>
|
|
<I18nProvider>
|
|
<MemoryRouter initialEntries={['/correspondence/review']}>
|
|
<Routes>
|
|
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
|
|
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</I18nProvider>
|
|
</ConfirmProvider>
|
|
</ToastProvider>,
|
|
);
|
|
|
|
expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument();
|
|
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/page', expect.anything());
|
|
});
|
|
|
|
test('failed inbox reads retain a current-language error and recover with one explicit GET-only Retry', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
const retry = deferred<any>();
|
|
let listReads = 0;
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/correspondence/page') return ++listReads === 1 ? Promise.reject({ response: { status: 503 } }) : retry.promise;
|
|
return original!(url, config);
|
|
});
|
|
renderPage();
|
|
await screen.findByText(copy('emailInboxLoadFailed'));
|
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', expect.anything()));
|
|
expect(screen.queryByText(copy('emailInboxNoMatches'))).not.toBeInTheDocument();
|
|
const reads = mockedApi.get.mock.calls.length;
|
|
await changeLanguage('nb');
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
|
const button = retryNotice('emailInboxLoadFailed').getByRole('button', { name: copy('retry') });
|
|
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
|
expect(listReads).toBe(2);
|
|
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: undefined, direction: undefined, linkState: undefined, page: 1, pageSize: 50 } });
|
|
expect(screen.getByRole('progressbar', { name: copy('loading') })).toBeInTheDocument();
|
|
await act(async () => { retry.resolve({ data: pageData([messageItem()]) }); await retry.promise; });
|
|
expect(await screen.findByText('Acme Systems', { exact: false })).toBeInTheDocument();
|
|
expect(screen.queryByText(copy('emailInboxNoMatches'))).not.toBeInTheDocument();
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
expect(mockedApi.patch).not.toHaveBeenCalled();
|
|
expect(mockedApi.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('a late old page cannot overwrite the latest query and page-one inbox snapshot', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
const oldPage = deferred<any>();
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/correspondence/page') {
|
|
const { q, page } = config.params;
|
|
if (q === 'A' && page === 2) return oldPage.promise;
|
|
return Promise.resolve({ data: pageData([messageItem({ subject: q === 'B' ? 'Current B query' : 'A query first page' })], 1, q === 'B' ? 1 : 51, q === 'B' ? 1 : 2) } as any);
|
|
}
|
|
return original!(url, config);
|
|
});
|
|
renderPage();
|
|
fireEvent.change(await screen.findByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'A' } });
|
|
await screen.findByText('A query first page');
|
|
fireEvent.click(screen.getByRole('button', { name: /go to page 2/i }));
|
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', { params: { q: 'A', direction: undefined, linkState: undefined, page: 2, pageSize: 50 } }));
|
|
fireEvent.change(screen.getByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'B' } });
|
|
await screen.findByText('Current B query');
|
|
await act(async () => { oldPage.resolve({ data: pageData([messageItem({ subject: 'Obsolete A page two' })], 2, 51, 2) }); await oldPage.promise; });
|
|
expect(screen.getByText('Current B query')).toBeInTheDocument();
|
|
expect(screen.queryByText('Obsolete A page two')).not.toBeInTheDocument();
|
|
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('B');
|
|
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: 'B', direction: undefined, linkState: undefined, page: 1, pageSize: 50 } });
|
|
expect(screen.queryByRole('button', { name: /go to page 2/i })).not.toBeInTheDocument();
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('message Retry replays the captured owned detail read without hiding it and localizes saved-copy fallback', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
let savedReads = 0;
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/email/message') return Promise.reject({ response: { status: 503 } });
|
|
if (url === '/correspondence/message/1') return ++savedReads === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: { id: 'message-1', threadId: 'thread-1', subject: 'Saved subject', from: 'Maria', to: 'Owner', bodyText: 'Owned saved body', snippet: '', labels: ['INBOX'], attachments: [{ fileName: 'owned.pdf' }] } } as any);
|
|
return original!(url, config);
|
|
});
|
|
renderPage();
|
|
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxViewMessage') }));
|
|
await screen.findByText(copy('emailInboxMessageLoadFailed'));
|
|
const reads = mockedApi.get.mock.calls.length;
|
|
await changeLanguage('nb');
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
|
fireEvent.click(retryNotice('emailInboxMessageLoadFailed').getByRole('button', { name: copy('retry') }));
|
|
expect(await screen.findByText('Owned saved body')).toBeInTheDocument();
|
|
expect(screen.getByText(copy('emailInboxProviderCopyUnavailable'))).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: copy('emailInboxHideMessage') })).toBeInTheDocument();
|
|
expect(screen.getByText('owned.pdf')).toBeInTheDocument();
|
|
const details = mockedApi.get.mock.calls.filter(([url]) => url === '/email/message' || url === '/correspondence/message/1');
|
|
expect(details).toEqual([
|
|
['/email/message', { params: { provider: 'gmail', messageId: 'message-1' } }], ['/correspondence/message/1'],
|
|
['/email/message', { params: { provider: 'gmail', messageId: 'message-1' } }], ['/correspondence/message/1'],
|
|
]);
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('hidden or inactive details never resume a saved-copy fallback and hub return preserves inbox context', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
const detailA = deferred<any>();
|
|
const detailB = deferred<any>();
|
|
const items = [messageItem({ subject: 'Detail A' }), messageItem({ id: 2, jobApplicationId: 43, subject: 'Detail B', externalThreadId: 'thread-2', externalMessageId: 'message-2' })];
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/correspondence/page') return Promise.resolve({ data: pageData(items, config.params.page, 51, 2) } as any);
|
|
if (url === '/email/message') return config.params.messageId === 'message-1' ? detailA.promise : detailB.promise;
|
|
return original!(url, config);
|
|
});
|
|
renderPage();
|
|
fireEvent.change(await screen.findByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'Recruiter' } });
|
|
await screen.findByText('Detail B');
|
|
fireEvent.click(screen.getByRole('button', { name: /go to page 2/i }));
|
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', { params: { q: 'Recruiter', direction: undefined, linkState: undefined, page: 2, pageSize: 50 } }));
|
|
fireEvent.click(messageRow('Detail A').getByRole('button', { name: copy('emailInboxViewMessage') }));
|
|
fireEvent.click(messageRow('Detail A').getByRole('button', { name: copy('emailInboxHideMessage') }));
|
|
await act(async () => { detailA.reject({ response: { status: 503 } }); await detailA.promise.catch(() => undefined); });
|
|
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/message/1');
|
|
fireEvent.click(messageRow('Detail B').getByRole('button', { name: copy('emailInboxViewMessage') }));
|
|
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSuggestions') }));
|
|
await screen.findByRole('heading', { name: copy('gmailReviewEmbeddedTitle') });
|
|
await act(async () => { detailB.reject({ response: { status: 503 } }); await detailB.promise.catch(() => undefined); });
|
|
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/message/2');
|
|
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxLinkedMessages') }));
|
|
expect(await screen.findByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('Recruiter');
|
|
expect(await screen.findByText('Detail B')).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: copy('emailInboxHideMessage') })).not.toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: 'Recruiter', direction: undefined, linkState: undefined, page: 2, pageSize: 50 } });
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('provider status Retry is isolated from saved messages and drafts and live locale changes cause no reads', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
const providerRetry = deferred<any>();
|
|
let providerReads = 0;
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/email/providers') return ++providerReads === 1 ? Promise.reject({ response: { status: 503 } }) : providerRetry.promise;
|
|
if (url === '/correspondence/page') return Promise.resolve({ data: pageData([messageItem()]) } as any);
|
|
if (url === '/email/drafts') return Promise.resolve({ data: [{ id: 'owned-draft', revision: 3, jobApplicationId: 42, provider: 'gmail', to: 'recruiter@acme.test', subject: 'Owned saved draft', bodyText: 'Private draft', threadId: 'thread-1', clientRequestId: 'owned-request', createdAtUtc: '2026-02-13T11:30:00Z', updatedAtUtc: '2026-02-13T11:30:00Z' }] } as any);
|
|
return original!(url, config);
|
|
});
|
|
renderPage();
|
|
await screen.findByText(copy('emailInboxProviderStatusUnavailable'));
|
|
await screen.findByRole('button', { name: /resume owned saved draft/i });
|
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', expect.anything()));
|
|
const reads = mockedApi.get.mock.calls.length;
|
|
await changeLanguage('nb');
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
|
expect(screen.getByText(new Date('2026-02-13T11:30:00Z').toLocaleString('nb-NO'), { exact: false })).toBeInTheDocument();
|
|
const button = retryNotice('emailInboxProviderStatusUnavailable').getByRole('button', { name: copy('retry') });
|
|
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(reads + 1);
|
|
expect(mockedApi.get).toHaveBeenLastCalledWith('/email/providers');
|
|
await act(async () => { providerRetry.resolve({ data: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false }] }); await providerRetry.promise; });
|
|
expect(await screen.findByText(/Gmail: owner@gmail\.test.*Kun lesing/)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned saved draft' }) })).toBeInTheDocument();
|
|
expect(screen.getByText('Interview invite')).toBeInTheDocument();
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
expect(mockedApi.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('confirmed unlink owns pending actions and a failed successful-write refresh recovers with GET only', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
const unlink = deferred<any>();
|
|
let refreshFails = false;
|
|
const items = [messageItem({ subject: 'Unlink A' }), messageItem({ id: 2, jobApplicationId: 43, subject: 'Unlink B', externalThreadId: 'thread-2', externalMessageId: 'message-2' })];
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/correspondence/page'
|
|
? refreshFails ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: pageData(items) } as any)
|
|
: original!(url, config));
|
|
mockedApi.post.mockReturnValue(unlink.promise);
|
|
renderPage();
|
|
fireEvent.click((await screen.findAllByRole('button', { name: copy('emailInboxUnlinkThread') }))[0]);
|
|
const dialog = await screen.findByRole('dialog');
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
fireEvent.click(within(dialog).getByRole('button', { name: copy('emailInboxUnlinkThread') }));
|
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
|
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/unlink-thread', { jobApplicationId: 42, threadId: 'thread-1', note: 'Unlinked from Job email hub', nextDecision: 'review' });
|
|
await waitFor(() => expect(messageRow('Unlink B').getByRole('button', { name: copy('emailInboxUnlinkThread') })).toBeInTheDocument());
|
|
expect(messageRow('Unlink B').getByRole('button', { name: copy('emailInboxUnlinkThread') })).toBeDisabled();
|
|
fireEvent.click(messageRow('Unlink B').getByRole('button', { name: copy('emailInboxUnlinkThread') }));
|
|
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
|
refreshFails = true;
|
|
await act(async () => { unlink.resolve({ data: { threadId: 'thread-1', jobApplicationId: 42, removedMessages: 1, decision: 'review' } }); await unlink.promise; });
|
|
await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/correspondence/page')).toHaveLength(2));
|
|
expect(screen.getByText('Unlink B')).toBeInTheDocument();
|
|
refreshFails = false;
|
|
fireEvent.click(retryNotice('emailInboxLoadFailed').getByRole('button', { name: copy('retry') }));
|
|
await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/correspondence/page')).toHaveLength(3));
|
|
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
expect(mockedApi.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('literal all search preserves source and hash and hides another query snapshot while retaining same-query refresh data', async () => {
|
|
const original = mockedApi.get.getMockImplementation();
|
|
const refreshRecruiter = deferred<any>();
|
|
const allQuery = deferred<any>();
|
|
let recruiterReads = 0;
|
|
let allReads = 0;
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === '/correspondence/page') {
|
|
if (config.params.q === 'Recruiter') return ++recruiterReads === 1
|
|
? Promise.resolve({ data: pageData([messageItem({ subject: 'Old Recruiter snapshot' })], 1, 51, 2) } as any)
|
|
: refreshRecruiter.promise;
|
|
if (config.params.q === 'all') {
|
|
if (++allReads === 1) return allQuery.promise;
|
|
return Promise.resolve({ data: pageData([messageItem({ subject: config.params.page === 2 ? 'All query page two' : 'Loaded literal all snapshot' })], config.params.page, 75, 2) } as any);
|
|
}
|
|
return Promise.resolve({ data: pageData([messageItem({ subject: 'Unexpected missing search' })]) } as any);
|
|
}
|
|
return original!(url, config);
|
|
});
|
|
const location = () => new URL(screen.getByTestId('inbox-location').textContent!, 'http://localhost');
|
|
renderPage('/correspondence?q=Recruiter&source=workflow#inbox');
|
|
await screen.findByText('Old Recruiter snapshot');
|
|
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('Recruiter');
|
|
fireEvent.click(screen.getByRole('button', { name: copy('refresh') }));
|
|
expect(recruiterReads).toBe(2);
|
|
expect(screen.getByText('Old Recruiter snapshot')).toBeInTheDocument();
|
|
expect(screen.getByText(copy('emailInboxItems', { count: 51 }))).toBeInTheDocument();
|
|
await act(async () => { refreshRecruiter.resolve({ data: pageData([messageItem({ subject: 'Old Recruiter snapshot' })], 1, 51, 2) }); await refreshRecruiter.promise; });
|
|
|
|
fireEvent.change(screen.getByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'all' } });
|
|
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('all');
|
|
expect(location().searchParams.get('q')).toBe('all');
|
|
expect(location().searchParams.get('source')).toBe('workflow');
|
|
expect(location().hash).toBe('#inbox');
|
|
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: 'all', direction: undefined, linkState: undefined, page: 1, pageSize: 50 } });
|
|
expect(screen.queryByText('Old Recruiter snapshot')).not.toBeInTheDocument();
|
|
expect(screen.queryByText(copy('emailInboxItems', { count: 51 }))).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: copy('emailInboxViewMessage') })).not.toBeInTheDocument();
|
|
await act(async () => { allQuery.reject({ response: { status: 503 } }); await allQuery.promise.catch(() => undefined); });
|
|
expect(screen.queryByText('Old Recruiter snapshot')).not.toBeInTheDocument();
|
|
expect(screen.queryByText(copy('emailInboxItems', { count: 51 }))).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: copy('emailInboxUnlinkThread') })).not.toBeInTheDocument();
|
|
fireEvent.click(retryNotice('emailInboxLoadFailed').getByRole('button', { name: copy('retry') }));
|
|
await screen.findByText('Loaded literal all snapshot');
|
|
expect(screen.getByText(copy('emailInboxItems', { count: 75 }))).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSuggestions') }));
|
|
expect(location().searchParams.get('q')).toBe('all');
|
|
expect(location().searchParams.get('source')).toBe('workflow');
|
|
expect(location().hash).toBe('#inbox');
|
|
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxLinkedMessages') }));
|
|
await screen.findByText('Loaded literal all snapshot');
|
|
expect(location().hash).toBe('#inbox');
|
|
fireEvent.click(screen.getByRole('button', { name: /go to page 2/i }));
|
|
await screen.findByText('All query page two');
|
|
expect(location().searchParams.get('page')).toBe('2');
|
|
expect(location().searchParams.get('q')).toBe('all');
|
|
expect(location().searchParams.get('source')).toBe('workflow');
|
|
expect(location().hash).toBe('#inbox');
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
expect(mockedApi.patch).not.toHaveBeenCalled();
|
|
expect(mockedApi.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('typing an inbox search keeps one natural Back step to the prior screen and preserves filter context', async () => {
|
|
render(
|
|
<ToastProvider><ConfirmProvider><I18nProvider>
|
|
<MemoryRouter initialEntries={['/previous-screen', '/correspondence?source=workflow&direction=inbound&linkState=linked#inbox']} initialIndex={1}>
|
|
<LocationProbe />
|
|
<HistoryBackControl />
|
|
<Routes>
|
|
<Route path="/previous-screen" element={<h2>Previous screen</h2>} />
|
|
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</I18nProvider></ConfirmProvider></ToastProvider>,
|
|
);
|
|
const search = await screen.findByRole('textbox', { name: copy('correspondenceSearch') });
|
|
await screen.findByText('Interview invite');
|
|
for (const text of ['A', 'Ac', 'Acm', 'Acme']) fireEvent.change(search, { target: { value: text } });
|
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', { params: { q: 'Acme', direction: 'inbound', linkState: 'linked', page: 1, pageSize: 50 } }));
|
|
const current = new URL(screen.getByTestId('inbox-location').textContent!, 'http://localhost');
|
|
expect(current.searchParams.get('q')).toBe('Acme');
|
|
expect(current.searchParams.get('source')).toBe('workflow');
|
|
expect(current.searchParams.get('direction')).toBe('inbound');
|
|
expect(current.searchParams.get('linkState')).toBe('linked');
|
|
expect(current.searchParams.get('page')).toBeNull();
|
|
expect(current.hash).toBe('#inbox');
|
|
fireEvent.click(screen.getByRole('button', { name: 'History Back' }));
|
|
expect(await screen.findByRole('heading', { name: 'Previous screen' })).toBeInTheDocument();
|
|
expect(screen.getByTestId('inbox-location')).toHaveTextContent('/previous-screen');
|
|
expect(screen.queryByRole('textbox', { name: copy('correspondenceSearch') })).not.toBeInTheDocument();
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
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();
|
|
});
|
|
});
|