29de2632f6
Rotate persisted delivery identity only after the matching owner attempt is definitively failed. Keep stale, foreign, pending, sent, and uncertain drafts non-retryable.
379 lines
19 KiB
TypeScript
379 lines
19 KiB
TypeScript
import React from 'react';
|
|
import '@testing-library/jest-dom';
|
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { MemoryRouter, Navigate, Route, Routes } 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';
|
|
|
|
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 renderPage() {
|
|
return render(
|
|
<ToastProvider>
|
|
<ConfirmProvider>
|
|
<I18nProvider>
|
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
|
<CorrespondenceInboxPage />
|
|
</MemoryRouter>
|
|
</I18nProvider>
|
|
</ConfirmProvider>
|
|
</ToastProvider>,
|
|
);
|
|
}
|
|
|
|
describe('CorrespondenceInboxPage', () => {
|
|
beforeEach(() => {
|
|
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') return Promise.resolve({ data: [
|
|
{
|
|
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,
|
|
},
|
|
] } as any);
|
|
if (url === '/email/drafts') 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', expect.objectContaining({
|
|
params: expect.objectContaining({
|
|
q: 'Maria',
|
|
direction: 'inbound',
|
|
}),
|
|
}));
|
|
});
|
|
});
|
|
|
|
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('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', 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.getByLabelText(/message/i), { 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.getByLabelText(/message/i)).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.getByLabelText(/message/i)).toHaveValue('Saved private draft.');
|
|
fireEvent.change(screen.getByLabelText(/message/i), { 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.getByLabelText(/message/i)).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('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.getByLabelText(/message/i), { 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('legacy Gmail review route redirects into the consolidated hub filter', async () => {
|
|
render(
|
|
<ToastProvider>
|
|
<ConfirmProvider>
|
|
<I18nProvider>
|
|
<MemoryRouter initialEntries={['/correspondence/review']} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
|
<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', expect.anything());
|
|
});
|
|
});
|