Files
jobtrackingapp/job-tracker-ui/src/documents-page.test.tsx
T

334 lines
28 KiB
TypeScript

import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { api } from './api';
import { I18nProvider } from './i18n/I18nProvider';
import { translations, type UiLanguage } from './i18n/translations';
import DocumentsPage from './views/DocumentsPage';
const mockedApi = api as jest.Mocked<typeof api>;
const defaults = { page: 1, pageSize: 25, q: undefined, type: 'all', state: 'all', visibility: 'all', sort: 'date-desc' };
function copy(key: string, params: Record<string, string | number> = {}, language: UiLanguage = document.documentElement.lang === 'nb-NO' ? 'nb' : 'en') {
return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
}
function item(overrides: Record<string, unknown> = {}) {
return { id: 'cv:12', resourceId: '12', type: 'cv', name: 'Metadata CV ÆØÅ', state: 'current', visibility: 'public', jobApplicationId: 42, jobTitle: 'Recorded role', companyName: 'Nordlys AS', jobIsDeleted: false, recordedAt: '2026-02-13T11:30:00.0000000Z', dateKind: 'updated-utc', version: 4, historyCount: null, attachmentCount: null, cvVariantId: null, cvVariantName: null, cvVariantVersion: null, fileType: null, fileSize: null, purpose: null, useForAi: null, provider: null, sourceHref: '/career/builder/12', downloadHref: null, publicHref: '/cv/metadata-cv', hasCurrentContent: null, ...overrides };
}
function page(items: ReturnType<typeof item>[] = [item()], currentPage = 1, total = items.length) {
return { items, page: currentPage, pageSize: 25, total, totalPages: Math.ceil(total / 25) };
}
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 Probe() {
const location = useLocation();
const navigate = useNavigate();
return <><output data-testid="documents-location">{JSON.stringify({ pathname: location.pathname, search: location.search, hash: location.hash, state: location.state })}</output><button onClick={() => navigate(-1)}>History Back</button><button onClick={() => navigate('/away')}>Leave documents</button></>;
}
function renderPage(path = '/documents', state: unknown = null) {
const url = new URL(path, 'https://local.test');
return render(<I18nProvider><MemoryRouter initialEntries={[{ pathname: url.pathname, search: url.search, hash: url.hash, state }]}><Probe /><Routes><Route path="/documents" element={<DocumentsPage />} /><Route path="/away" element={<p>Other workflow</p>} /></Routes></MemoryRouter></I18nProvider>);
}
function location() { return JSON.parse(screen.getByTestId('documents-location').textContent!) as { pathname: string; search: string; hash: string; state: unknown }; }
function reads() { return mockedApi.get.mock.calls.filter(([url]) => url === '/documents'); }
function row(name: string) { return within(screen.getByText(name, { exact: true }).closest('tr, article') as HTMLElement); }
async function select(labelKey: string, optionKey: string) { fireEvent.mouseDown(screen.getByRole('combobox', { name: copy(labelKey) })); fireEvent.click(await screen.findByRole('option', { name: copy(optionKey) })); }
async function language(value: UiLanguage) { await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: value })); }); }
beforeEach(() => {
localStorage.clear();
document.documentElement.lang = 'en';
jest.clearAllMocks();
mockedApi.get.mockReset().mockImplementation((url: string) => url === '/documents'
? Promise.resolve({ data: page() } as never)
: Promise.reject(new Error(`Unexpected read ${url}`)));
for (const method of [mockedApi.post, mockedApi.put, mockedApi.patch, mockedApi.delete]) method.mockReset().mockRejectedValue(new Error('Unexpected document-index write'));
Object.defineProperty(URL, 'createObjectURL', { configurable: true, writable: true, value: jest.fn(() => 'blob:uploaded-file') });
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, writable: true, value: jest.fn() });
});
afterEach(() => {
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.patch).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
jest.restoreAllMocks();
});
test('[P7-07 documents] validates URL filters and reads one metadata page with literal Nordic search', async () => {
renderPage('/documents?q=100%25_%21+%C3%86%C3%98%C3%85&page=bad&type=unknown&state=unknown&visibility=unknown&sort=unknown&jobApplicationId=-2&keep=1#context');
expect(await screen.findByText('Metadata CV ÆØÅ', { exact: true })).toBeVisible();
expect(reads()).toHaveLength(1);
expect(reads()[0][1]?.params).toEqual({ ...defaults, q: '100%_! ÆØÅ' });
expect(screen.getByRole('textbox', { name: copy('documentsSearch') })).toHaveValue('100%_! ÆØÅ');
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
test('[P7-07 documents] server pagination and filter history preserve unknown query, hash and router state', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => Promise.resolve({ data: page([item({ name: `Page ${config.params.page} ${config.params.type}` })], config.params.page, 26) } as never));
const state = { from: '/jobs/42', draftContext: 'preserve-navigation-context' };
renderPage('/documents?page=2&jobApplicationId=42&keep=%C3%A6%26x#materials', state);
expect(await screen.findByText('Page 2 all')).toBeVisible();
expect(reads()[0][1]?.params).toEqual({ ...defaults, page: 2, jobApplicationId: 42 });
await select('documentsType', 'documentsTypeCv');
expect(await screen.findByText('Page 1 cv')).toBeVisible();
const current = location(); const query = new URLSearchParams(current.search);
expect(query.get('keep')).toBe('æ&x'); expect(query.get('jobApplicationId')).toBe('42'); expect(query.get('page')).not.toBe('2');
expect(current.hash).toBe('#materials'); expect(current.state).toEqual(state);
expect(reads().at(-1)?.[1]?.params).toEqual({ ...defaults, type: 'cv', jobApplicationId: 42 });
fireEvent.click(screen.getByRole('button', { name: 'History Back' }));
expect(await screen.findByText('Page 2 all')).toBeVisible();
expect(reads().at(-1)?.[1]?.params).toEqual({ ...defaults, page: 2, jobApplicationId: 42 });
});
test('[P7-07 documents] literal search all remains free text and preserves unrelated navigation context', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => Promise.resolve({ data: page([item({ name: config.params.q === 'all' ? 'Literal all metadata' : 'Initial metadata' })]) } as never));
const state = { from: '/jobs/42', draftContext: 'preserve-literal-search-context' };
renderPage('/documents?keep=%C3%A6%26x#materials', state);
await screen.findByText('Initial metadata');
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
fireEvent.change(search, { target: { value: 'all' } });
expect(search).toHaveValue('all');
expect(await screen.findByText('Literal all metadata')).toBeVisible();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents', '/documents']);
expect(reads()[1][1]?.params).toEqual({ ...defaults, q: 'all' });
const current = location(); const query = new URLSearchParams(current.search);
expect(query.get('q')).toBe('all'); expect(query.get('keep')).toBe('æ&x');
expect(current.hash).toBe('#materials'); expect(current.state).toEqual(state);
});
test('[P7-07 documents] a batched sort selection and immediate search preserve both filters and navigation context', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => Promise.resolve({ data: page([item({ name: config.params.q === 'Indexed' && config.params.sort === 'name-asc' ? 'Indexed sorted metadata' : 'Initial metadata' })]) } as never));
const state = { from: '/jobs/42', draftContext: 'preserve-rapid-filter-context' };
renderPage('/documents?keep=%C3%A6%26x#context', state);
await screen.findByText('Initial metadata');
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
fireEvent.mouseDown(screen.getByRole('combobox', { name: copy('documentsSort') }));
const option = await screen.findByRole('option', { name: copy('documentsSortNameAsc') });
act(() => { fireEvent.click(option); fireEvent.change(search, { target: { value: 'Indexed' } }); });
const current = location(); const query = new URLSearchParams(current.search);
expect(query.get('sort')).toBe('name-asc'); expect(query.get('q')).toBe('Indexed');
expect(query.get('keep')).toBe('æ&x'); expect(current.hash).toBe('#context'); expect(current.state).toEqual(state);
expect(await screen.findByText('Indexed sorted metadata')).toBeVisible();
expect(reads().at(-1)?.[1]?.params).toEqual({ ...defaults, q: 'Indexed', sort: 'name-asc' });
expect(mockedApi.get.mock.calls.every(([url]) => url === '/documents')).toBe(true);
fireEvent.click(screen.getByRole('button', { name: 'History Back' }));
await waitFor(() => {
const restored = new URLSearchParams(location().search);
expect(restored.get('q')).toBeNull(); expect(restored.get('sort')).toBeNull();
expect(reads().at(-1)?.[1]?.params).toEqual(defaults);
});
expect(await screen.findByText('Initial metadata')).toBeVisible();
fireEvent.change(screen.getByRole('textbox', { name: copy('documentsSearch') }), { target: { value: 'After back' } });
await waitFor(() => expect(reads().at(-1)?.[1]?.params).toEqual({ ...defaults, q: 'After back' }));
const afterBack = location(); const afterBackQuery = new URLSearchParams(afterBack.search);
expect(afterBackQuery.get('q')).toBe('After back'); expect(afterBackQuery.get('sort')).toBeNull();
expect(afterBackQuery.get('keep')).toBe('æ&x'); expect(afterBack.hash).toBe('#context'); expect(afterBack.state).toEqual(state);
expect(screen.getByRole('textbox', { name: copy('documentsSearch') })).toHaveValue('After back');
});
test('[P7-07 documents] incompatible pending and failed queries hide old actions and late results cannot replace current metadata', async () => {
const old = deferred<any>();
mockedApi.get.mockImplementation((_url: string, config?: any) => config.params.q === 'old' ? old.promise
: config.params.q === 'failed' ? Promise.reject(new Error('offline'))
: Promise.resolve({ data: page([item({ name: config.params.q === 'new' ? 'New owned metadata' : 'Initial metadata' })]) }) as never);
renderPage(); await screen.findByText('Initial metadata');
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
fireEvent.change(search, { target: { value: 'old' } });
await waitFor(() => expect((reads().at(-1)?.[1]?.params as Record<string, unknown> | undefined)?.q).toBe('old'));
expect(screen.queryByText('Initial metadata')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: copy('documentsOpenSource') })).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'new' } });
expect(await screen.findByText('New owned metadata')).toBeVisible();
await act(async () => old.resolve({ data: page([item({ name: 'Obsolete private metadata' })]) }));
expect(screen.queryByText('Obsolete private metadata')).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'failed' } });
expect(await screen.findByRole('alert')).toHaveTextContent(copy('documentsLoadFailed'));
expect(screen.queryByText('New owned metadata')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: copy('documentsOpenSource') })).not.toBeInTheDocument();
});
test('[P7-07 documents] Retry is one guarded GET and a completion after leaving cannot publish or start follow-up work', async () => {
const pending = deferred<any>();
mockedApi.get.mockRejectedValueOnce(new Error('offline')).mockImplementation(() => pending.promise);
renderPage('/documents?q=owned&type=attachment');
expect(await screen.findByRole('alert')).toHaveTextContent(copy('documentsLoadFailed'));
const retry = screen.getByRole('button', { name: copy('retry') });
act(() => { fireEvent.click(retry); fireEvent.click(retry); });
await waitFor(() => expect(reads()).toHaveLength(2));
expect(reads()[1][1]?.params).toEqual({ ...defaults, q: 'owned', type: 'attachment' });
fireEvent.click(screen.getByRole('button', { name: 'Leave documents' }));
await screen.findByText('Other workflow');
await act(async () => pending.resolve({ data: page([item({ name: 'Late document result' })]) }));
expect(screen.queryByText('Late document result')).not.toBeInTheDocument();
expect(reads()).toHaveLength(2); expect(URL.createObjectURL).not.toHaveBeenCalled();
});
test('[P7-07 documents] a true empty index, filtered no-match and request failure remain distinct', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => config.params.q === 'offline'
? Promise.reject(new Error('offline')) : Promise.resolve({ data: page([]) }) as never);
renderPage();
expect(await screen.findByText(copy('documentsEmpty'))).toBeVisible();
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
fireEvent.change(search, { target: { value: 'no match æøå' } });
expect(await screen.findByText(copy('documentsNoMatches'))).toBeVisible();
expect(screen.queryByText(copy('documentsEmpty'))).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'offline' } });
expect(await screen.findByRole('alert')).toHaveTextContent(copy('documentsLoadFailed'));
expect(screen.queryByText(copy('documentsEmpty'))).not.toBeInTheDocument();
expect(screen.queryByText(copy('documentsNoMatches'))).not.toBeInTheDocument();
});
test('[P7-07 documents] English and Bokmål presentation changes preserve query context without a metadata refetch', async () => {
renderPage('/documents?q=%C3%A6%C3%B8%C3%A5&state=current&visibility=public&keep=one#context', { draft: 'unsaved-source-context' });
await screen.findByText('Metadata CV ÆØÅ'); const before = location(); const count = reads().length;
expect(screen.getByRole('textbox', { name: copy('documentsSearch') })).toHaveValue('æøå');
await language('nb');
expect(screen.getByRole('textbox', { name: copy('documentsSearch', {}, 'nb') })).toHaveValue('æøå');
expect(row('Metadata CV ÆØÅ').getByText(copy('documentsCurrent', {}, 'nb'))).toBeVisible();
expect(row('Metadata CV ÆØÅ').getByRole('link', { name: copy('documentsOpenSource', {}, 'nb') })).toHaveAttribute('href', '/career/builder/12');
await language('en');
expect(screen.getByRole('textbox', { name: copy('documentsSearch', {}, 'en') })).toHaveValue('æøå');
expect(location()).toEqual(before); expect(reads()).toHaveLength(count);
});
test('[P7-07 documents] an out-of-range page offers explicit first-page recovery without claiming an empty index', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => Promise.resolve({ data: config.params.page === 999 ? page([], 999, 1) : page([item()]) } as never));
const state = { from: '/jobs/42', draftContext: 'preserve-out-of-range-context' };
renderPage('/documents?page=999&keep=%C3%A6%26x#materials', state);
expect(await screen.findByText(copy('documentsPageEmpty'))).toBeVisible();
expect(screen.getByText(copy('documentsPageEmptyHelp'))).toBeVisible();
expect(screen.queryByText(copy('documentsEmpty'))).not.toBeInTheDocument();
expect(screen.queryByText(copy('documentsNoMatches'))).not.toBeInTheDocument();
expect(reads()).toHaveLength(1);
expect(reads()[0][1]?.params).toEqual({ ...defaults, page: 999 });
fireEvent.click(screen.getByRole('button', { name: copy('documentsFirstPage') }));
expect(await screen.findByText('Metadata CV ÆØÅ')).toBeVisible();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents', '/documents']);
expect(reads()[1][1]?.params).toEqual(defaults);
const current = location(); const query = new URLSearchParams(current.search);
expect(query.get('page')).not.toBe('999'); expect(query.get('keep')).toBe('æ&x');
expect(current.hash).toBe('#materials'); expect(current.state).toEqual(state);
});
test('[P7-07 documents] seven resource kinds retain exact source actions, frozen provenance and honest date labels', async () => {
const letter = item({ id: 'cover-letter:42', resourceId: '42', type: 'cover-letter', name: 'Versions-only letter', state: 'draft', visibility: null, dateKind: 'history-recorded-utc', version: 3, historyCount: 3, sourceHref: '/jobs/42?section=application&sub=letter', publicHref: null, hasCurrentContent: false });
const material = item({ id: 'application-material:42', resourceId: '42', type: 'application-material', name: 'Application answers', state: 'draft', visibility: null, recordedAt: null, dateKind: 'unknown', version: null, sourceHref: '/jobs/42?section=application&sub=answers', publicHref: null });
const tailored = item({ id: 'tailored-cv:42', resourceId: '42', type: 'tailored-cv', name: 'Tailored CV text', state: 'draft', visibility: null, recordedAt: null, dateKind: 'unknown', version: null, sourceHref: '/jobs/42?section=application&sub=cv', publicHref: null });
const upload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Certificate æøå.pdf', visibility: null, recordedAt: '2026-02-12T09:15:00.0000000', dateKind: 'uploaded-local', version: null, fileType: 'application/pdf', fileSize: 1234, purpose: 'Certificate', useForAi: false, sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null });
const frozen = item({ id: 'submitted-package:86', resourceId: '86', type: 'submitted-package', name: 'Captured package', state: 'frozen', visibility: null, dateKind: 'captured-utc', version: 9, attachmentCount: 2, cvVariantId: 3, cvVariantName: 'Captured CV', cvVariantVersion: 2, jobIsDeleted: true, sourceHref: '/jobs/42?section=application&sub=submission', publicHref: null });
const email = item({ id: 'email-draft:11111111-1111-4111-8111-111111111111', resourceId: '11111111-1111-4111-8111-111111111111', type: 'email-draft', name: 'Saved email draft', state: 'draft', visibility: null, provider: 'gmail', version: 6, sourceHref: '/correspondence', publicHref: null });
mockedApi.get.mockResolvedValue({ data: page([item(), letter, material, tailored, upload, frozen, email]) } as never);
renderPage(); await screen.findByText('Metadata CV ÆØÅ');
for (const entry of [item(), letter, material, tailored, upload]) expect(row(entry.name).getByRole('link', { name: copy('documentsOpenSource') })).toHaveAttribute('href', entry.sourceHref);
expect(row('Metadata CV ÆØÅ').getByRole('link', { name: copy('documentsOpenPublicCv') })).toHaveAttribute('href', '/cv/metadata-cv');
expect(row(letter.name).getByText(copy('documentsHistoryOnly'))).toBeVisible();
expect(row(letter.name).getByText(copy('documentsHistoryVersions', { count: 3 }))).toBeVisible();
expect(row(upload.name).getByText(copy('documentsUploadedLocal'), { exact: false })).toBeVisible();
expect(row(material.name).getByText(copy('documentsDateUnknown'))).toBeVisible();
expect(row(tailored.name).getByText(copy('documentsDateUnknown'))).toBeVisible();
expect(row(frozen.name).getByText(copy('documentsFrozen'))).toBeVisible();
expect(row(frozen.name).getByText(copy('documentsCapturedCv', { name: 'Captured CV', version: 2 }))).toBeVisible();
expect(row(frozen.name).getByText(copy('documentsDeletedJob'))).toBeVisible();
expect(row(frozen.name).getByRole('link', { name: copy('documentsOpenSubmissionHistory') })).toHaveAttribute('href', frozen.sourceHref);
expect(row(email.name).getByRole('link', { name: copy('documentsOpenCorrespondence') })).toHaveAttribute('href', '/correspondence');
expect(row(email.name).queryByRole('button', { name: /resume|send/i })).not.toBeInTheDocument();
expect(screen.getByText(copy('documentsDateOrderHelp'))).toBeVisible();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
test('[P7-07 documents] attachment download is explicit, API-relative and single-flight with original MIME, filename and URL disposal', async () => {
const upload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Certificate æøå.pdf', visibility: null, fileType: 'application/pdf', sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null });
const pending = deferred<any>(); const bytes = new Blob(['%PDF uploaded bytes'], { type: 'application/pdf' }); const clicks: Array<{ href: string; filename: string }> = [];
jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { clicks.push({ href: this.href, filename: this.download }); });
mockedApi.get.mockImplementation((url: string) => url === '/documents' ? Promise.resolve({ data: page([upload]) }) : url === '/attachments/download/9' ? pending.promise : Promise.reject(new Error(`Unexpected read ${url}`)) as never);
renderPage(); await screen.findByText(upload.name);
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
const download = row(upload.name).getByRole('button', { name: copy('documentsDownloadAttachment') });
act(() => { fireEvent.click(download); fireEvent.click(download); });
expect(mockedApi.get.mock.calls.filter(([url]) => url === '/attachments/download/9')).toHaveLength(1);
expect(mockedApi.get).toHaveBeenLastCalledWith('/attachments/download/9', expect.objectContaining({ responseType: 'blob' }));
await act(async () => pending.resolve({ data: bytes, headers: { 'content-type': 'application/pdf' } }));
await waitFor(() => expect(clicks).toEqual([{ href: 'blob:uploaded-file', filename: upload.name }]));
const downloaded = (URL.createObjectURL as jest.Mock).mock.calls[0][0] as Blob;
expect(downloaded.type).toBe('application/pdf'); expect(downloaded.size).toBe(bytes.size);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:uploaded-file'); expect(reads()).toHaveLength(1);
await waitFor(() => expect(download).toHaveFocus());
});
test('[P7-07 documents] a completed download preserves deliberate Search focus instead of restoring the old button', async () => {
const upload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Focus-safe uploaded file.pdf', visibility: null, fileType: 'application/pdf', sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null });
const pending = deferred<any>(); const bytes = new Blob(['%PDF uploaded bytes'], { type: 'application/pdf' });
const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
mockedApi.get.mockImplementation((url: string) => url === '/documents' ? Promise.resolve({ data: page([upload]) }) : url === '/attachments/download/9' ? pending.promise : Promise.reject(new Error(`Unexpected read ${url}`)) as never);
renderPage(); await screen.findByText(upload.name);
const download = row(upload.name).getByRole('button', { name: copy('documentsDownloadAttachment') });
fireEvent.click(download);
expect(screen.getByRole('region', { name: copy('documentsYourDocuments') })).toHaveFocus();
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
act(() => search.focus()); expect(search).toHaveFocus();
await act(async () => pending.resolve({ data: bytes, headers: { 'content-type': 'application/pdf' } }));
await waitFor(() => expect(download).toBeEnabled());
expect(click).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalledWith(bytes);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:uploaded-file');
expect(search).toHaveFocus();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents', '/attachments/download/9']);
});
test('[P7-07 documents] download errors retry only that file and late download completion after leaving creates no URL', async () => {
const upload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Private uploaded file.pdf', visibility: null, fileType: 'application/pdf', sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null });
const pending = deferred<any>(); let attempts = 0;
mockedApi.get.mockImplementation((url: string) => url === '/documents' ? Promise.resolve({ data: page([upload]) }) : url === '/attachments/download/9'
? ++attempts === 1 ? Promise.reject({ response: { status: 403 } }) : pending.promise : Promise.reject(new Error(`Unexpected read ${url}`)) as never);
renderPage(); await screen.findByText(upload.name);
fireEvent.click(row(upload.name).getByRole('button', { name: copy('documentsDownloadAttachment') }));
expect(await row(upload.name).findByRole('alert')).toHaveTextContent(copy('documentsDownloadFailed'));
await language('nb');
const error = row(upload.name).getByRole('alert'); expect(error).toHaveTextContent(copy('documentsDownloadFailed', {}, 'nb'));
const retry = within(error).getByRole('button', { name: copy('retry', {}, 'nb') });
act(() => { fireEvent.click(retry); fireEvent.click(retry); });
expect(attempts).toBe(2); expect(reads()).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Leave documents' })); await screen.findByText('Other workflow');
await act(async () => pending.resolve({ data: new Blob(['private file'], { type: 'application/pdf' }) }));
expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(URL.revokeObjectURL).not.toHaveBeenCalled(); expect(reads()).toHaveLength(1);
});
test('[P7-07 documents] a published historical CV retains its percent-encoded Nordic public slug without fetching its body', async () => {
const historical = item({ name: 'Historical published CV', publicHref: '/cv/legacy-%C3%A6%C3%B8%C3%A5%20cv' });
mockedApi.get.mockResolvedValue({ data: page([historical]) } as never);
renderPage(); await screen.findByText(historical.name);
expect(row(historical.name).getByRole('link', { name: copy('documentsOpenPublicCv') })).toHaveAttribute('href', historical.publicHref);
expect(row(historical.name).getByRole('link', { name: copy('documentsOpenSource') })).toHaveAttribute('href', '/career/builder/12');
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
test('[P7-07 documents] malformed external and mismatched resource paths never become source, public or download actions', async () => {
const invalidCv = item({ name: 'Unsafe external CV', sourceHref: 'https://external.test/career/builder/12', publicHref: 'javascript:alert(1)' });
const wrongId = item({ id: 'cv:13', resourceId: '13', name: 'Mismatched CV source', sourceHref: '/career/builder/999', publicHref: '//external.test/cv/other' });
const invalidUpload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Mismatched file', visibility: null, sourceHref: '/jobs/99?section=documents', downloadHref: '/api/attachments/download/999', publicHref: null });
mockedApi.get.mockResolvedValue({ data: page([invalidCv, wrongId, invalidUpload]) } as never);
renderPage(); await screen.findByText(invalidCv.name);
for (const entry of [invalidCv, wrongId, invalidUpload]) {
expect(row(entry.name).queryByRole('link', { name: copy('documentsOpenSource') })).not.toBeInTheDocument();
expect(row(entry.name).queryByRole('link', { name: copy('documentsOpenPublicCv') })).not.toBeInTheDocument();
expect(row(entry.name).queryByRole('button', { name: copy('documentsDownloadAttachment') })).not.toBeInTheDocument();
}
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
expect(URL.createObjectURL).not.toHaveBeenCalled();
});