feat(email): add confirmed reply composer
Keep provider, recipient, subject, thread, and body editable until an app-owned confirmation is accepted. Reuse one request UUID per attempt and block automatic retry when delivery is uncertain.
This commit is contained in:
@@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } 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';
|
||||
@@ -24,17 +25,20 @@ const mockedApi = api as jest.Mocked<typeof api>;
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<CorrespondenceInboxPage />
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
<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 },
|
||||
@@ -135,6 +139,80 @@ describe('CorrespondenceInboxPage', () => {
|
||||
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('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();
|
||||
|
||||
@@ -147,14 +225,16 @@ describe('CorrespondenceInboxPage', () => {
|
||||
test('legacy Gmail review route redirects into the consolidated hub filter', async () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<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>
|
||||
<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>,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user