Files
jobtrackingapp/job-tracker-ui/src/gmail-review-page.test.tsx
T

287 lines
16 KiB
TypeScript

import React from 'react';
import '@testing-library/jest-dom';
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: {
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 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>
<I18nProvider>
<MemoryRouter>
<GmailReviewPage />
</MemoryRouter>
</I18nProvider>
</ToastProvider>,
);
}
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({
data: {
queries: ['"Acme" "Backend Developer" newer_than:365d'],
candidateThreadCount: 2,
autoLinkThreadCount: 1,
reviewThreadCount: 1,
unmatchedThreadCount: 0,
threads: [
{
threadId: 'thread-1',
subject: 'Backend Developer interview',
latestDate: new Date().toISOString(),
messageCount: 2,
routing: 'review',
hasImportedMessages: false,
matchedQueries: ['"Acme" "Backend Developer" newer_than:365d'],
jobCandidates: [
{ jobApplicationId: 42, jobTitle: 'Backend Developer', companyName: 'Acme', score: 24, confidence: 'medium', reasons: [{ label: 'company', value: 'Acme', points: 18 }] },
],
messages: [],
},
],
},
} 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 Gmail review queue summary and candidate threads', async () => {
renderPage();
expect(await screen.findByText(/gmail review queue/i)).toBeInTheDocument();
expect(await screen.findByText(/2 candidate threads/i)).toBeInTheDocument();
expect(screen.getByText(/backend developer interview/i)).toBeInTheDocument();
expect(screen.getByText(/acme • backend developer \(24\)/i)).toBeInTheDocument();
await waitFor(() => {
expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates');
});
});
test('persists a review decision for the top job', async () => {
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /link top job/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/review-decision', {
threadId: 'thread-1',
decision: 'linked',
jobApplicationId: 42,
note: null,
});
});
});
test('renders the review workflow in Norwegian Bokmål', async () => {
window.localStorage.setItem('uiLanguage', 'nb');
renderPage();
expect(await screen.findByText('Kontrollkø for Gmail')).toBeInTheDocument();
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();
});
});