425 lines
23 KiB
TypeScript
425 lines
23 KiB
TypeScript
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<typeof api>;
|
|
|
|
function renderPage(extra?: React.ReactNode) {
|
|
return render(
|
|
<MemoryRouter>
|
|
<ToastProvider>
|
|
<I18nProvider>
|
|
<ConfirmProvider>
|
|
<PromptProvider>
|
|
{extra}
|
|
<CvBuilderPage />
|
|
</PromptProvider>
|
|
</ConfirmProvider>
|
|
</I18nProvider>
|
|
</ToastProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
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<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason: unknown) => void;
|
|
const promise = new Promise<T>((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<any>();
|
|
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<any>();
|
|
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<any>();
|
|
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<any>();
|
|
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<any>();
|
|
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<any>();
|
|
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
|
|
mockedApi.delete.mockImplementationOnce(() => write.promise);
|
|
renderPage(<button type="button">Separate workflow control</button>);
|
|
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());
|
|
});
|