import React from 'react'; import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import CvBuilderEditor from './views/CvBuilderEditor'; import { I18nProvider } from './i18n/I18nProvider'; import { ToastProvider } from './toast'; import { api } from './api'; import { emptyCvVariantSettings } from './cvBuilder'; // The Application Workspace CV section deep-links straight into the builder at // /career/builder/:id. These tests cover that entry point: the variant loads, and a variant the // server refuses (missing, or someone else's) lands on a safe error state rather than a blank editor. jest.mock('./api', () => ({ api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, }, getApiErrorMessage: (_e: any, fallback?: string) => fallback || 'Request failed.', })); const mockedApi = api as jest.Mocked; const variant = { id: 3, name: 'Backend CV', settings: emptyCvVariantSettings(), isPublic: false, publicSlug: 'abc123', version: 4, jobApplicationId: 7, updatedAtUtc: '2026-07-19T10:00:00Z', }; function renderAt(id: number) { const router = createMemoryRouter([ { path: '/career/builder/:id', element: }, { path: '/career/builder', element:
CV list destination
}, ], { initialEntries: [`/career/builder/${id}`] }); return render( , ); } beforeEach(() => { jest.clearAllMocks(); mockedApi.put.mockReset(); mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '

cv

', suggestedFileName: 'cv.pdf' } } as any); }); function routeGet(onVariant: () => Promise, outline: any = { sections: [] }) { mockedApi.get.mockImplementation((url: string) => { if (url === '/cv/variants/3') return onVariant(); if (url === '/cv/themes') return Promise.resolve({ data: [] } as any); if (url === '/cv/outline') return Promise.resolve({ data: outline } as any); return Promise.resolve({ data: [] } as any); }); } test('deep link loads the exact variant named in the route', async () => { routeGet(() => Promise.resolve({ data: variant } as any)); renderAt(3); expect(await screen.findByDisplayValue('Backend CV')).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants/3'); }); test('a variant that does not exist shows a safe error instead of an empty editor', async () => { routeGet(() => Promise.reject(new Error('404'))); renderAt(3); expect(await screen.findByText(/Could not open this CV/i)).toBeInTheDocument(); expect(screen.queryByDisplayValue('Backend CV')).not.toBeInTheDocument(); }); test("another user's variant is refused by the server and never rendered", async () => { // Ownership is enforced server-side: CvVariantService scopes every read to the owner and the // controller returns 404, so the client only ever sees the same safe failure. routeGet(() => Promise.reject({ response: { status: 404 } })); renderAt(3); expect(await screen.findByText(/Could not open this CV/i)).toBeInTheDocument(); }); test('invalid names are explained and are not silently autosaved', async () => { routeGet(() => Promise.resolve({ data: variant } as any)); renderAt(3); fireEvent.change(await screen.findByLabelText('CV name'), { target: { value: '' } }); expect(screen.getByText('Enter a name before saving.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Save now' })).toBeDisabled(); expect(mockedApi.put).not.toHaveBeenCalled(); }); test('failed autosave is visible and can be retried with the latest data', async () => { routeGet(() => Promise.resolve({ data: variant } as any)); mockedApi.put.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ data: variant } as any); renderAt(3); fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Platform Engineer' } }); expect(screen.getByText('Unsaved')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Save now' })); expect(await screen.findByText('Save failed')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Save now' })); expect(await screen.findByText('Saved')).toBeInTheDocument(); expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ name: 'Backend CV', settings: expect.objectContaining({ headline: 'Platform Engineer' }), })); }); test('internal navigation warns and can be cancelled before discarding a pending edit', async () => { routeGet(() => Promise.resolve({ data: variant } as any)); const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false); renderAt(3); fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Unsaved headline' } }); fireEvent.click(screen.getByRole('button', { name: 'Back to CVs' })); expect(confirm).toHaveBeenCalledWith('This CV has unsaved changes. Leave and discard them?'); expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument(); confirm.mockRestore(); }); test('custom entries can be added, edited, reordered and deleted with confirmation', async () => { routeGet(() => Promise.resolve({ data: variant } as any)); mockedApi.put.mockResolvedValue({ data: variant } as any); const confirm = jest.spyOn(window, 'confirm'); renderAt(3); await screen.findByLabelText('Headline override'); fireEvent.click(screen.getByRole('button', { name: 'Add' })); fireEvent.change(screen.getByLabelText('Custom section title'), { target: { value: 'Selected projects' } }); fireEvent.click(screen.getByRole('button', { name: 'Add entry' })); fireEvent.change(screen.getByLabelText('Entry 1'), { target: { value: 'First project' } }); fireEvent.click(screen.getByRole('button', { name: 'Add entry' })); fireEvent.change(screen.getByLabelText('Entry 2'), { target: { value: 'Second project' } }); fireEvent.click(screen.getByRole('button', { name: 'Move custom entry 2 up' })); expect(screen.getByLabelText('Entry 1')).toHaveValue('Second project'); expect(screen.getByLabelText('Entry 2')).toHaveValue('First project'); confirm.mockReturnValueOnce(false).mockReturnValueOnce(true); fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' })); expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(2); fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' })); expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: 'Save now' })); expect(await screen.findByText('Saved')).toBeInTheDocument(); expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ settings: expect.objectContaining({ customSections: [expect.objectContaining({ title: 'Selected projects', items: ['First project'] })], }), })); confirm.mockReturnValueOnce(false).mockReturnValueOnce(true); fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' })); expect(screen.getByLabelText('Custom section title')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' })); expect(screen.queryByLabelText('Custom section title')).not.toBeInTheDocument(); confirm.mockRestore(); }); test('profile-backed sections expand, reorder, hide and persist variant-only overrides', async () => { routeGet(() => Promise.resolve({ data: variant } as any), { sections: [{ key: 'experience', title: 'Experience', kind: 'entries', bullets: [], tags: [], entries: [ { key: 'job-1', title: 'Engineer', subtitle: 'First Co', bullets: ['Built APIs'], tags: [] }, { key: 'job-2', title: 'Lead', subtitle: 'Second Co', bullets: ['Led teams'], tags: [] }, ], }], }); mockedApi.put.mockResolvedValue({ data: variant } as any); renderAt(3); fireEvent.click(await screen.findByRole('button', { name: 'Expand Experience entries' })); expect(screen.getByRole('button', { name: 'Collapse Experience entries' })).toHaveAttribute('aria-expanded', 'true'); fireEvent.click(screen.getByRole('button', { name: 'Move Engineer entry down' })); fireEvent.click(screen.getByRole('button', { name: 'Hide Lead entry' })); fireEvent.click(screen.getByRole('button', { name: 'Move Experience section down' })); fireEvent.click(screen.getByRole('button', { name: 'Save now' })); expect(await screen.findByText('Saved')).toBeInTheDocument(); expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ settings: expect.objectContaining({ sections: expect.arrayContaining([expect.objectContaining({ key: 'experience', itemOrder: ['job-2', 'job-1'] })]), overrides: expect.objectContaining({ 'job-2': expect.objectContaining({ hidden: true }) }), }), })); }); test('preview failure is visible and retryable without leaving the editor', async () => { routeGet(() => Promise.resolve({ data: variant } as any)); mockedApi.post.mockRejectedValueOnce(new Error('preview offline')).mockResolvedValueOnce({ data: { themeId: 'modern', html: '

retry

', suggestedFileName: 'cv.pdf' } } as any); renderAt(3); expect(await screen.findByText('Preview unavailable')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Retry preview' })); await waitFor(() => expect(screen.getByTitle('CV preview')).toHaveAttribute('srcdoc', '

retry

')); await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument()); });