import React from 'react'; import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ToastProvider } from './toast'; import { I18nProvider } from './i18n/I18nProvider'; import ProfilePage from './views/ProfilePage'; import CareerProfilePage from './views/CareerProfilePage'; 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() } }, }, })); jest.mock('./components/GoogleAuthCard', () => () => null); jest.mock('./components/CropImageDialog', () => () => null); const mockedApi = api as jest.Mocked; let extractionRunsResponse: any[] = []; const structuredCv = { version: '1', metadata: { profileVersion: 3, appliedExtractionRunId: 12, updatedAtUtc: '2026-03-28T12:00:00Z', fields: { 'contact.fullName': { confidence: 0.92, method: 'llm', sourceBlockId: 'block-1', reviewState: 'suggested', sourceSnippet: 'Demo User' }, summary: { confidence: 0.71, method: 'deterministic', sourceBlockId: 'block-2', reviewState: 'suggested', sourceSnippet: 'Built backend systems' }, skills: { confidence: 0.68, method: 'deterministic', sourceBlockId: 'block-3', reviewState: 'suggested', sourceSnippet: '.NET' }, }, }, contact: { fullName: 'Demo User', headline: 'Backend Developer', email: 'demo@example.com', }, summary: ['Built backend systems'], jobs: [ { title: 'System Developer', company: 'Demo Co', location: 'Oslo', start: '2020', end: '2024', isCurrent: false, bullets: ['Built backend systems'], skills: ['.NET', 'SQL'], }, ], education: [], skills: ['.NET', 'SQL'], languages: [{ name: 'English', level: 'Native' }], interests: ['Cooking'], otherSections: [], sections: [ { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, { name: 'Skills', content: '.NET\nSQL', wordCount: 2 }, ], }; function renderWith(Component: React.ComponentType) { return render( , ); } // These exercise the master-CV editing surface, which lives on /career (CareerProfilePage) after // the Phase 2.2 split. ProfilePage no longer carries it. function renderPage() { return renderWith(CareerProfilePage); } void ProfilePage; beforeEach(() => { extractionRunsResponse = [{ id: 12, trigger: 'upload', status: 'applied', artifactFileName: 'resume.pdf', startedAtUtc: '2026-03-28T12:00:00Z', completedAtUtc: '2026-03-28T12:00:05Z', appliedAtUtc: '2026-03-28T12:00:05Z', parserVersion: 'm005-s01', normalizerVersion: 'm005-s01', llmPromptVersion: 'm005-s01', }]; mockedApi.get.mockImplementation((url: string) => { if (url === '/career/profile') { // Phase 3: /career reads the structured profile from the relational source of truth. return Promise.resolve({ data: { profile: structuredCv, cvText: 'Professional Summary\nBuilt backend systems', completeness: { percent: 70, missing: ['Projects'], sections: [] }, }, } as any); } if (url === '/career/profile/versions') { return Promise.resolve({ data: [] } as any); } if (url === '/auth/me') { return Promise.resolve({ data: { provider: 'local', email: 'demo@example.com', userName: 'demo', firstName: 'Demo', lastName: 'User', displayName: 'Demo User', profileCvText: 'Professional Summary\nBuilt backend systems', profileCvStructureJson: JSON.stringify(structuredCv), googleLink: { linked: false }, }, } as any); } if (url === '/profile-cv/runs') { return Promise.resolve({ data: extractionRunsResponse } as any); } if (/^\/profile-cv\/runs\/\d+\/diff$/.test(url)) { return Promise.resolve({ data: { runId: 13, status: 'pending_review', diff: { totalAdded: 3, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [ { category: 'Skills', added: [{ id: 'Skills|docker', label: 'Docker', confidence: 'High' }], updated: [], unchangedCount: 2, lowConfidenceCount: 0 }, { category: 'Languages', added: [{ id: 'Languages|french', label: 'French', confidence: 'Low' }], updated: [], unchangedCount: 1, lowConfidenceCount: 1 }, { category: 'Experience', added: [{ id: 'Experience|new co engineer', label: 'Engineer - New Co', confidence: 'High' }], updated: [{ id: 'Experience|demo co developer', label: 'Developer - Demo Co', confidence: 'High' }], unchangedCount: 0, lowConfidenceCount: 0 }, ] }, } } as any); } if (url === '/jobapplications') { return Promise.resolve({ data: { items: [ { id: 42, jobTitle: 'Senior Backend Engineer', company: { id: 7, name: 'Acme Systems' }, status: 'Waiting', dateApplied: '2026-03-20', daysSince: 10, description: 'Build API integrations and platform workflows.', responseReceived: false, }, ], total: 1, page: 1, pageSize: 100, }, } as any); } return Promise.resolve({ data: {} } as any); }); mockedApi.post.mockImplementation((url: string, payload?: any, config?: any) => { if (url === '/profile-cv/parse') { return Promise.resolve({ data: { structuredCv: { ...structuredCv, sections: [ { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, { name: 'Core Skills', content: '.NET\nSQL\nAzure', wordCount: 3 }, ], skills: ['.NET', 'SQL', 'Azure'], }, }, } as any); } if (url === '/profile-cv/reprocess') { return Promise.resolve({ data: { reprocessed: true } } as any); } return Promise.resolve({ data: {} } as any); }); mockedApi.put.mockResolvedValue({ data: {} } as any); window.localStorage.clear(); }); afterEach(() => { jest.clearAllMocks(); }); test('profile page loads persisted structured cv and can re-parse it', async () => { renderPage(); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); expect(screen.getByText(/profile sections/i)).toBeInTheDocument(); expect(screen.getAllByText(/career information/i).length).toBeGreaterThan(0); expect(screen.getByText(/extraction history/i)).toBeInTheDocument(); expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument(); expect(screen.getByText(/current run/i)).toBeInTheDocument(); expect(screen.queryByText(/template-driven cv builder/i)).not.toBeInTheDocument(); expect(screen.getByRole('link', { name: /open cv builder/i })).toHaveAttribute('href', '/career/builder'); expect(screen.getAllByText(/original import/i).length).toBeGreaterThan(0); const originalExtractionToggle = screen.getByRole('button', { name: /original import/i }); expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false'); expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0); expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User'); expect(screen.getByText(/high 92%/i)).toBeInTheDocument(); expect(screen.getByText(/block-1/i)).toBeInTheDocument(); fireEvent.click(originalExtractionToggle); expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true'); expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems'); // Structure overview is hidden from the default workflow (Phase 1 increment 2); reveal it. fireEvent.click(screen.getByRole('button', { name: /advanced cv tools/i })); const analyzeButton = screen.getByRole('button', { name: /read sections/i }); await waitFor(() => expect(analyzeButton).toBeEnabled()); fireEvent.click(analyzeButton); await waitFor(() => { expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/parse', { text: 'Professional Summary\nBuilt backend systems' }); }); expect(screen.getAllByText(/core skills/i).length).toBeGreaterThan(0); }); test('saving the career profile PUTs the structured profile and cv text unchanged (Phase 1 refactor invariant)', async () => { renderPage(); const saveButton = await screen.findByRole('button', { name: /save changes/i }); await waitFor(() => expect(saveButton).toBeEnabled()); fireEvent.click(saveButton); await waitFor(() => { expect(mockedApi.put).toHaveBeenCalledWith( '/career/profile', expect.objectContaining({ profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Demo User' }) }), cvText: 'Professional Summary\nBuilt backend systems', }), ); }); }); test('editing a field in an extracted section updates parent state and flows into save (Phase 1 increment 2)', async () => { renderPage(); // Renders existing data through the extracted PersonalInformationSection. const nameField = await screen.findByLabelText(/full name/i); expect(nameField).toHaveValue('Demo User'); // Editing the child field updates the parent's structuredCv (controlled input reflects it back). fireEvent.change(nameField, { target: { value: 'Edited Name' } }); expect(nameField).toHaveValue('Edited Name'); const saveButton = screen.getByRole('button', { name: /save changes/i }); await waitFor(() => expect(saveButton).toBeEnabled()); fireEvent.click(saveButton); // The edited value reaches the unchanged save path. await waitFor(() => { expect(mockedApi.put).toHaveBeenCalledWith( '/career/profile', expect.objectContaining({ profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Edited Name' }) }), cvText: expect.any(String), }), ); }); }); test('profile page can reprocess from stored artifact history', async () => { renderPage(); expect(await screen.findByText(/extraction history/i)).toBeInTheDocument(); const reprocessButton = screen.getByRole('button', { name: /reprocess cv/i }); fireEvent.click(reprocessButton); await waitFor(() => { expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/reprocess'); }); }); test('profile page keeps raw extraction collapsed until expanded', async () => { renderPage(); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); expect(screen.getByText(/your career information stays front and center/i)).toBeInTheDocument(); const originalExtractionToggle = screen.getByRole('button', { name: /original import/i }); expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByRole('button', { name: /copy cv text/i })).not.toBeInTheDocument(); fireEvent.click(originalExtractionToggle); expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true'); expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems'); expect(screen.getByRole('button', { name: /copy cv text/i })).toBeEnabled(); }); test('saving the master profile (career) persists the structured profile via /career/profile', async () => { // Phase 3: /career saves the master profile through the relational API. The payload carries the // structured profile object (not the legacy flat blob) and never identity fields. mockedApi.put.mockResolvedValue({ data: { profile: structuredCv, completeness: { percent: 70, missing: [], sections: [] }, cvText: '' } } as any); renderWith(CareerProfilePage); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); const fullNameInput = screen.getByLabelText(/full name/i); fireEvent.change(fullNameInput, { target: { value: 'Updated Demo User' } }); const saveButton = screen.getByRole('button', { name: /save changes/i }); await waitFor(() => expect(saveButton).toBeEnabled()); fireEvent.click(saveButton); await waitFor(() => { expect(mockedApi.put).toHaveBeenCalledWith('/career/profile', expect.anything()); }); const payload = mockedApi.put.mock.calls[0][1] as any; expect(payload.profile.contact.fullName).toBe('Updated Demo User'); expect(payload.profile.skills).toEqual(['.NET', 'SQL']); expect(payload.profile.jobs[0].title).toBe('System Developer'); // The career save must NOT carry identity fields (they belong to /profile). expect(payload.email).toBeUndefined(); expect(payload.displayName).toBeUndefined(); }); test('/career shows the profile completeness overview', async () => { renderWith(CareerProfilePage); expect(await screen.findByText(/profile completeness/i)).toBeInTheDocument(); expect(screen.getByText(/70%/)).toBeInTheDocument(); }); test('pending CV extraction can be reviewed and applied', async () => { extractionRunsResponse = [{ id: 13, trigger: 'upload', status: 'pending_review', artifactFileName: 'new-resume.pdf', startedAtUtc: '2026-03-29T12:00:00Z', completedAtUtc: '2026-03-29T12:00:05Z', parserVersion: 'm005-s01', normalizerVersion: 'm005-s01', llmPromptVersion: 'm005-s01', }]; renderPage(); expect(await screen.findByText(/3 additions/i)).toBeInTheDocument(); const lowConfidence = screen.getByRole('checkbox', { name: /include low-confidence languages: french/i }); expect(lowConfidence).not.toBeChecked(); fireEvent.click(lowConfidence); fireEvent.click(screen.getByRole('button', { name: /apply changes/i })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/13/accept', { acceptedLowConfidenceIds: ['Languages|french'], })); });