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; const originalInnerWidth = window.innerWidth; const originalTitle = document.title; function RouteControls() { const navigate = useNavigate(); return <>; } function renderPage(path = '/cv/public-slug') { return render( } /> , ); } 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 }; } 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: '

Public CV

', name: 'Ada Lovelace' } } as any); renderPage(); const frame = await screen.findByTitle('Public CV'); expect(frame).toHaveAttribute('srcdoc', '

Public CV

'); 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(); 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: '

Encoded slug document

', name: 'Candidate' } }); }); expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '

Encoded slug document

'); 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(); 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: '

Current public document

', name: 'Ada' } }); }); expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '

Current public document

'); }); 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(); 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: '

Recovered CV

', name: 'Recovered candidate' } }); }); expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '

Recovered CV

'); 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(); mockedApi.get.mockResolvedValueOnce({ data: { html: '

Candidate A

', 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: '

Candidate B

', name: 'Candidate B' } }); }); const secondFrame = await screen.findByTitle('Public CV'); expect(secondFrame).toHaveAttribute('srcdoc', '

Candidate B

'); 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: '

Candidate B

', 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', '

Candidate B

'); 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(); mockedApi.get.mockImplementationOnce(() => previous.promise).mockResolvedValueOnce({ data: { html: '

Candidate B

', 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', '

Candidate B

'); await act(async () => { if (outcome === 'success') previous.resolve({ data: { html: '

Late candidate A

', name: 'Late candidate A' } }); else previous.reject({ response: { status: 404 } }); }); expect(screen.getByTitle('Public CV')).toHaveAttribute('srcdoc', '

Candidate B

'); 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(); mockedApi.get.mockImplementationOnce(() => first.promise).mockResolvedValueOnce({ data: { html: '

Untranslated candidate facts

', 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', '

Untranslated candidate facts

'); switchLanguage('nb'); expect(screen.getByTitle('Offentlig CV')).toHaveAttribute('srcdoc', '

Untranslated candidate facts

'); 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(); 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: '

Late response

', 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: '

Candidate document

', 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: '

Candidate document

', 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(); const previousB = deferred(); const currentA = deferred(); 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: '

Stale original A

', 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: '

Current A document

', name: 'Current A candidate' } }); }); expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '

Current A document

'); 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); });