import React from 'react'; import '@testing-library/jest-dom'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import CvBuilderPage from './views/CvBuilderPage'; import { I18nProvider } from './i18n/I18nProvider'; import { ToastProvider } from './toast'; import { api } from './api'; import { ConfirmProvider } from './confirm'; import { PromptProvider } from './prompt'; import { emptyCvVariantSettings } from './cvBuilder'; const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockNavigate, })); 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(extra?: React.ReactNode) { return render( {extra} , ); } beforeEach(() => { window.localStorage.removeItem('uiLanguage'); jest.resetAllMocks(); mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : [] }) as any); }); afterEach(() => window.localStorage.removeItem('uiLanguage')); const themes = [ { id: 'modern', name: 'Modern', category: 'Professional', description: '', layout: 'header-band', accent: '#3157d5', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#3157d5'] }, { id: 'clean', name: 'Clean', category: 'Professional', description: '', layout: 'single-column', accent: '#444444', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#444444'] }, { id: 'premium', name: 'Premium', category: 'Professional', description: '', layout: 'sidebar', accent: '#333333', photoShape: 'none', supportsIcons: true, atsFriendly: true, requiresPro: true, available: false, swatches: ['#333333'] }, ]; const existingCv = { id: 1, name: 'Frontend CV', themeId: 'modern', language: 'en', version: 2, jobApplicationId: 17, jobTitle: 'Frontend Engineer', companyName: 'Northstar', publicSlug: 'original-slug', isPublic: true, updatedAtUtc: '2026-10-12T10:00:00Z', }; function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } async function selectAction(name: string) { fireEvent.click(screen.getByRole('button', { name: 'Actions for Frontend CV' })); const item = await screen.findByRole('menuitem', { name }); item.focus(); fireEvent.click(item); } test('the CV library has a section heading and announces its held initial read without showing an empty result', async () => { const read = deferred(); mockedApi.get.mockImplementation((url) => url === '/cv/variants' ? read.promise : Promise.resolve({ data: themes }) as any); renderPage(); expect(screen.getByRole('heading', { level: 2, name: 'Your CVs' })).toBeInTheDocument(); expect(screen.getByRole('status')).toHaveTextContent(/Loading/i); expect(screen.queryByText('No CVs yet')).not.toBeInTheDocument(); await act(async () => { read.resolve({ data: [existingCv] }); }); expect(await screen.findByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument(); expect(screen.getByRole('status')).toHaveTextContent('Content loaded'); expect(screen.getByRole('status')).not.toHaveTextContent(/Loading/i); }); test('a failed CV list read has an explicit retry that clears the stale failure after recovery', async () => { const retry = deferred(); let listReads = 0; mockedApi.get.mockImplementation((url) => url === '/cv/variants' ? ++listReads === 1 ? Promise.reject(new Error('synthetic unavailable')) : retry.promise : Promise.resolve({ data: themes }) as any); renderPage(); expect(await screen.findByRole('alert')).toHaveTextContent('Could not load your CVs.'); expect(screen.queryByText('No CVs yet')).not.toBeInTheDocument(); const retryButton = screen.getByRole('button', { name: 'Retry' }); retryButton.focus(); fireEvent.click(retryButton); await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/cv/variants')).toHaveLength(2)); expect(screen.getByRole('status')).toHaveTextContent(/Loading/i); expect(mockedApi.post).not.toHaveBeenCalled(); await act(async () => { retry.resolve({ data: [existingCv] }); }); expect(await screen.findByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument(); expect(screen.queryByText('Could not load your CVs.')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); }); test('a live language change retains an open CV name and template draft without refetching library data', async () => { renderPage(); await screen.findByText('No CVs yet'); fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]); await screen.findByRole('dialog', { name: 'Create a CV' }); fireEvent.change(screen.getByRole('textbox', { name: 'CV name' }), { target: { value: 'My unsaved application CV' } }); fireEvent.click(screen.getByRole('button', { name: 'Clean' })); const requests = mockedApi.get.mock.calls.length; act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); }); expect(await screen.findByRole('dialog', { name: 'Opprett en CV' })).toBeInTheDocument(); expect(screen.getByRole('textbox', { name: 'CV-navn' })).toHaveValue('My unsaved application CV'); expect(screen.getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true'); expect(mockedApi.get).toHaveBeenCalledTimes(requests); expect(mockedApi.post).not.toHaveBeenCalled(); }); test('held CV creation locks the draft and gallery, then keeps a failed draft for an explicit retry', async () => { const write = deferred(); mockedApi.post.mockImplementationOnce(() => write.promise).mockResolvedValueOnce({ data: { id: 42 } } as any); renderPage(); await screen.findByText('No CVs yet'); fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]); const dialog = await screen.findByRole('dialog', { name: 'Create a CV' }); const name = within(dialog).getByRole('textbox', { name: 'CV name' }); fireEvent.change(name, { target: { value: 'Application-specific CV' } }); fireEvent.click(within(dialog).getByRole('button', { name: 'Clean' })); fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1)); expect(name).toBeDisabled(); expect(within(dialog).getByRole('button', { name: 'ATS templates' })).toBeDisabled(); expect(within(dialog).getByRole('button', { name: 'All templates' })).toBeDisabled(); expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-disabled', 'true'); expect(within(dialog).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-disabled', 'true'); expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled(); fireEvent.click(within(dialog).getByRole('button', { name: /Creating/ })); fireEvent.click(within(dialog).getByRole('button', { name: 'Modern' })); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(within(dialog).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true'); await act(async () => { write.reject(new Error('synthetic failed create')); }); expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not create a CV.'); expect(name).toBeEnabled(); expect(name).toHaveValue('Application-specific CV'); expect(within(dialog).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true'); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockNavigate).not.toHaveBeenCalled(); fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' })); await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: emptyCvVariantSettings('clean') })); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42')); }); test('a locked CV template remains identifiable and cannot change the creation request', async () => { mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any); renderPage(); await screen.findByText('No CVs yet'); fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]); const dialog = await screen.findByRole('dialog', { name: 'Create a CV' }); const premium = within(dialog).getByRole('button', { name: 'Premium' }); expect(premium).toHaveAttribute('aria-disabled', 'true'); expect(premium).toHaveAttribute('tabindex', '-1'); fireEvent.click(premium); fireEvent.keyDown(premium, { key: 'Enter' }); expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-pressed', 'true'); fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: emptyCvVariantSettings('modern') }))); }); test('a held duplicate blocks another action for the same CV and shows the persisted refreshed copy', async () => { const write = deferred(); let listReads = 0; mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? ++listReads === 1 ? [existingCv] : [existingCv, { ...existingCv, id: 2, name: 'Frontend CV (copy)', publicSlug: 'copy-slug', isPublic: false }] : themes }) as any); mockedApi.post.mockImplementationOnce(() => write.promise); renderPage(); await screen.findByText('Frontend CV'); const actions = screen.getByRole('button', { name: 'Actions for Frontend CV' }); await selectAction('Duplicate'); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/1/duplicate', { name: undefined })); expect(actions).toBeDisabled(); fireEvent.click(actions); const duplicate = screen.queryByRole('menuitem', { name: 'Duplicate' }); if (duplicate) fireEvent.click(duplicate); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(listReads).toBe(1); await act(async () => { write.resolve({ data: { id: 2 } }); }); expect(await screen.findByRole('link', { name: 'Open Frontend CV (copy)' })).toBeInTheDocument(); await waitFor(() => expect(actions).toBeEnabled()); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(listReads).toBe(2); }); test('rename reads the current revision and preserves its settings when saving the new name', async () => { const settings = { ...emptyCvVariantSettings('clean'), overrides: { summary: { text: 'Owned override' } }, showPageNumbers: true }; mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : url === '/cv/variants/1' ? { ...existingCv, settings, version: 7 } : themes }) as any); mockedApi.put.mockResolvedValueOnce({ data: { ...existingCv, name: 'Renamed application CV', version: 8, updatedAtUtc: '2026-10-12T11:00:00Z' } } as any); renderPage(); await screen.findByText('Frontend CV'); await selectAction('Rename'); const input = await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }); fireEvent.change(input, { target: { value: ' Renamed application CV ' } }); fireEvent.click(screen.getByRole('button', { name: 'Rename' })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/1', { name: 'Renamed application CV', settings, source: 'manual', expectedVersion: 7 })); expect(await screen.findByRole('link', { name: 'Open Renamed application CV' })).toBeInTheDocument(); expect(screen.getByText(/Frontend Engineer at Northstar/)).toBeInTheDocument(); expect(screen.getByText('Public')).toBeInTheDocument(); expect(mockedApi.put).toHaveBeenCalledTimes(1); }); test('a rename conflict keeps the original library entry and never retries the write automatically', async () => { const settings = { ...emptyCvVariantSettings('modern'), overrides: { summary: { text: 'Owned override' } } }; mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : url === '/cv/variants/1' ? { ...existingCv, settings, version: 7 } : themes }) as any); mockedApi.put.mockRejectedValueOnce({ response: { status: 409 } }); renderPage(); await screen.findByText('Frontend CV'); await selectAction('Rename'); fireEvent.change(await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }), { target: { value: 'Conflicting CV name' } }); fireEvent.click(screen.getByRole('button', { name: 'Rename' })); expect((await screen.findAllByText('Rename failed.')).length).toBeGreaterThan(0); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(screen.getByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Open Conflicting CV name' })).not.toBeInTheDocument(); expect(mockedApi.put).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled(); }); test('cancelling deletion returns focus to the surviving CV action control without removing the CV', async () => { mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); renderPage(); await screen.findByText('Frontend CV'); const actions = screen.getByRole('button', { name: 'Actions for Frontend CV' }); actions.focus(); await selectAction('Delete'); await screen.findByRole('heading', { name: 'Delete CV' }); fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); await waitFor(() => expect(actions).toHaveFocus()); expect(screen.getByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); test('a failed creation keeps its draft and translates its failure after closing and reopening in another language', async () => { mockedApi.post.mockRejectedValueOnce(new Error('synthetic unavailable')); renderPage(); await screen.findByText('No CVs yet'); fireEvent.click(screen.getByRole('button', { name: 'New CV' })); const dialog = await screen.findByRole('dialog', { name: 'Create a CV' }); fireEvent.change(within(dialog).getByRole('textbox', { name: 'CV name' }), { target: { value: 'Keep this failed CV draft' } }); fireEvent.click(within(dialog).getByRole('button', { name: 'Clean' })); fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' })); expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not create a CV.'); fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); const reads = mockedApi.get.mock.calls.length; act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); }); fireEvent.click(screen.getByRole('button', { name: 'Ny CV' })); const reopened = await screen.findByRole('dialog', { name: 'Opprett en CV' }); expect(within(reopened).getByRole('textbox', { name: 'CV-navn' })).toHaveValue('Keep this failed CV draft'); expect(within(reopened).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true'); expect(within(reopened).getByRole('alert')).toHaveTextContent('Kunne ikke opprette en CV.'); expect(within(reopened).queryByText('Could not create a CV.')).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(reads); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockNavigate).not.toHaveBeenCalled(); }); test('generic PDF failure follows the current language both after failure and when a held request fails', async () => { const secondDownload = deferred(); mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); mockedApi.post.mockRejectedValueOnce(new Error('synthetic unavailable')).mockImplementationOnce(() => secondDownload.promise); renderPage(); await screen.findByText('Frontend CV'); await selectAction('Download PDF'); expect(await screen.findByRole('alert')).toHaveTextContent('PDF download failed.'); const reads = mockedApi.get.mock.calls.length; act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); }); expect(screen.getByRole('alert')).toHaveTextContent('Nedlasting av PDF mislyktes.'); expect(screen.queryByText('PDF download failed.')).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(reads); expect(mockedApi.post).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole('button', { name: 'Handlinger for Frontend CV' })); fireEvent.click(await screen.findByRole('menuitem', { name: 'Last ned PDF' })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2)); act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'en' })); }); await act(async () => { secondDownload.reject(new Error('synthetic unavailable')); }); expect(await screen.findByRole('alert')).toHaveTextContent('PDF download failed.'); expect(screen.queryByText('Nedlasting av PDF mislyktes.')).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(reads); expect(mockedApi.post).toHaveBeenCalledTimes(2); expect(mockedApi.put).not.toHaveBeenCalled(); }); test('a held deletion preserves focus moved to another surviving control before the response arrives', async () => { const write = deferred(); mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); mockedApi.delete.mockImplementationOnce(() => write.promise); renderPage(); await screen.findByText('Frontend CV'); await selectAction('Delete'); fireEvent.click(await screen.findByRole('button', { name: 'Delete CV' })); await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith('/cv/variants/1')); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); const otherControl = screen.getByRole('button', { name: 'Separate workflow control' }); otherControl.focus(); expect(otherControl).toHaveFocus(); await act(async () => { write.resolve({ data: null }); }); await waitFor(() => expect(screen.queryByRole('link', { name: 'Open Frontend CV' })).not.toBeInTheDocument()); expect(otherControl).toHaveFocus(); expect(mockedApi.delete).toHaveBeenCalledTimes(1); expect(mockedApi.post).not.toHaveBeenCalled(); }); test.each([ [503, 'PDF generation is busy. Please try again shortly.'], [504, 'PDF generation took too long. Please try again.'], ])('PDF status %s shows an actionable message without retrying', async (status, message) => { mockedApi.get.mockResolvedValueOnce({ data: [{ id: 1, name: 'Synthetic CV', themeId: 'modern', version: 1, updatedAtUtc: new Date().toISOString() }] } as any); mockedApi.post.mockRejectedValueOnce({ response: { status, data: new Blob(['{}'], { type: 'application/problem+json' }) } }); renderPage(); await screen.findByText('Synthetic CV'); fireEvent.click(screen.getByRole('button', { name: 'Actions for Synthetic CV' })); fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PDF' })); expect(await screen.findByText(message)).toBeInTheDocument(); expect(mockedApi.post).toHaveBeenCalledTimes(1); }); test('lists existing CVs from the variants API', async () => { mockedApi.get.mockResolvedValueOnce({ data: [ { id: 1, name: 'Frontend CV', themeId: 'modern', language: 'en', publicSlug: 'abc', isPublic: true, version: 2, jobApplicationId: 17, jobTitle: 'Frontend Engineer', companyName: 'Northstar', updatedAtUtc: new Date().toISOString() }, ], } as any); renderPage(); expect(await screen.findByText('Frontend CV')).toBeInTheDocument(); expect(screen.getByText('Public')).toBeInTheDocument(); expect(screen.getByText(/Frontend Engineer at Northstar/)).toBeInTheDocument(); const cvCard = screen.getByRole('link', { name: 'Open Frontend CV' }); cvCard.focus(); expect(cvCard).toHaveFocus(); fireEvent.keyDown(cvCard, { key: 'Enter' }); expect(mockNavigate).toHaveBeenCalledWith('/career/builder/1'); expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants'); }); test('shows the empty state and creates a CV then navigates to the editor', async () => { mockedApi.get.mockResolvedValueOnce({ data: [] } as any); mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any); renderPage(); expect(await screen.findByText('No CVs yet')).toBeInTheDocument(); fireEvent.click(screen.getAllByRole('button', { name: /New CV/i })[0]); expect(await screen.findByRole('dialog', { name: 'Create a CV' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Create CV' })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ name: 'Untitled CV' }))); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42')); }); test('CV dashboard and create flow render in Bokmål', async () => { window.localStorage.setItem('uiLanguage', 'nb'); mockedApi.get.mockResolvedValueOnce({ data: [] } as any); renderPage(); expect(await screen.findByText('Ingen CV-er ennå')).toBeInTheDocument(); fireEvent.click(screen.getAllByRole('button', { name: 'Ny CV' })[0]); expect(await screen.findByRole('dialog', { name: 'Opprett en CV' })).toBeInTheDocument(); expect(screen.getByLabelText(/^CV-navn/)).toHaveValue('CV uten tittel'); expect(screen.getByText('Velg en mal')).toBeInTheDocument(); }); test('CV deletion requires confirmation and supports cancellation', async () => { mockedApi.get.mockResolvedValueOnce({ data: [ { id: 1, name: 'Frontend CV', themeId: 'modern', publicSlug: 'abc', isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: new Date().toISOString() }, ] } as any); mockedApi.delete.mockResolvedValue({ data: null } as any); renderPage(); await screen.findByText('Frontend CV'); fireEvent.click(screen.getByRole('button', { name: 'Actions for Frontend CV' })); fireEvent.click(await screen.findByRole('menuitem', { name: 'Delete' })); expect(await screen.findByRole('dialog', { name: 'Delete CV' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(mockedApi.delete).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: 'Actions for Frontend CV' })); fireEvent.click(await screen.findByRole('menuitem', { name: 'Delete' })); fireEvent.click(await screen.findByRole('button', { name: 'Delete CV' })); await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith('/cv/variants/1')); await waitFor(() => expect(screen.queryByText('Frontend CV')).not.toBeInTheDocument()); });