fix(ui): preserve inbox context and review recovery
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import GmailReviewPage from './views/GmailReviewPage';
|
||||
import { api } from './api';
|
||||
import { translations, type UiLanguage } from './i18n/translations';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
@@ -21,6 +22,39 @@ jest.mock('./api', () => ({
|
||||
|
||||
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 reviewThread(overrides: Record<string, unknown> = {}) {
|
||||
return { threadId: 'thread-1', subject: 'Owned review thread', latestDate: '2026-02-13T11:30:00Z', messageCount: 2, routing: 'review', hasImportedMessages: false, decisionNote: 'Recorded review note', matchedQueries: ['"Acme" "Backend Developer"'], jobCandidates: [{ jobApplicationId: 42, jobTitle: 'Backend Developer', companyName: 'Acme', score: 24, confidence: 'medium', reasons: [{ label: 'company', value: 'Acme', points: 18 }] }], messages: [], ...overrides };
|
||||
}
|
||||
|
||||
function reviewQueue(threads = [reviewThread()]) {
|
||||
return { queries: [], candidateThreadCount: threads.length, autoLinkThreadCount: 0, reviewThreadCount: threads.length, unmatchedThreadCount: 0, threads };
|
||||
}
|
||||
|
||||
function threadRow(subject: string) {
|
||||
return within(screen.getByText(subject, { exact: true }).closest('.MuiPaper-root') as HTMLElement);
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
@@ -36,6 +70,13 @@ function renderPage() {
|
||||
describe('GmailReviewPage', () => {
|
||||
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();
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/gmail/review-candidates') {
|
||||
return Promise.resolve({
|
||||
@@ -112,4 +153,134 @@ describe('GmailReviewPage', () => {
|
||||
expect(screen.getByRole('button', { name: 'Knytt til beste jobbtreff' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Kontrollnotater')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed paired review reads have durable current-language recovery and one explicit GET-only Retry cycle', async () => {
|
||||
const queueRetry = deferred<any>();
|
||||
const suggestedRetry = deferred<any>();
|
||||
let queueReads = 0;
|
||||
let suggestedReads = 0;
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/gmail/review-candidates') return ++queueReads === 1 ? Promise.reject({ response: { status: 503 } }) : queueRetry.promise;
|
||||
if (url === '/gmail/suggested-jobs') return ++suggestedReads === 1 ? Promise.resolve({ data: { count: 0, items: [] } } as any) : suggestedRetry.promise;
|
||||
return Promise.reject(new Error(`Unexpected GET ${url}`));
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText(copy('gmailReviewLoadFailed'));
|
||||
expect(screen.queryByText(copy('gmailReviewNoCandidates'))).not.toBeInTheDocument();
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
await changeLanguage('nb');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
const button = retryNotice('gmailReviewLoadFailed').getByRole('button', { name: copy('retry') });
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
||||
expect(queueReads).toBe(2);
|
||||
expect(suggestedReads).toBe(2);
|
||||
await act(async () => { queueRetry.resolve({ data: reviewQueue() }); await queueRetry.promise; });
|
||||
expect(screen.getByRole('progressbar', { name: copy('loading') })).toBeInTheDocument();
|
||||
await act(async () => { suggestedRetry.resolve({ data: { count: 0, items: [] } }); await suggestedRetry.promise; });
|
||||
expect(await screen.findByText('Owned review thread')).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('review notes round-trip without losing edits to filtering, refresh, failed explicit saving or locale changes', async () => {
|
||||
let serverNote = 'Recorded review note';
|
||||
let writeFails = true;
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === '/gmail/review-candidates'
|
||||
? reviewQueue([reviewThread({ decisionNote: serverNote })]) : { count: 0, items: [] } } as any));
|
||||
mockedApi.post.mockImplementation((url: string, body?: any) => {
|
||||
expect(url).toBe('/gmail/review-decision');
|
||||
if (writeFails) return Promise.reject({ response: { status: 503 } });
|
||||
serverNote = body.note;
|
||||
return Promise.resolve({ data: { threadId: body.threadId, decision: body.decision, jobApplicationId: body.jobApplicationId, note: body.note } } as any);
|
||||
});
|
||||
const mounted = renderPage();
|
||||
const note = await screen.findByRole('textbox', { name: copy('gmailReviewNotes') });
|
||||
expect(note).toHaveValue('Recorded review note');
|
||||
fireEvent.change(note, { target: { value: ' Unsaved owned review note ' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewRouteRejected') }));
|
||||
expect(screen.queryByRole('textbox', { name: copy('gmailReviewNotes') })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewRouteAll') }));
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note ');
|
||||
serverNote = 'Changed saved server note';
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('correspondenceRefresh') }));
|
||||
await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/gmail/review-candidates')).toHaveLength(2));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: copy('correspondenceRefresh') })).toBeEnabled());
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note ');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') }));
|
||||
await screen.findByText(copy('gmailReviewSaveFailed'));
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note ');
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
await changeLanguage('nb');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note ');
|
||||
writeFails = false;
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2));
|
||||
expect(mockedApi.post).toHaveBeenNthCalledWith(1, '/gmail/review-decision', { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Unsaved owned review note' });
|
||||
expect(mockedApi.post).toHaveBeenNthCalledWith(2, '/gmail/review-decision', { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Unsaved owned review note' });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') })).toBeEnabled());
|
||||
mounted.unmount();
|
||||
renderPage();
|
||||
expect(await screen.findByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue('Unsaved owned review note');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('decision, manual sync and suggested creation each own their captured request against duplicate and competing actions', async () => {
|
||||
const second = reviewThread({ threadId: 'thread-2', subject: 'Other owned thread' });
|
||||
const suggestion = { threadId: 'thread-1', companyName: 'Acme', suggestedJobTitle: 'Backend Developer', subject: 'Suggestion subject', recruiterName: 'Maria', recruiterEmail: 'maria@acme.test', preview: 'Recorded preview' };
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === '/gmail/review-candidates' ? reviewQueue([reviewThread(), second]) : { count: 1, items: [suggestion] } } as any));
|
||||
renderPage();
|
||||
await screen.findByText('Owned review thread');
|
||||
const operations = [
|
||||
{ key: 'decision', url: '/gmail/review-decision', body: { threadId: 'thread-1', decision: 'linked', jobApplicationId: 42, note: 'Captured note' } },
|
||||
{ key: 'sync', url: '/gmail/manual-sync', body: { lookbackDays: 365, maxResultsPerQuery: 8, autoImportHighConfidence: true, includeSpamTrash: false } },
|
||||
{ key: 'create', url: '/gmail/create-suggested-job', body: { threadId: 'thread-1', companyName: 'Acme', jobTitle: 'Backend Developer', recruiterName: 'Maria', recruiterEmail: 'maria@acme.test', notes: 'Captured note', status: 'Applied' } },
|
||||
];
|
||||
for (const operation of operations) {
|
||||
mockedApi.post.mockReset();
|
||||
const held = deferred<any>();
|
||||
mockedApi.post.mockReturnValue(held.promise);
|
||||
fireEvent.change(threadRow('Owned review thread').getByRole('textbox', { name: copy('gmailReviewNotes') }), { target: { value: ' Captured note ' } });
|
||||
const primary = operation.key === 'decision' ? threadRow('Owned review thread').getByRole('button', { name: copy('gmailReviewLinkTopJob') })
|
||||
: operation.key === 'sync' ? screen.getByRole('button', { name: copy('gmailReviewRunSync') })
|
||||
: threadRow('Owned review thread').getByRole('button', { name: copy('createJob') });
|
||||
const otherDecision = threadRow('Other owned thread').getByRole('button', { name: copy('gmailReviewKeepInReview') });
|
||||
const sync = screen.getByRole('button', { name: copy('gmailReviewRunSync') });
|
||||
const create = threadRow('Owned review thread').getByRole('button', { name: copy('createJob') });
|
||||
act(() => { fireEvent.click(primary); fireEvent.click(primary); fireEvent.click(otherDecision); fireEvent.click(sync); fireEvent.click(create); });
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith(operation.url, operation.body);
|
||||
expect(otherDecision).toBeDisabled();
|
||||
expect(sync).toBeDisabled();
|
||||
expect(create).toBeDisabled();
|
||||
await act(async () => { held.reject({ response: { status: 503 } }); await held.promise.catch(() => undefined); });
|
||||
await waitFor(() => expect(threadRow('Other owned thread').getByRole('button', { name: copy('gmailReviewKeepInReview') })).toBeEnabled());
|
||||
expect(threadRow('Owned review thread').getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Captured note ');
|
||||
}
|
||||
expect(mockedApi.post.mock.calls.some(([url]) => url === '/email/send')).toBe(false);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('an already-saved decision with failed paired refresh recovers reads without duplicating the mutation', async () => {
|
||||
let readFails = false;
|
||||
mockedApi.get.mockImplementation((url: string) => readFails ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: url === '/gmail/review-candidates' ? reviewQueue() : { count: 0, items: [] } } as any));
|
||||
mockedApi.post.mockImplementation(() => { readFails = true; return Promise.resolve({ data: { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Edited review note' } } as any); });
|
||||
renderPage();
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: copy('gmailReviewNotes') }), { target: { value: 'Edited review note' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(4));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue('Edited review note');
|
||||
expect(screen.queryByText(copy('gmailReviewSaveFailed'))).not.toBeInTheDocument();
|
||||
readFails = false;
|
||||
fireEvent.click(retryNotice('gmailReviewLoadFailed').getByRole('button', { name: copy('retry') }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(6));
|
||||
expect(mockedApi.get.mock.calls.slice(-2)).toEqual([['/gmail/review-candidates'], ['/gmail/suggested-jobs']]);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/review-decision', { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Edited review note' });
|
||||
expect(screen.getByText('Owned review thread')).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user