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; function copy(key: string, params: Record = {}, language: UiLanguage = document.documentElement.lang === 'nb-NO' ? 'nb' : 'en') { return ((translations[language] as Record)[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`)); } function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((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 = {}) { 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( , ); } 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(); const suggestedRetry = deferred(); 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(); 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(); }); });