fix(ui): preserve inbox context and review recovery
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
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 { 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: {
|
||||
@@ -22,12 +23,56 @@ jest.mock('./api', () => ({
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage() {
|
||||
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>;
|
||||
}
|
||||
|
||||
function renderPage(initialPath = '/') {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<I18nProvider>
|
||||
<MemoryRouter>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<LocationProbe />
|
||||
<CorrespondenceInboxPage />
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
@@ -39,6 +84,13 @@ function renderPage() {
|
||||
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: [
|
||||
@@ -238,7 +290,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
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.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();
|
||||
@@ -246,7 +298,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
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(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 }));
|
||||
@@ -315,8 +367,8 @@ describe('CorrespondenceInboxPage', () => {
|
||||
|
||||
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.' } });
|
||||
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', {
|
||||
@@ -326,7 +378,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
bodyText: 'Conflicting local edit.',
|
||||
}));
|
||||
expect(await screen.findByText(/changed in another tab/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/message/i)).toHaveValue('Conflicting local edit.');
|
||||
expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Conflicting local edit.');
|
||||
});
|
||||
|
||||
test('persists a new delivery identity only after a definitive failed attempt', async () => {
|
||||
@@ -400,7 +452,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
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.getByLabelText(/message/i), { target: { value: 'Hello from a synthetic draft.' } });
|
||||
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', {
|
||||
@@ -456,7 +508,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
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.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 }));
|
||||
|
||||
@@ -506,4 +558,280 @@ describe('CorrespondenceInboxPage', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user