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 { ToastProvider } from './toast'; import { ConfirmProvider } from './confirm'; import { I18nProvider } from './i18n/I18nProvider'; import CorrespondenceInboxPage from './views/CorrespondenceInboxPage'; import { api } from './api'; 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 renderPage() { return render( , ); } describe('CorrespondenceInboxPage', () => { beforeEach(() => { window.localStorage.clear(); 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: [ { provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false }, { provider: 'microsoft', displayName: 'Outlook', connected: false, address: null, canRead: false, canSend: false }, ] } as any); if (url === '/correspondence/page') return Promise.resolve({ data: { items: [ { id: 1, jobApplicationId: 42, companyName: 'Acme Systems', jobTitle: 'Backend Engineer', from: 'Company', direction: 'inbound', subject: 'Interview invite', channel: 'Email', date: new Date().toISOString(), contentPreview: 'We would like to schedule an interview.', externalThreadId: 'thread-1', externalMessageId: 'message-1', provider: 'gmail', externalFrom: 'Maria Recruiter ', externalTo: 'user@example.test', labelCount: 2, attachmentCount: 1, }, ], page: 1, pageSize: 50, total: 1, totalPages: 1 } } as any); if (url === '/email/drafts') return Promise.resolve({ data: [] } as any); if (url === '/jobapplications/choices') return Promise.resolve({ data: [] } as any); if (url === '/email/message') return Promise.resolve({ data: { id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter ', to: 'user@example.test', date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }], } } as any); if (url === '/gmail/review-candidates') return Promise.resolve({ data: { queries: [], candidateThreadCount: 0, autoLinkThreadCount: 0, reviewThreadCount: 0, unmatchedThreadCount: 0, threads: [], } } 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 correspondence inbox items and reloads with filters', async () => { renderPage(); expect(await screen.findByRole('heading', { name: /job email/i })).toBeInTheDocument(); expect(await screen.findByText(/1 items/i)).toBeInTheDocument(); expect(await screen.findByText(/acme systems/i)).toBeInTheDocument(); expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument(); expect(screen.getByText(/2 labels/i)).toBeInTheDocument(); expect(screen.getByText(/1 attachments/i)).toBeInTheDocument(); expect(screen.getByText(/Gmail: owner@gmail\.test · Read only; reconnect to enable send/i)).toBeInTheDocument(); expect(screen.getByText(/^Outlook: Not connected$/i)).toBeInTheDocument(); expect(screen.queryByText(/Outlook: Not connected.*Read only/i)).not.toBeInTheDocument(); fireEvent.change(screen.getByLabelText(/search/i), { target: { value: 'Maria' } }); fireEvent.mouseDown(screen.getAllByRole('combobox')[0]); fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]); await waitFor(() => { expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', expect.objectContaining({ params: expect.objectContaining({ q: 'Maria', direction: 'inbound', page: 1, pageSize: 50, }), })); }); }); test('keeps saved correspondence visible when provider status is unavailable', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/email/providers') return Promise.reject(new Error('provider status unavailable')); return original!(url, config); }); renderPage(); expect(await screen.findByText(/email provider status is temporarily unavailable/i)).toBeInTheDocument(); expect(await screen.findByText(/acme systems/i)).toBeInTheDocument(); expect(screen.getByText(/saved jobtracker correspondence remains available/i)).toBeInTheDocument(); }); test('opens provider-backed plain-text message detail in the hub', async () => { renderPage(); fireEvent.click(await screen.findByRole('button', { name: /view message/i })); expect(await screen.findByText(/please choose an interview time/i)).toBeInTheDocument(); expect(screen.getByText('INBOX')).toBeInTheDocument(); expect(screen.getByText('agenda.pdf')).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledWith('/email/message', { params: { provider: 'gmail', messageId: 'message-1' }, }); }); test('navigates beyond the first correspondence page', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/correspondence/page') { const page = config?.params?.page ?? 1; return Promise.resolve({ data: { items: [{ id: page, jobApplicationId: 42, companyName: page === 2 ? 'Second page company' : 'First page company', jobTitle: 'Engineer', from: 'Recruiter', direction: 'inbound', subject: `Page ${page}`, channel: 'Email', date: new Date().toISOString(), contentPreview: `Page ${page} message`, labelCount: 0, attachmentCount: 0, }], page, pageSize: 50, total: 51, totalPages: 2, } } as any); } return original!(url, config); }); renderPage(); fireEvent.click(await screen.findByRole('button', { name: /go to page 2/i })); expect(await screen.findByText(/second page company/i)).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.objectContaining({ params: expect.objectContaining({ page: 2, pageSize: 50 }), })); }); test('unlinks a Gmail thread only after confirmation and returns it to review', async () => { mockedApi.post.mockResolvedValue({ data: { threadId: 'thread-1', jobApplicationId: 42, removedMessages: 1, decision: 'review' } } as any); renderPage(); fireEvent.click(await screen.findByRole('button', { name: /^unlink thread$/i })); expect(mockedApi.post).not.toHaveBeenCalled(); const dialog = await screen.findByRole('dialog'); expect(within(dialog).getByText(/provider copy is not deleted/i)).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole('button', { name: /^unlink thread$/i })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/gmail/unlink-thread', { jobApplicationId: 42, threadId: 'thread-1', note: 'Unlinked from Job email hub', nextDecision: 'review', })); await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.anything())); expect(await screen.findByText(/returned to recruitment review/i)).toBeInTheDocument(); }); test('falls back to the saved copy when provider detail is unavailable', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/email/message') return Promise.reject(new Error('reauthorization required')); if (url === '/correspondence/message/1') return Promise.resolve({ data: { id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter', to: 'user@example.test', date: new Date().toISOString(), snippet: 'Saved', bodyText: 'Saved interview message.', labels: [], attachments: [], } } as any); return original!(url, config); }); renderPage(); fireEvent.click(await screen.findByRole('button', { name: /view message/i })); expect(await screen.findByText(/showing the saved jobtracker copy/i)).toBeInTheDocument(); expect(await screen.findByText(/saved interview message/i)).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/message/1'); }); test('keeps replies editable and requires final send confirmation', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/email/providers') return Promise.resolve({ data: [ { provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }, ] } as any); return original!(url, config); }); mockedApi.post.mockResolvedValue({ data: { attemptId: 'attempt-1', status: 'sent', duplicate: false, externalMessageId: 'sent-1', externalThreadId: 'thread-1', failureCategory: null, } } as any); renderPage(); fireEvent.click(await screen.findByRole('button', { name: /view message/i })); fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i })); expect(screen.getByRole('textbox', { name: /^provider$/i })).toHaveValue('Gmail · owner@gmail.test'); expect(screen.getByLabelText(/recipient/i)).toHaveValue('Maria Recruiter '); 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.click(screen.getByRole('button', { name: /review and send/i })); expect(await screen.findByRole('heading', { name: /confirm email send/i })).toBeInTheDocument(); expect(screen.getByText(/owner@gmail\.test.*maria@acme\.test/i)).toBeInTheDocument(); 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(mockedApi.post).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: /review and send/i })); fireEvent.click(screen.getByRole('button', { name: /^send email$/i })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/send', { jobApplicationId: 42, provider: 'gmail', clientRequestId: '00000000-0000-4000-8000-000000000123', to: 'Maria Recruiter ', subject: 'Re: Interview invite', bodyText: 'Thank you. Tuesday works well.', threadId: 'thread-1', confirmed: true, })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(await screen.findByText(/sent successfully/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled(); }); test('saves an incomplete reply with its server delivery identity', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/email/providers') return Promise.resolve({ data: [ { provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }, ] } as any); return original!(url, config); }); mockedApi.post.mockImplementation((url: string) => { if (url === '/email/drafts') return Promise.resolve({ data: { id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'Maria Recruiter ', subject: 'Re: Interview invite', bodyText: '', threadId: 'thread-1', clientRequestId: 'server-request-id', revision: 1, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(), } } as any); return Promise.reject(new Error(`Unexpected POST ${url}`)); }); renderPage(); fireEvent.click(await screen.findByRole('button', { name: /view message/i })); fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i })); fireEvent.click(screen.getByRole('button', { name: /^save draft$/i })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', { jobApplicationId: 42, provider: 'gmail', to: 'Maria Recruiter ', subject: 'Re: Interview invite', bodyText: '', threadId: 'thread-1', })); expect(await screen.findByText(/email draft saved/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^save changes$/i })).toBeInTheDocument(); }); test('resumes a saved draft and surfaces revision conflicts without overwriting it', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/email/drafts') return Promise.resolve({ data: [{ id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'maria@acme.test', subject: 'Saved subject', bodyText: 'Saved private draft.', threadId: 'thread-1', clientRequestId: 'server-request-id', revision: 4, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(), }] } as any); if (url === '/email/providers') return Promise.resolve({ data: [ { provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }, ] } as any); return original!(url, config); }); mockedApi.put.mockRejectedValue({ response: { status: 409 } }); 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.' } }); fireEvent.click(screen.getByRole('button', { name: /^save changes$/i })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/email/drafts/draft-1', { revision: 4, to: 'maria@acme.test', subject: 'Saved subject', bodyText: 'Conflicting local edit.', })); expect(await screen.findByText(/changed in another tab/i)).toBeInTheDocument(); expect(screen.getByLabelText(/message/i)).toHaveValue('Conflicting local edit.'); }); test('persists a new delivery identity only after a definitive failed attempt', async () => { const original = mockedApi.get.getMockImplementation(); const stored = { id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'maria@acme.test', subject: 'Saved subject', bodyText: 'Saved private draft.', threadId: 'thread-1', clientRequestId: 'failed-request-id', revision: 4, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(), }; mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/email/drafts') return Promise.resolve({ data: [stored] } as any); if (url === '/email/providers') return Promise.resolve({ data: [ { provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }, ] } as any); return original!(url, config); }); mockedApi.post.mockImplementation((url: string) => { if (url === '/email/send') return Promise.reject({ response: { status: 502, data: { attemptId: 'attempt-1', status: 'failed', duplicate: false, failureCategory: 'provider_rejected', } } }); if (url === '/email/drafts/draft-1/new-attempt') return Promise.resolve({ data: { ...stored, revision: 5, clientRequestId: 'new-server-request-id', updatedAtUtc: new Date().toISOString(), } } as any); return Promise.reject(new Error(`Unexpected POST ${url}`)); }); renderPage(); fireEvent.click(await screen.findByRole('button', { name: /resume saved subject/i })); fireEvent.click(screen.getByRole('button', { name: /review and send/i })); fireEvent.click(await screen.findByRole('button', { name: /^send email$/i })); fireEvent.click(await screen.findByRole('button', { name: /prepare new attempt/i })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts/draft-1/new-attempt', { revision: 4 })); await waitFor(() => expect(screen.queryByText(/provider confirmed that this attempt did not complete/i)).not.toBeInTheDocument()); expect(screen.getByRole('button', { name: /review and send/i })).toBeEnabled(); }); test('starts a new-message draft only from an owned job and send-capable provider choice', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/email/providers') return Promise.resolve({ data: [ { provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }, { provider: 'microsoft', displayName: 'Outlook', connected: true, address: 'owner@outlook.test', canRead: true, canSend: false }, ] } as any); if (url === '/jobapplications/choices') return Promise.resolve({ data: [ { id: 42, jobTitle: 'Backend Engineer', companyName: 'Acme Systems' }, ] } as any); return original!(url, config); }); mockedApi.post.mockImplementation((url: string) => { if (url === '/email/drafts') return Promise.resolve({ data: { id: 'draft-new', jobApplicationId: 42, provider: 'gmail', to: 'recruiter@acme.test', subject: 'Application question', bodyText: 'Hello from a synthetic draft.', threadId: null, clientRequestId: 'server-request-id', revision: 1, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(), } } as any); return Promise.reject(new Error(`Unexpected POST ${url}`)); }); renderPage(); const compose = await screen.findByRole('button', { name: /compose new email/i }); await waitFor(() => expect(compose).toBeEnabled()); fireEvent.click(compose); expect(await screen.findByRole('heading', { name: /compose new email/i })).toBeInTheDocument(); const providerSelect = screen.getByRole('combobox', { name: /sending provider/i }); expect(providerSelect).toHaveTextContent(/Gmail/i); fireEvent.mouseDown(providerSelect); const gmailOption = await screen.findByRole('option', { name: /Gmail/i }); expect(screen.queryByRole('option', { name: /Outlook/i })).not.toBeInTheDocument(); fireEvent.click(gmailOption); fireEvent.click(screen.getByRole('button', { name: /start draft/i })); expect(screen.getByRole('textbox', { name: /^provider$/i })).toHaveValue('Gmail · owner@gmail.test'); 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.click(screen.getByRole('button', { name: /^save draft$/i })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', { jobApplicationId: 42, provider: 'gmail', to: 'recruiter@acme.test', subject: 'Application question', bodyText: 'Hello from a synthetic draft.', threadId: null, })); }); test('searches the complete server-side job choice set while composing', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/email/providers') return Promise.resolve({ data: [ { provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }, ] } as any); if (url === '/jobapplications/choices') { return Promise.resolve({ data: config?.params?.q === 'Historic' ? [{ id: 7, jobTitle: 'Historic target role', companyName: 'Archive Co' }] : [{ id: 42, jobTitle: 'Recent role', companyName: 'Acme Systems' }] } as any); } return original!(url, config); }); renderPage(); const compose = await screen.findByRole('button', { name: /compose new email/i }); await waitFor(() => expect(compose).toBeEnabled()); fireEvent.click(compose); fireEvent.change(screen.getByRole('combobox', { name: /^job$/i }), { target: { value: 'Historic' } }); await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', { params: { q: 'Historic', limit: 20 }, })); fireEvent.click(await screen.findByRole('option', { name: /Archive Co.*Historic target role/i })); fireEvent.click(screen.getByRole('button', { name: /start draft/i })); expect(screen.getByText(/Archive Co.*Historic target role/i)).toBeInTheDocument(); }); test('does not offer retry when delivery is uncertain', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { if (url === '/email/providers') return Promise.resolve({ data: [ { provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }, ] } as any); return original!(url, config); }); mockedApi.post.mockRejectedValue({ response: { status: 409, data: { attemptId: 'attempt-1', status: 'uncertain', duplicate: false, failureCategory: 'transport_interrupted', } } }); 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.click(screen.getByRole('button', { name: /review and send/i })); fireEvent.click(await screen.findByRole('button', { name: /^send email$/i })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(await screen.findByText(/delivery status is uncertain/i)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /prepare new attempt/i })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled(); expect(mockedApi.post).toHaveBeenCalledTimes(1); }); test('uses one hub for linked messages and recruitment suggestions', async () => { renderPage(); fireEvent.click(await screen.findByRole('button', { name: /review suggestions/i })); expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates'); expect(screen.getByRole('button', { name: /linked messages/i })).toBeInTheDocument(); }); test('localizes inbox controls while preserving correspondence content', async () => { window.localStorage.setItem('uiLanguage', 'nb'); renderPage(); expect(await screen.findByRole('heading', { name: 'Jobb-e-post' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Tilknyttede meldinger' })).toBeInTheDocument(); expect(screen.getByLabelText('Retning')).toBeInTheDocument(); expect(screen.getByText('Interview invite')).toBeInTheDocument(); expect(screen.getByText(/Gmail: owner@gmail\.test · Kun lesing/)).toBeInTheDocument(); }); test('legacy Gmail review route redirects into the consolidated hub filter', async () => { render( } /> } /> , ); expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument(); expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/page', expect.anything()); }); });