269 lines
14 KiB
TypeScript
269 lines
14 KiB
TypeScript
import React from 'react';
|
|
import '@testing-library/jest-dom';
|
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
|
|
|
|
import { api } from './api';
|
|
import PublicCvPage from './views/PublicCvPage';
|
|
import { I18nProvider } from './i18n/I18nProvider';
|
|
|
|
jest.mock('./api', () => ({
|
|
api: {
|
|
defaults: { baseURL: '/api' },
|
|
get: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockedApi = api as jest.Mocked<typeof api>;
|
|
const originalInnerWidth = window.innerWidth;
|
|
const originalTitle = document.title;
|
|
|
|
function RouteControls() {
|
|
const navigate = useNavigate();
|
|
return <><button onClick={() => navigate('/cv/slug-a')}>Open A</button><button onClick={() => navigate('/cv/slug-b')}>Open B</button></>;
|
|
}
|
|
|
|
function renderPage(path = '/cv/public-slug') {
|
|
return render(
|
|
<I18nProvider>
|
|
<MemoryRouter initialEntries={[path]}>
|
|
<RouteControls />
|
|
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
|
|
</MemoryRouter>
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
function switchLanguage(language: 'en' | 'nb') {
|
|
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockedApi.get.mockReset();
|
|
window.localStorage.removeItem('uiLanguage');
|
|
document.title = originalTitle;
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
|
|
window.localStorage.removeItem('uiLanguage');
|
|
document.title = originalTitle;
|
|
});
|
|
|
|
test('public CV exposes the rendered CV and PDF download', async () => {
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 });
|
|
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
|
|
renderPage();
|
|
|
|
const frame = await screen.findByTitle('Public CV');
|
|
expect(frame).toHaveAttribute('srcdoc', '<p>Public CV</p>');
|
|
expect(frame).toHaveAttribute('sandbox', 'allow-same-origin');
|
|
expect(Number.parseFloat(frame.style.width)).toBeGreaterThan(790);
|
|
await waitFor(() => expect(frame.style.transform).not.toBe('scale(1)'));
|
|
expect(Number.parseFloat(screen.getByTestId('public-cv-frame-container').style.width)).toBeLessThanOrEqual(343);
|
|
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
|
|
expect(mockedApi.get).toHaveBeenCalledWith('/public-cv/public-slug');
|
|
});
|
|
|
|
test('a public slug containing path and URL delimiters stays one encoded API and PDF path segment', async () => {
|
|
const slug = 'owner/document?mode=preview#section%';
|
|
const encodedSlug = encodeURIComponent(slug);
|
|
const read = deferred<any>();
|
|
mockedApi.get.mockImplementationOnce(() => read.promise);
|
|
renderPage(`/cv/${encodedSlug}`);
|
|
expect(mockedApi.get).toHaveBeenCalledWith(`/public-cv/${encodedSlug}`);
|
|
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
|
await act(async () => { read.resolve({ data: { html: '<p>Encoded slug document</p>', name: 'Candidate' } }); });
|
|
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Encoded slug document</p>');
|
|
expect(screen.getByTitle('Public CV')).toHaveAttribute('sandbox', 'allow-same-origin');
|
|
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', `/api/public-cv/${encodedSlug}/pdf`);
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('a held public CV read has a named page and loading announcement without a document or download', async () => {
|
|
const read = deferred<any>();
|
|
mockedApi.get.mockImplementationOnce(() => read.promise);
|
|
renderPage();
|
|
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toBeInTheDocument();
|
|
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
|
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
|
await act(async () => { read.resolve({ data: { html: '<p>Current public document</p>', name: 'Ada' } }); });
|
|
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current public document</p>');
|
|
});
|
|
|
|
test('a revoked or missing public slug shows an unavailable state without retry or download', async () => {
|
|
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } });
|
|
renderPage();
|
|
expect(await screen.findByText('CV unavailable')).toBeInTheDocument();
|
|
expect(screen.getByRole('alert')).toHaveTextContent(/link|owner|private/i);
|
|
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
|
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('a transient failure retries only when requested and keeps focus on the page heading during its held read', async () => {
|
|
const retry = deferred<any>();
|
|
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockImplementationOnce(() => retry.promise);
|
|
renderPage();
|
|
expect(await screen.findByText('Could not load CV')).toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
|
const retryButton = screen.getByRole('button', { name: 'Retry' });
|
|
retryButton.focus();
|
|
fireEvent.click(retryButton);
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
|
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toHaveFocus();
|
|
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
|
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
|
await act(async () => { retry.resolve({ data: { html: '<p>Recovered CV</p>', name: 'Recovered candidate' } }); });
|
|
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Recovered CV</p>');
|
|
expect(screen.queryByText('Could not load CV')).not.toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('changing public slugs immediately hides the previous document, name and PDF and resets its measured height', async () => {
|
|
const second = deferred<any>();
|
|
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate A</p>', name: 'Candidate A' } } as any).mockImplementationOnce(() => second.promise);
|
|
renderPage('/cv/slug-a');
|
|
const firstFrame = await screen.findByTitle('Public CV') as HTMLIFrameElement;
|
|
Object.defineProperty(firstFrame.contentDocument!.documentElement, 'scrollHeight', { configurable: true, value: 3000 });
|
|
fireEvent.load(firstFrame);
|
|
expect(firstFrame.style.height).toBe('3000px');
|
|
expect(document.title).toBe('Candidate A — CV');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
|
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
|
expect(document.title).not.toContain('Candidate A');
|
|
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
|
await act(async () => { second.resolve({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } }); });
|
|
const secondFrame = await screen.findByTitle('Public CV');
|
|
expect(secondFrame).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
|
expect(Number.parseFloat(secondFrame.style.height)).toBeCloseTo((297 / 25.4) * 96);
|
|
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
|
|
expect(document.title).toBe('Candidate B — CV');
|
|
});
|
|
|
|
test('a failed public slug cannot hide a subsequently loaded different public CV', async () => {
|
|
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } }).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
|
|
renderPage('/cv/slug-a');
|
|
await screen.findByText(/link.*wrong|wrong.*link/i);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
|
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
|
|
});
|
|
|
|
test.each(['success', 'failure'])('a late %s from the previous slug cannot replace the current CV or its title', async (outcome) => {
|
|
const previous = deferred<any>();
|
|
mockedApi.get.mockImplementationOnce(() => previous.promise).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
|
|
renderPage('/cv/slug-a');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
|
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
|
await act(async () => {
|
|
if (outcome === 'success') previous.resolve({ data: { html: '<p>Late candidate A</p>', name: 'Late candidate A' } });
|
|
else previous.reject({ response: { status: 404 } });
|
|
});
|
|
expect(screen.getByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
expect(document.title).toBe('Candidate B — CV');
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('language changes translate loading, failure and loaded chrome without refetching anonymous CV content', async () => {
|
|
const first = deferred<any>();
|
|
mockedApi.get.mockImplementationOnce(() => first.promise).mockResolvedValueOnce({ data: { html: '<p>Untranslated candidate facts</p>', name: 'Candidate' } } as any);
|
|
renderPage();
|
|
switchLanguage('nb');
|
|
expect(screen.getByRole('heading', { level: 1, name: 'Offentlig CV' })).toBeInTheDocument();
|
|
expect(screen.getByRole('status')).toHaveTextContent(/Laster/i);
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
|
await act(async () => { first.reject({ response: { status: 500 } }); });
|
|
expect(await screen.findByText('Kunne ikke laste CV')).toBeInTheDocument();
|
|
switchLanguage('en');
|
|
expect(screen.getByText('Could not load CV')).toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
|
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
|
|
switchLanguage('nb');
|
|
expect(screen.getByTitle('Offentlig CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
|
|
expect(screen.getByRole('link', { name: 'Last ned PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('the public page removes only its own noindex metadata and ignores a response after unmount', async () => {
|
|
const read = deferred<any>();
|
|
mockedApi.get.mockImplementationOnce(() => read.promise);
|
|
const unrelated = document.createElement('meta');
|
|
unrelated.name = 'robots';
|
|
unrelated.content = 'index, follow';
|
|
document.head.appendChild(unrelated);
|
|
try {
|
|
const page = renderPage();
|
|
const owned = Array.from(document.head.querySelectorAll('meta[name="robots"]')).find((item) => item !== unrelated)!;
|
|
expect(owned).toHaveAttribute('content', 'noindex, nofollow');
|
|
expect(unrelated).toHaveAttribute('content', 'index, follow');
|
|
page.unmount();
|
|
expect(owned).not.toBeInTheDocument();
|
|
expect(unrelated).toBeInTheDocument();
|
|
document.title = 'Destination after unmount';
|
|
await act(async () => { read.resolve({ data: { html: '<p>Late response</p>', name: 'Late candidate' } }); });
|
|
expect(document.title).toBe('Destination after unmount');
|
|
expect(unrelated).toHaveAttribute('content', 'index, follow');
|
|
} finally { unrelated.remove(); }
|
|
});
|
|
|
|
test('leaving a loaded public CV restores the prior page title when its candidate title is still current', async () => {
|
|
document.title = 'Jobs dashboard — Jobbjakt';
|
|
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
|
|
const page = renderPage();
|
|
await screen.findByTitle('Public CV');
|
|
expect(document.title).toBe('Candidate identity — CV');
|
|
page.unmount();
|
|
expect(document.title).toBe('Jobs dashboard — Jobbjakt');
|
|
});
|
|
|
|
test('leaving a loaded public CV preserves a page title already replaced by another owner', async () => {
|
|
document.title = 'Jobs dashboard — Jobbjakt';
|
|
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
|
|
const page = renderPage();
|
|
await screen.findByTitle('Public CV');
|
|
expect(document.title).toBe('Candidate identity — CV');
|
|
document.title = 'Destination page — Jobbjakt';
|
|
page.unmount();
|
|
expect(document.title).toBe('Destination page — Jobbjakt');
|
|
});
|
|
|
|
test('returning from A through B to A ignores both earlier held reads until the current A response arrives', async () => {
|
|
const originalA = deferred<any>();
|
|
const previousB = deferred<any>();
|
|
const currentA = deferred<any>();
|
|
mockedApi.get.mockImplementationOnce(() => originalA.promise)
|
|
.mockImplementationOnce(() => previousB.promise)
|
|
.mockImplementationOnce(() => currentA.promise);
|
|
renderPage('/cv/slug-a');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open A' }));
|
|
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/public-cv/slug-a', '/public-cv/slug-b', '/public-cv/slug-a']);
|
|
await act(async () => { originalA.resolve({ data: { html: '<p>Stale original A</p>', name: 'Stale original A' } }); });
|
|
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
|
expect(document.title).not.toContain('Stale original A');
|
|
await act(async () => { previousB.reject({ response: { status: 404 } }); });
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
|
await act(async () => { currentA.resolve({ data: { html: '<p>Current A document</p>', name: 'Current A candidate' } }); });
|
|
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current A document</p>');
|
|
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-a/pdf');
|
|
expect(document.title).toBe('Current A candidate — CV');
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(3);
|
|
});
|