import React from 'react'; import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { api } from './api'; import { emptyStructuredCv } from './profileCv'; import { ToastProvider } from './toast'; import { I18nProvider } from './i18n/I18nProvider'; import CareerProfilePage from './views/CareerProfilePage'; import type { MappingBlock, MappingCategory, MappingTarget } from './views/career/CvImportMappingEditor'; jest.mock('./api', () => ({ getApiErrorMessage: jest.requireActual('./api').getApiErrorMessage, api: { get: jest.fn(), post: jest.fn(), put: jest.fn() }, })); jest.mock('./components/CropImageDialog', () => () => null); const mockedApi = api as jest.Mocked; const run = { id: 23, trigger: 'upload', status: 'pending_review', artifactFileName: 'career.pdf', startedAtUtc: '2026-10-26T10:00:00Z', parserVersion: 'docling-v1', normalizerVersion: 'document-v1', llmPromptVersion: 'none' }; const review = { runId: 23, status: 'pending_review', revision: 7, fileName: 'career.pdf', sourceUrl: '/api/profile-cv/runs/23/source', sourceText: 'Source name\nDocker', warnings: ['Check ambiguous dates.'], groups: [{ id: 'Contact', category: 'Contact', label: 'Contact', matchDescription: null, fields: [ { id: 'Contact.fullName', label: 'Full name', currentValue: 'Saved name', extractedValue: 'Source name', sourceSnippet: 'Source name', sourcePage: 1, method: 'docling', reviewState: 'suggested' }, { id: 'Contact.headline', label: 'Headline', currentValue: 'Saved headline', extractedValue: 'Engineer', sourceSnippet: null, sourcePage: null, method: 'deterministic', reviewState: 'suggested' }, ] }] }; let currentReview: typeof review & Partial<{ sourceBlocks: MappingBlock[]; mappingCategories: MappingCategory[]; targets: MappingTarget[] }> = review; let runs = [run]; function renderImport() { window.history.replaceState({}, '', '/career?section=import'); return render(); } beforeEach(() => { window.localStorage.clear(); currentReview = review; runs = [run]; URL.createObjectURL = jest.fn(() => 'blob:owned-source'); URL.revokeObjectURL = jest.fn(); mockedApi.get.mockImplementation((url: string) => { if (url === '/career/profile') return Promise.resolve({ data: { profile: emptyStructuredCv(), revision: 7, completeness: { percent: 0, missing: [], sections: [] } } } as any); if (url === '/auth/me') return Promise.resolve({ data: { provider: 'local', userName: 'Importer' } } as any); if (url === '/career/profile/versions') return Promise.resolve({ data: [] } as any); if (url === '/profile-cv/runs') return Promise.resolve({ data: runs } as any); if (url === '/profile-cv/runs/23/review') return Promise.resolve({ data: currentReview } as any); if (url === '/profile-cv/runs/23/diff') return Promise.resolve({ data: { revision: 7, diff: { categories: [], hasChanges: false, totalAdded: 0, totalUpdated: 0, totalLowConfidence: 0 } } } as any); if (url === '/profile-cv/runs/23/source') return Promise.resolve({ data: new Blob(['%PDF-1.4'], { type: 'application/pdf' }) } as any); return Promise.resolve({ data: {} } as any); }); mockedApi.post.mockResolvedValue({ data: { runId: 23, status: 'applied', revision: 8, structuredCv: emptyStructuredCv() } } as any); }); afterEach(() => jest.clearAllMocks()); test('inferred paragraph grouping displays an explicit verification warning without approving it', async () => { currentReview = { ...review, groups: review.groups.map(group => ({ ...group, fields: group.fields.map(field => ({ ...field, method: 'legacy-paragraph-continuation', reviewState: 'needs_review' })) })) }; renderImport(); expect((await screen.findAllByText('Suggested grouping: verify that this detail belongs to this record before approving it.')).length).toBe(2); expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test('a source-mapped missing record can be the only approved change and survives stale review without a stale target', async () => { currentReview = { ...review, groups: [], sourceBlocks: [{ id: 'b1', text: 'Engineer', kind: 'paragraph', page: 1 }, { id: 'b2', text: 'Example AS', kind: 'paragraph', page: 1 }], mappingCategories: [{ category: 'jobs', fields: [{ name: 'title', multiple: false }, { name: 'company', multiple: false }] }], targets: [{ category: 'jobs', index: 0, label: 'Saved job' }] }; renderImport(); fireEvent.click(await screen.findByRole('button', { name: 'Create missing record' })); const record = screen.getByRole('group', { name: 'Mapped record 1' }); fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'Engineer' } }); fireEvent.change(within(record).getByRole('textbox', { name: 'Value 2' }), { target: { value: 'Example AS' } }); fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b1' } }); fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 2' }), { target: { value: 'b2' } }); fireEvent.change(within(record).getByRole('combobox', { name: 'Destination' }), { target: { value: '0' } }); fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' })); currentReview = { ...currentReview, revision: 8 }; mockedApi.post.mockRejectedValueOnce({ response: { status: 409 } }); fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/23/apply-reviewed', expect.objectContaining({ expectedVersion: 7, decisions: [], mappings: [expect.objectContaining({ category: 'jobs', targetIndex: 0 })] }))); await waitFor(() => expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled()); expect(within(record).getByRole('textbox', { name: 'Value 1' })).toHaveValue('Engineer'); expect(within(record).getByRole('combobox', { name: 'Destination' })).toHaveValue(''); fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' })); fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' })); await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/profile-cv/runs/23/apply-reviewed', expect.objectContaining({ expectedVersion: 8, mappings: [expect.objectContaining({ category: 'jobs', targetIndex: null })] }))); expect(await screen.findByText('Reviewed changes saved to your Career Profile.')).toBeInTheDocument(); }); test('Docling review requires explicit decisions and sends only reviewed corrections before showing durable completion', async () => { renderImport(); const choice = await screen.findByRole('combobox', { name: 'Decision: Full name' }); expect(choice).toHaveValue(''); expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled(); expect(screen.getByText('Current: Saved name')).toBeInTheDocument(); expect(screen.getByText('Imported: Source name')).toBeInTheDocument(); expect(screen.getByText('Page 1')).toBeInTheDocument(); expect(screen.getByText('Check ambiguous dates.')).toBeInTheDocument(); fireEvent.change(choice, { target: { value: 'correct' } }); fireEvent.change(screen.getByLabelText('Correction: Full name'), { target: { value: 'Reviewed name' } }); fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Professional headline' }), { target: { value: 'keep' } }); fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/23/apply-reviewed', { expectedVersion: 7, decisions: [{ id: 'Contact.fullName', action: 'correct', value: 'Reviewed name' }, { id: 'Contact.headline', action: 'keep' }], })); expect(await screen.findByText('Reviewed changes saved to your Career Profile.')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open Career Profile' })).toHaveAttribute('href', '/career?section=profile'); expect(screen.getByRole('link', { name: 'Open CV editor' })).toHaveAttribute('href', '/career/builder'); }); test('stale apply retains corrections and refreshes current values but requires every choice to be confirmed again', async () => { mockedApi.post.mockRejectedValueOnce({ response: { status: 409 } }); renderImport(); fireEvent.change(await screen.findByRole('combobox', { name: 'Decision: Full name' }), { target: { value: 'correct' } }); fireEvent.change(screen.getByLabelText('Correction: Full name'), { target: { value: 'Keep this correction' } }); fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Professional headline' }), { target: { value: 'use' } }); currentReview = { ...review, revision: 9, groups: [{ ...review.groups[0], fields: [{ ...review.groups[0].fields[0], currentValue: 'Changed elsewhere' }, review.groups[0].fields[1]] }] }; fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' })); expect(await screen.findByText('Current: Changed elsewhere')).toBeInTheDocument(); expect(screen.getByLabelText('Correction: Full name')).toHaveValue('Keep this correction'); expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled(); expect(screen.getByRole('combobox', { name: 'Decision: Full name' })).toHaveValue(''); fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Full name' }), { target: { value: 'correct' } }); expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled(); fireEvent.change(screen.getByRole('combobox', { name: 'Decision: Professional headline' }), { target: { value: 'keep' } }); fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' })); await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/profile-cv/runs/23/apply-reviewed', { expectedVersion: 9, decisions: [{ id: 'Contact.fullName', action: 'correct', value: 'Keep this correction' }, { id: 'Contact.headline', action: 'keep' }], })); }); test('original PDF uses the authenticated API blob and revokes its URL when leaving the page', async () => { const { unmount } = renderImport(); const preview = await screen.findByTitle('Original CV document'); expect(preview).toHaveAttribute('src', 'blob:owned-source'); expect(mockedApi.get).toHaveBeenCalledWith('/profile-cv/runs/23/source', expect.objectContaining({ responseType: 'blob' })); expect(screen.getByRole('link', { name: 'Download original' })).toHaveAttribute('download', 'career.pdf'); unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owned-source'); }); test.each([['career.pdf', 'application/pdf', '/profile-cv/import'], ['career.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '/profile-cv/import'], ['career.txt', 'text/plain', '/profile-cv/upload']])('upload %s uses its compatible pipeline', async (fileName, type, route) => { runs = []; const { container } = renderImport(); await waitFor(() => expect(screen.getByRole('button', { name: 'Upload CV' })).toBeEnabled()); fireEvent.change(container.querySelector('input[type="file"][accept*=".pdf"]')!, { target: { files: [new File(['content'], fileName, { type })] } }); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(route, expect.any(FormData), { headers: { 'Content-Type': 'multipart/form-data' } })); }); test('oversized document is explained before upload and a later valid file remains selectable', async () => { runs = []; const { container } = renderImport(); await screen.findByRole('button', { name: 'Upload CV' }); const input = container.querySelector('input[type="file"][accept*=".pdf"]')!; const file = new File(['small'], 'career.pdf', { type: 'application/pdf' }); Object.defineProperty(file, 'size', { value: 5 * 1024 * 1024 + 1 }); fireEvent.change(input, { target: { files: [file] } }); expect(await screen.findByText('Choose a file no larger than 5 MiB.')).toBeInTheDocument(); expect(mockedApi.post).not.toHaveBeenCalled(); fireEvent.change(input, { target: { files: [new File(['pdf'], 'career.pdf', { type: 'application/pdf' })] } }); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1)); }); test('group approval is deliberate and a dirty profile disables application without losing review corrections', async () => { renderImport(); const group = await screen.findByRole('group', { name: 'Contact information' }); fireEvent.click(within(group).getByRole('button', { name: 'Use all extracted values in this group' })); expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeEnabled(); fireEvent.click(screen.getByRole('tab', { name: 'Career profile' })); fireEvent.change(await screen.findByLabelText('Full name'), { target: { value: 'Unsaved draft' } }); fireEvent.click(screen.getByRole('tab', { name: 'Import review' })); expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled(); fireEvent.click(screen.getByRole('tab', { name: 'Career profile' })); expect(screen.getByLabelText('Full name')).toHaveValue('Unsaved draft'); });