301 lines
15 KiB
TypeScript
301 lines
15 KiB
TypeScript
import React from 'react';
|
|
import '@testing-library/jest-dom';
|
|
import { fireEvent, render, screen, waitFor, within } 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';
|
|
import { ConfirmProvider } from './confirm';
|
|
import { PromptProvider } from './prompt';
|
|
|
|
// 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<typeof api>;
|
|
|
|
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: <CvBuilderEditor /> },
|
|
{ path: '/career/builder', element: <div>CV list destination</div> },
|
|
], { initialEntries: [`/career/builder/${id}`] });
|
|
return render(
|
|
<I18nProvider>
|
|
<ToastProvider>
|
|
<ConfirmProvider>
|
|
<PromptProvider>
|
|
<RouterProvider router={router} />
|
|
</PromptProvider>
|
|
</ConfirmProvider>
|
|
</ToastProvider>
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
window.localStorage.removeItem('uiLanguage');
|
|
jest.clearAllMocks();
|
|
mockedApi.put.mockReset();
|
|
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
|
|
});
|
|
|
|
afterEach(() => window.localStorage.removeItem('uiLanguage'));
|
|
|
|
function routeGet(onVariant: () => Promise<any>, 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);
|
|
});
|
|
}
|
|
|
|
async function openContentTab() {
|
|
await screen.findByDisplayValue('Backend CV');
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
|
}
|
|
|
|
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);
|
|
|
|
await openContentTab();
|
|
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('session undo and redo restore content edits before autosave', async () => {
|
|
routeGet(() => Promise.resolve({ data: variant } as any));
|
|
mockedApi.put.mockResolvedValue({ data: variant } as any);
|
|
renderAt(3);
|
|
|
|
await openContentTab();
|
|
const headline = await screen.findByLabelText('Headline override');
|
|
fireEvent.change(headline, { target: { value: 'Platform Engineer' } });
|
|
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
|
expect(headline).toHaveValue('');
|
|
expect(screen.getByRole('button', { name: 'Redo' })).toBeEnabled();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
|
|
expect(headline).toHaveValue('Platform Engineer');
|
|
});
|
|
|
|
test('professional editor starts with templates and combines design and layout controls', async () => {
|
|
routeGet(() => Promise.resolve({ data: variant } as any));
|
|
renderAt(3);
|
|
|
|
await screen.findByDisplayValue('Backend CV');
|
|
expect(screen.getByRole('tab', { name: 'Template' })).toHaveAttribute('aria-selected', 'true');
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Customize' }));
|
|
expect(screen.getByText('Typography')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Body size')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Skills presentation')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Page size')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Columns')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Language')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
|
|
expect(screen.getByText('Translate resume')).toBeInTheDocument();
|
|
expect(screen.getByText('Improve writing')).toBeInTheDocument();
|
|
expect(screen.getByText('Check spelling and grammar')).toBeInTheDocument();
|
|
});
|
|
|
|
test('editor sections, design controls and document settings render in Bokmål', async () => {
|
|
window.localStorage.setItem('uiLanguage', 'nb');
|
|
routeGet(() => Promise.resolve({ data: variant } as any));
|
|
renderAt(3);
|
|
|
|
await screen.findByDisplayValue('Backend CV');
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Innhold' }));
|
|
expect(await screen.findByLabelText('Egen overskrift')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Utvid Erfaring' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Legg til innhold' })).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Tilpass' }));
|
|
expect(screen.getByText('Typografi')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Visning av ferdigheter')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Sidestørrelse')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Dokumentspråk')).toBeInTheDocument();
|
|
});
|
|
|
|
test('internal navigation warns and can be cancelled before discarding a pending edit', async () => {
|
|
routeGet(() => Promise.resolve({ data: variant } as any));
|
|
renderAt(3);
|
|
|
|
await openContentTab();
|
|
fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Unsaved headline' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Back to CVs' }));
|
|
const dialog = await screen.findByRole('dialog', { name: 'Discard unsaved CV changes?' });
|
|
expect(within(dialog).getByText('This CV has unsaved changes. Leave and discard them?')).toBeInTheDocument();
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), { timeout: 5000 });
|
|
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
|
|
});
|
|
|
|
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);
|
|
renderAt(3);
|
|
|
|
await openContentTab();
|
|
await screen.findByLabelText('Headline override');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Add content' }));
|
|
const addContentDialog = await screen.findByRole('dialog', { name: 'Add content' });
|
|
const additionalExperienceCard = within(addContentDialog).getByText('Additional Experience').closest('.MuiPaper-root');
|
|
expect(additionalExperienceCard).not.toBeNull();
|
|
fireEvent.click(within(additionalExperienceCard as HTMLElement).getByRole('button', { name: 'Add' }));
|
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Add content' })).not.toBeInTheDocument());
|
|
const title = screen.getByLabelText(/Section name for custom:/);
|
|
fireEvent.change(title, { target: { value: 'Selected projects' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Expand 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');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
|
|
let dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(2);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
|
|
dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete entry' }));
|
|
await waitFor(() => expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1));
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
|
|
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
|
|
settings: expect.objectContaining({
|
|
customSections: [expect.objectContaining({ title: 'Selected projects', items: ['First project'] })],
|
|
}),
|
|
})), { timeout: 5000 });
|
|
expect(await screen.findByText('Saved')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Remove Selected projects section from this CV' }));
|
|
dialog = await screen.findByRole('dialog', { name: 'Delete custom section' });
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
expect(screen.getByLabelText(/Section name for custom:/)).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Remove Selected projects section from this CV' }));
|
|
dialog = await screen.findByRole('dialog', { name: 'Delete custom section' });
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete section' }));
|
|
await waitFor(() => expect(screen.queryByLabelText(/Section name for custom:/)).not.toBeInTheDocument());
|
|
});
|
|
|
|
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);
|
|
|
|
await openContentTab();
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Expand Experience' }));
|
|
expect(screen.getByRole('button', { name: 'Collapse Experience' })).toHaveAttribute('aria-expanded', 'true');
|
|
fireEvent.mouseDown(screen.getByLabelText('Presentation'));
|
|
fireEvent.click(await screen.findByRole('option', { name: 'Grid' }));
|
|
fireEvent.click(within(screen.getByRole('group', { name: 'Columns' })).getByRole('button', { name: '2' }));
|
|
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' }));
|
|
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
|
|
settings: expect.objectContaining({
|
|
sections: expect.arrayContaining([expect.objectContaining({ key: 'experience', itemOrder: ['job-2', 'job-1'], presentation: 'grid', columns: 2 })]),
|
|
overrides: expect.objectContaining({ 'job-2': expect.objectContaining({ hidden: true }) }),
|
|
}),
|
|
})), { timeout: 5000 });
|
|
expect(await screen.findByText('Saved')).toBeInTheDocument();
|
|
});
|
|
|
|
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: '<p>retry</p>', 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').getAttribute('srcdoc')).toContain('<p>retry</p>'));
|
|
expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin allow-scripts');
|
|
await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument());
|
|
});
|