287 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|