Files
jobtrackingapp/job-tracker-ui/src/cv-import-review.test.tsx
T

140 lines
9.7 KiB
TypeScript

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';
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<typeof api>;
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 = review;
let runs = [run];
function renderImport() {
window.history.replaceState({}, '', '/career?section=import');
return render(<ToastProvider><I18nProvider><CareerProfilePage /></I18nProvider></ToastProvider>);
}
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('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');
});