feat(documents): add owner-scoped document workspace

This commit is contained in:
cesnimda
2026-10-16 18:22:19 +02:00
parent 8e4db121ae
commit 5a67ec3d3d
19 changed files with 4932 additions and 9 deletions
+132
View File
@@ -0,0 +1,132 @@
import AxeBuilder from '@axe-core/playwright';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import { emptyStructuredCv } from '../src/profileCv';
import type { CvVariant } from '../src/cvBuilder';
import type { CoverLetter, SubmittedPackage, SubmittedPackageDetail } from '../src/applicationWorkspace';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const privateMarker = 'PRIVATE_DOCUMENT_BODY_NOT_INDEX_METADATA';
type Owner = { id: string; headers: Record<string, string> };
type Job = { id: number; companyId: number; jobTitle: string };
type Email = { id: string; subject: string; bodyText: string; clientRequestId: string; revision: number };
type Kind = 'cv' | 'cover-letter' | 'application-material' | 'tailored-cv' | 'attachment' | 'submitted-package' | 'email-draft';
type Item = { id: string; resourceId: string; type: Kind; name: string; state: string; visibility: string | null; jobApplicationId: number | null; jobTitle: string | null; companyName: string | null; jobIsDeleted: boolean | null; recordedAt: string | null; dateKind: string; version: number | null; historyCount: number | null; attachmentCount: number | null; cvVariantId: number | null; cvVariantName: string | null; cvVariantVersion: number | null; fileType: string | null; fileSize: number | null; purpose: string | null; useForAi: boolean | null; provider: string | null; sourceHref: string; downloadHref: string | null; publicHref: string | null; hasCurrentContent: boolean | null };
type Documents = { items: Item[]; page: number; pageSize: number; total: number; totalPages: number };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type RequestRecord = { method: string; path: string; query: Record<string, string>; body: unknown };
type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const itemFields = ['id', 'resourceId', 'type', 'name', 'state', 'visibility', 'jobApplicationId', 'jobTitle', 'companyName', 'jobIsDeleted', 'recordedAt', 'dateKind', 'version', 'historyCount', 'attachmentCount', 'cvVariantId', 'cvVariantName', 'cvVariantVersion', 'fileType', 'fileSize', 'purpose', 'useForAi', 'provider', 'sourceHref', 'downloadHref', 'publicHref', 'hasCurrentContent'].sort();
const typeKeys: Record<Kind, string> = { cv: 'documentsTypeCv', 'cover-letter': 'documentsTypeLetter', 'application-material': 'documentsTypeMaterial', 'tailored-cv': 'documentsTypeTailoredCv', attachment: 'documentsTypeAttachment', 'submitted-package': 'documentsTypePackage', 'email-draft': 'documentsTypeEmailDraft' };
const boundary = 'Actual Program/local Identity cookie sessions, CSRF-protected existing source writes and owner Documents GET use disposable isolated SQLite. Setup creates seven genuine existing resource kinds; an explicit setup submitted-package capture invokes the established CV renderer once to create its immutable snapshot. Documents listing itself must issue no source-body/render/preview/PDF/export/AI/provider/send request or mutation. Existing source editors own their own reads, errors, dirty guards and saves after deliberate navigation. Attachment download is an explicit authenticated existing GET; controlled precommit503 qualifies read recovery, not uncertain delivery. Frozen package metadata/archive bytes are independently read after current CV/upload deletion. No provider grant/send/sync/import/model, raw SQL/schema, storage relocation, production/main/deployment or Phase8 action. Free accounts and disabled workers remain mandatory; external/model/delivery browser requests are blocked and recorded without credentials/CSRF/session headers. Exact27-field metadata and finite all-enabled axe/target-size vectors retain every incomplete node. UI evidence does not establish internal SQL/service-call counts, live MariaDB, human screen reader, physical touch/software keyboard, real zoom, production fonts/photos, provider qualification or production readiness. Correspondence opens its existing context without exact-ID Resume; Submission opens history without automatically selecting a package; CV PDF stays the existing explicit export operation.';
function copy(key: string, params: Record<string, string | number> = {}, language: Sample['language'] = 'en') { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
function waitResponse(page: Page, method: string, path: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === path); }
function count(session: Session, method: string, path: string) { return session.requests.filter(value => value.method === method && value.path === path); }
function writes(session: Session) { return session.requests.filter(value => value.method !== 'GET'); }
function indexPath(page: Page) { const url = new URL(page.url()); return url.pathname + url.search + url.hash; }
function results(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: copy('documentsYourDocuments', {}, language), exact: true }); }
function rows(page: Page, language: Sample['language'] = 'en') { return results(page, language).locator('tbody tr, article'); }
function row(page: Page, item: Pick<Item, 'name' | 'type'>, language: Sample['language'] = 'en') { const type = copy(typeKeys[item.type], {}, language).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return rows(page, language).filter({ has: page.getByText(item.name, { exact: true }) }).filter({ has: page.getByText(new RegExp(`^${type}(?:\\s*·|$)`)) }); }
function search(page: Page, language: Sample['language'] = 'en') { return page.getByRole('textbox', { name: copy('documentsSearch', {}, language), exact: true }); }
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
async function select(page: Page, label: string, option: string, language: Sample['language'] = 'en') { await keyboard(page, page.getByRole('combobox', { name: copy(label, {}, language), exact: true })); await keyboard(page, page.getByRole('option', { name: copy(option, {}, language), exact: true })); }
async function goPage(page: Page, number: number, language: Sample['language'] = 'en') { await keyboard(page, page.getByRole('navigation', { name: copy('documentsPages', {}, language), exact: true }).getByRole('button', { name: copy('documentsGoToPage', { page: number }, language), exact: true })); }
function metadata(data: Documents) { expect(Object.keys(data).sort()).toEqual(['items', 'page', 'pageSize', 'total', 'totalPages'].sort()); for (const item of data.items) expect(Object.keys(item).sort()).toEqual(itemFields); expect(JSON.stringify(data)).not.toContain(privateMarker); return data; }
async function readIndex(api: APIRequestContext, params: Record<string, string | number> = {}) { return metadata(await json<Documents>(await api.get(`${apiUrl}/documents`, { params: { page: 1, pageSize: 25, ...params } }))); }
function noImplicitSourceWork(session: Session) { expect(writes(session)).toEqual([]); expect(session.requests.filter(value => /\/api\/(?:attachments\/download|email\/drafts|email\/send|gmail|ai|career\/profile|cv\/variants\/\d+|jobapplications\/\d+\/)/.test(value.path))).toEqual([]); }
async function login(api: APIRequestContext, email: string): Promise<Owner> { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`)); const token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); return { id: me.id, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; }
async function account(admin: APIRequestContext, owner: Owner) { const email = `p707-ui-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email; }
const test = base.extend<{ session: Session }>({
session: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = [];
page.on('request', request => { const url = new URL(request.url()); if (/^\/api\/(?:documents|attachments|cv|career|email|gmail|ai|correspondence|jobapplications)(?:\/|$)/.test(url.pathname)) requests.push({ method: request.method(), path: url.pathname, query: Object.fromEntries(url.searchParams), body: request.postData() ? request.postDataJSON() : null }); });
await context.route('**/*', route => { const url = new URL(route.request().url()); if (/^\/api\/(?:email\/send|gmail|ai)(?:\/|$)/.test(url.pathname) || !['localhost', '127.0.0.1'].includes(url.hostname) && !['blob:', 'data:', 'about:'].includes(url.protocol)) { blocked.push(url.origin + url.pathname); return route.abort(); } return route.continue(); });
try { const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await account(admin, administrator)); expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false); await provide({ owner, administrator, admin, requests }); expect(requests.filter(value => /\/api\/(?:email\/send|gmail|ai)(?:\/|$)/.test(value.path))).toEqual([]); }
finally { await info.attach('documents-ui-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalOrModelDeliveryRequests: blocked }), contentType: 'application/json' }); await admin.dispose(); }
},
});
async function job(api: APIRequestContext, owner: Owner, title = 'Recorded document role — æøå', company?: number) { const companyId = company ?? (await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded Documents company — æøå' } }), 201)).id; return json<Job>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: title, status: 'Saved', notes: 'Ordinary notes stay separate.', description: 'Recorded platform engineering vacancy.' } }), 201); }
async function cv(api: APIRequestContext, owner: Owner, name: string) { return json<CvVariant>(await api.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name, settings: { themeId: 'minimal', language: 'nb-NO', showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] } } })); }
async function email(api: APIRequestContext, owner: Owner, parent: Job, subject: string) { return json<Email>(await api.post(`${apiUrl}/email/drafts`, { headers: owner.headers, data: { jobApplicationId: parent.id, provider: 'gmail', to: 'private-document-recipient@example.test', subject, bodyText: privateMarker, threadId: 'private-document-thread' } }), 201); }
async function upload(api: APIRequestContext, owner: Owner, parent: Job, name = 'Recorded Documents attachment — æøå.txt') {
const bytes = Buffer.from(`${privateMarker}\nExact owned file bytes — æøå\n`, 'utf8'); expect((await api.post(`${apiUrl}/attachments`, { headers: owner.headers, multipart: { jobId: String(parent.id), files: { name, mimeType: 'text/plain', buffer: bytes } } })).ok()).toBe(true); const attachment = (await json<{ id: number; fileName: string }[]>(await api.get(`${apiUrl}/attachments/${parent.id}`))).find(value => value.fileName === name)!; expect(attachment.id).toBeGreaterThan(0); expect((await api.patch(`${apiUrl}/attachments/${attachment.id}`, { headers: owner.headers, data: { useForAi: false } })).status()).toBe(204); return { attachment, bytes, name };
}
async function seven(page: Page, owner: Owner, long = false) {
const empty = emptyStructuredCv(); await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: { ...empty, contact: { ...empty.contact, fullName: 'Åse Recorded Documents', headline: 'Owner-reviewed engineering evidence' }, summary: [privateMarker], skills: ['C#', 'SQL'] }, cvText: privateMarker } }));
const parent = await job(page.request, owner, long ? 'Langvarig plattformutvikling for samfunnskritiske tjenester og internasjonale samarbeidspartnere Æ Ø Å' : undefined); const variant = await cv(page.request, owner, long ? 'CV med lang dokumenttittel om samfunnskritisk plattformutvikling og inkluderende arbeidsprosesser Æ Ø Å' : 'Recorded current and captured CV — æøå'); await json(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cv`, { headers: owner.headers, data: { variantId: variant.id } })); const published = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${variant.id}/public`, { headers: owner.headers, data: { isPublic: true } }));
const letter = `${privateMarker}\nOriginal reviewed cover letter — æøå`; await json(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cover-letter`, { headers: owner.headers, data: { text: letter, source: 'manual' } })); expect((await page.request.put(`${apiUrl}/jobapplications/${parent.id}/application-drafts`, { headers: owner.headers, data: { applicationAnswerDraft: `${privateMarker} answer`, recruiterMessageDraft: `${privateMarker} recruiter draft` } })).status()).toBe(204); expect((await page.request.put(`${apiUrl}/jobapplications/${parent.id}/tailored-cv`, { headers: owner.headers, data: { tailoredCvText: `${privateMarker} manually tailored CV` } })).status()).toBe(204);
const file = await upload(page.request, owner, parent, long ? 'Dokumentasjon av samfunnskritiske tjenester og inkluderende arbeidsprosesser med lange dokumentnavn Æ Ø Å.txt' : undefined); const draft = await email(page.request, owner, parent, long ? 'Lagret e-postutkast om samfunnskritiske tjenester og internasjonalt samarbeid Æ Ø Å' : 'Recorded saved email draft — æøå'); const preserved = await json<SubmittedPackage>(await page.request.post(`${apiUrl}/jobapplications/${parent.id}/submitted-packages`, { headers: owner.headers })); const items = (await readIndex(page.request)).items; expect(items.map(value => value.type).sort()).toEqual(Object.keys(typeKeys).sort()); return { parent, variant: published, letter, draft, preserved, file, items };
}
async function enter(page: Page, owner: Owner, sample: Sample = desktop, path = '/documents?source=phase7-documents&keep=%C3%A6%26x&followMode=waiting-update#library') {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); await page.addInitScript(({ language, theme }) => { if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language); if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path);
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') })); if (stored.language !== sample.language || stored.theme !== sample.theme) { await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload(); }
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme); await expect(page.getByRole('heading', { level: 1, name: copy('documentsTitle', {}, sample.language), exact: true })).toBeVisible(); await expect(results(page, sample.language)).toBeVisible();
}
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }); const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), focused: { tag: document.activeElement?.tagName, role: document.activeElement?.getAttribute('role'), name: document.activeElement?.getAttribute('aria-label') }, overflowElements: [...document.querySelectorAll('body *')].filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden' && !node.closest('[hidden],[aria-hidden="true"]')).map(node => { const rect = node.getBoundingClientRect(); return { tag: node.tagName, text: node.textContent?.slice(0, 80), left: rect.left, right: rect.right, width: rect.width }; }).filter(node => node.width > 0 && (node.left < -1 || node.right > innerWidth + 1)).slice(0, 20) })); const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p707-documents-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, boundary, ariaSnapshot: await page.locator('body').ariaSnapshot(), axe: { scope: 'All default-enabled rules plus target-size, no exclusions', testEngine: axe.testEngine, testEnvironment: axe.testEnvironment, toolOptions: axe.toolOptions, violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Finite local Chromium states; not the full width/language/theme/state cross product.', 'All incomplete nodes retained for triage; no human AT, physical device/software keyboard, real zoom, precise screenshot contrast or production font/photo acceptance.', 'Full-page PNG can be resized and sticky elements captured out of viewport; focus viewport images are separate.', 'Actual MariaDB, model/provider/delivery and production release remain unverified.'] }), contentType: 'application/json' }); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]);
}
test('[P7-07 Documents] seven owned metadata kinds retain honest source actions and frozen provenance after current source deletion', async ({ page, session }, info) => {
test.setTimeout(180_000); const seeded = await seven(page, session.owner); await enter(page, session.owner); await expect(rows(page)).toHaveCount(7); noImplicitSourceWork(session); expect(count(session, 'GET', '/api/jobapplications/choices')).toHaveLength(0);
for (const item of seeded.items) { const body = row(page, item); await expect(body).toBeVisible(); await expect(body.getByText(copy(item.state === 'frozen' ? 'documentsFrozen' : item.state === 'draft' ? 'documentsDraft' : 'documentsCurrent'), { exact: true })).toBeVisible(); const action = item.type === 'email-draft' ? 'documentsOpenCorrespondence' : item.type === 'submitted-package' ? 'documentsOpenSubmissionHistory' : 'documentsOpenSource'; await expect(body.getByRole('link', { name: copy(action), exact: true })).toHaveAttribute('href', item.sourceHref); }
const currentCv = seeded.items.find(value => value.type === 'cv')!; await expect(row(page, currentCv).getByRole('link', { name: copy('documentsOpenPublicCv'), exact: true })).toHaveAttribute('href', `/cv/${seeded.variant.publicSlug}`); await expect(page.getByRole('button', { name: copy('documentsDownloadAttachment'), exact: true })).toHaveCount(1); await expect(results(page)).not.toContainText(privateMarker); await expect(row(page, seeded.items.find(value => value.type === 'application-material')!).getByText(copy('documentsDateUnknown'), { exact: true })).toBeVisible(); await expect(row(page, seeded.items.find(value => value.type === 'attachment')!)).toContainText(copy('documentsUploadedLocal'));
const frozen = seeded.items.find(value => value.type === 'submitted-package')!; await expect(row(page, frozen)).toContainText(copy('documentsCapturedCv', { name: seeded.preserved.cvVariantName!, version: seeded.preserved.cvVariantVersion! })); await capture(page, info, desktop, 'seven-honest-metadata-kinds');
expect((await page.request.delete(`${apiUrl}/cv/variants/${seeded.variant.id}`, { headers: session.owner.headers })).status()).toBe(204); expect((await page.request.delete(`${apiUrl}/attachments/${seeded.file.attachment.id}`, { headers: session.owner.headers })).status()).toBe(204); const refreshed = waitResponse(page, 'GET', '/api/documents'); await keyboard(page, page.getByRole('button', { name: copy('documentsRefresh'), exact: true })); const after = metadata(await json<Documents>(await refreshed)); expect(after.items.find(value => value.id === frozen.id)).toEqual(frozen); await expect(rows(page)).toHaveCount(5); await expect(row(page, currentCv)).toHaveCount(0); await expect(row(page, frozen)).toContainText(copy('documentsFrozen'));
const detail = await json<SubmittedPackageDetail>(await page.request.get(`${apiUrl}/jobapplications/${seeded.parent.id}/submitted-packages/${seeded.preserved.id}`)); expect(detail.summary.cvVariantName).toBe(seeded.preserved.cvVariantName); const archive = await page.request.get(`${apiUrl}/jobapplications/${seeded.parent.id}/submitted-packages/${seeded.preserved.id}/attachments/${seeded.preserved.attachments[0].id}`); expect(archive.status()).toBe(200); expect(await archive.body()).toEqual(seeded.file.bytes); noImplicitSourceWork(session); await info.attach('documents-frozen-metadata-readback', { body: JSON.stringify({ frozenBefore: frozen, frozenAfter: after.items.find(value => value.id === frozen.id), archivedSha256: createHash('sha256').update(await archive.body()).digest('hex'), qualification: 'Explicit setup capture; index does not render or recapture. Full immutable corpus/P2-D01 remain separately required.' }), contentType: 'application/json' });
});
test('[P7-07 Documents] server pages filter history owned job selection and reload preserve unknown query and hash', async ({ page, session }, info) => {
test.setTimeout(180_000); const parent = await job(page.request, session.owner, 'Indexed primary role'); const other = await job(page.request, session.owner, 'Independent secondary role', parent.companyId); for (let index = 0; index < 26; index++) await email(page.request, session.owner, parent, `Indexed draft ${String(index).padStart(2, '0')} — æøå`); for (let index = 0; index < 2; index++) await email(page.request, session.owner, other, `Other draft ${index}`);
await enter(page, session.owner); await expect(rows(page)).toHaveCount(25); expect(count(session, 'GET', '/api/jobapplications/choices')).toHaveLength(0); await goPage(page, 2); await expect(rows(page)).toHaveCount(3); expect(new URL(page.url()).searchParams.get('page')).toBe('2'); const before = indexPath(page); await select(page, 'documentsType', 'documentsTypeEmailDraft'); await expect(rows(page)).toHaveCount(25); expect(new URL(page.url()).searchParams.get('page') || '1').toBe('1'); await page.goBack(); await expect(rows(page)).toHaveCount(3); expect(indexPath(page)).toBe(before);
await select(page, 'documentsType', 'documentsTypeEmailDraft'); await select(page, 'documentsState', 'documentsDraft'); await select(page, 'documentsSort', 'documentsSortNameAsc'); await search(page).fill('Indexed'); await expect(rows(page)).toHaveCount(25); const choice = page.getByRole('combobox', { name: copy('documentsJobFilter'), exact: true }); await choice.fill(parent.jobTitle); const selectedOption = page.getByRole('option').filter({ hasText: parent.jobTitle }); await expect(selectedOption).toBeVisible(); const selectedOptionId = await selectedOption.getAttribute('id'); expect(selectedOptionId).not.toBeNull(); await choice.press('ArrowDown'); await expect(choice).toBeFocused(); await expect(choice).toHaveAttribute('aria-activedescendant', selectedOptionId!); await choice.press('Enter'); await expect.poll(() => new URL(page.url()).searchParams.get('jobApplicationId')).toBe(String(parent.id)); await goPage(page, 2); await expect(rows(page)).toHaveCount(1); const retained = indexPath(page); expect(new URL(page.url()).searchParams.get('keep')).toBe('æ&x'); expect(new URL(page.url()).searchParams.get('followMode')).toBe('waiting-update'); expect(new URL(page.url()).hash).toBe('#library');
await page.reload(); await expect(rows(page)).toHaveCount(1); expect(indexPath(page)).toBe(retained); await expect(search(page)).toHaveValue('Indexed'); await expect(page.getByRole('combobox', { name: copy('documentsType'), exact: true })).toContainText(copy('documentsTypeEmailDraft')); expect(count(session, 'GET', '/api/documents').at(-1)?.query).toMatchObject({ page: '2', pageSize: '25', q: 'Indexed', type: 'email-draft', state: 'draft', sort: 'name-asc', jobApplicationId: String(parent.id) }); noImplicitSourceWork(session); await capture(page, info, desktop, 'filtered-server-page-reloaded');
});
test('[P7-07 Documents] initial503 Retry and incompatible held query expose no stale actions or false empty state', async ({ page, session }, info) => {
test.setTimeout(120_000); const parent = await job(page.request, session.owner); const first = await email(page.request, session.owner, parent, 'Initial owned metadata'); const latest = await email(page.request, session.owner, parent, 'Latest owned metadata'); let attempts = 0; const gate = hold(); const reached = hold(); const completed = hold(); let heldMetadata: Documents | null = null;
await page.route(`${apiUrl}/documents*`, async route => { const query = new URL(route.request().url()).searchParams.get('q'); attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); if (query === 'Held old query') { try { const actual = await route.fetch(); heldMetadata = metadata(await json<Documents>(actual)); reached.release(); await gate.pending; await route.fulfill({ response: actual }); } finally { completed.release(); } return; } return route.fulfill({ response: await route.fetch() }); });
try { await enter(page, session.owner); const error = page.getByRole('alert').filter({ hasText: copy('documentsLoadFailed') }); await expect(error).toBeVisible(); await expect(page.getByText(copy('documentsEmpty'), { exact: true })).toHaveCount(0); await expect(page.getByText(copy('documentsNoMatches'), { exact: true })).toHaveCount(0); await keyboard(page, error.getByRole('button', { name: copy('retry'), exact: true })); await expect(rows(page)).toHaveCount(2); expect(attempts).toBe(2);
await search(page).fill('Held old query'); await reached.pending; await expect(rows(page)).toHaveCount(0); await expect(page.getByRole('link', { name: copy('documentsOpenCorrespondence'), exact: true })).toHaveCount(0); await expect(page.getByText(copy('documentsNoMatches'), { exact: true })).toHaveCount(0); const cancelled = page.waitForEvent('requestfailed', { predicate: request => request.method() === 'GET' && new URL(request.url()).pathname === '/api/documents' && new URL(request.url()).searchParams.get('q') === 'Held old query' }); await search(page).fill('Latest owned'); const failedRead = await cancelled; expect(failedRead.failure()?.errorText).toContain('ERR_ABORTED'); await expect(rows(page)).toHaveCount(1); await expect(row(page, { name: latest.subject, type: 'email-draft' })).toBeVisible(); gate.release(); await completed.pending; await expect(row(page, { name: first.subject, type: 'email-draft' })).toHaveCount(0); noImplicitSourceWork(session); await capture(page, info, desktop, 'latest-query-after-get-recovery'); await expect(row(page, { name: latest.subject, type: 'email-draft' })).toBeVisible(); await expect(page.getByText(copy('documentsNoMatches'), { exact: true })).toHaveCount(0); await info.attach('documents-cancelled-obsolete-read', { body: JSON.stringify({ heldServerMetadata: heldMetadata, browserFailure: failedRead.failure(), qualification: 'The actual held server response was retained, but the superseded browser GET was aborted. Handler completion and latest rendered rows are asserted; no browser response is claimed for the cancelled read.' }), contentType: 'application/json' });
} finally { gate.release(); await page.unroute(`${apiUrl}/documents*`); }
});
test('[P7-07 Documents] stale CV opens the existing missing-source error and Back plus explicit Refresh removes its metadata', async ({ page, session }, info) => {
test.setTimeout(120_000); const variant = await cv(page.request, session.owner, 'Removed current CV — æøå'); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; await expect(row(page, item)).toBeVisible(); const from = indexPath(page); noImplicitSourceWork(session); expect((await page.request.delete(`${apiUrl}/cv/variants/${variant.id}`, { headers: session.owner.headers })).status()).toBe(204);
const missing = waitResponse(page, 'GET', `/api/cv/variants/${variant.id}`); await keyboard(page, row(page, item).getByRole('link', { name: copy('documentsOpenSource'), exact: true })); expect((await missing).status()).toBe(404); await expect(page).toHaveURL(new RegExp(`/career/builder/${variant.id}$`)); const sourceError = page.getByRole('main').getByRole('alert'); await expect(sourceError).toBeVisible(); await expect(sourceError).toHaveText('Not Found'); await expect(page.getByRole('heading', { level: 2, name: copy('documentsYourDocuments'), exact: true })).toHaveCount(0); await capture(page, info, desktop, 'existing-missing-source-editor'); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from);
const refreshed = waitResponse(page, 'GET', '/api/documents'); await keyboard(page, page.getByRole('button', { name: copy('documentsRefresh'), exact: true })); expect(metadata(await json<Documents>(await refreshed)).total).toBe(0); await expect(row(page, item)).toHaveCount(0); await expect(page.getByText(copy('documentsEmpty'), { exact: true })).toBeVisible(); expect(writes(session)).toEqual([]);
});
test('[P7-07 Documents] explicit authenticated attachment GET retries exact bytes and a real404 offers only list Refresh', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const file = await upload(page.request, session.owner, parent); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; await expect(row(page, item)).toBeVisible(); noImplicitSourceWork(session); const path = `/api/attachments/download/${file.attachment.id}`; let attempts = 0; await page.route(`${apiUrl}/attachments/download/${file.attachment.id}`, async route => { attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); return route.fulfill({ response: await route.fetch() }); });
await keyboard(page, row(page, item).getByRole('button', { name: copy('documentsDownloadAttachment'), exact: true })); const failed = page.getByRole('alert').filter({ hasText: copy('documentsDownloadFailed') }); await expect(failed).toBeVisible(); expect(count(session, 'GET', path)).toHaveLength(1); const listReads = count(session, 'GET', '/api/documents').length; const downloaded = page.waitForEvent('download'); const actual = waitResponse(page, 'GET', path); await keyboard(page, failed.getByRole('button', { name: copy('retry'), exact: true })); const response = await actual; expect(response.status()).toBe(200); expect(response.headers()['content-type']).toContain('text/plain'); const download = await downloaded; expect(download.suggestedFilename()).toBe(file.name); const saved = await download.path(); expect(saved).not.toBeNull(); expect(await readFile(saved!)).toEqual(file.bytes); expect(attempts).toBe(2); expect(count(session, 'GET', '/api/documents')).toHaveLength(listReads); await expect(row(page, item)).toBeVisible();
expect((await page.request.delete(`${apiUrl}/attachments/${file.attachment.id}`, { headers: session.owner.headers })).status()).toBe(204); const unavailable = waitResponse(page, 'GET', path); await keyboard(page, row(page, item).getByRole('button', { name: copy('documentsDownloadAttachment'), exact: true })); expect((await unavailable).status()).toBe(404); const stale = page.getByRole('alert').filter({ hasText: copy('documentsDownloadUnavailable') }); await expect(stale).toBeVisible(); await expect(stale.getByRole('button', { name: copy('retry'), exact: true })).toHaveCount(0); await expect(row(page, item)).toBeVisible(); expect(count(session, 'GET', '/api/documents')).toHaveLength(listReads); await capture(page, info, desktop, 'real-deleted-attachment-stale-metadata'); const refreshed = waitResponse(page, 'GET', '/api/documents'); await keyboard(page, stale.getByRole('button', { name: copy('documentsRefresh'), exact: true })); expect(metadata(await json<Documents>(await refreshed)).total).toBe(0); await expect(rows(page)).toHaveCount(0); expect(attempts).toBe(3); expect(writes(session)).toEqual([]);
});
test('[P7-07 Documents] actual sign-out clears private rows and a separate owner sees only its own metadata', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner, 'First private owner role'); const privateA = await email(page.request, session.owner, parent, 'First private owner document'); const otherEmail = await account(session.admin, session.administrator); await enter(page, session.owner); await expect(row(page, { name: privateA.subject, type: 'email-draft' })).toBeVisible(); await keyboard(page, page.locator('header').getByRole('button', { name: copy('user'), exact: true })); const logout = waitResponse(page, 'POST', '/api/auth/logout'); await keyboard(page, page.getByRole('menuitem', { name: copy('signOut'), exact: true })); expect((await logout).status()).toBe(204); await expect(page).toHaveURL(/\/login(?:\?|$)/); await expect(page.getByText(privateA.subject, { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/documents`)).status()).toBe(401);
const second = await login(page.request, otherEmail); expect(second.id).not.toBe(session.owner.id); const secondJob = await job(page.request, second, 'Second private owner role'); const privateB = await email(page.request, second, secondJob, 'Second private owner document'); await enter(page, second); await expect(rows(page)).toHaveCount(1); await expect(row(page, { name: privateB.subject, type: 'email-draft' })).toBeVisible(); await expect(page.getByText(privateA.subject, { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/email/drafts/${privateA.id}`)).status()).toBe(404); expect((await readIndex(page.request, { ownerUserId: session.owner.id })).items.map(value => value.id)).toEqual([`email-draft:${privateB.id}`]); await capture(page, info, desktop, 'second-owner-no-cached-private-rows'); expect(writes(session)).toEqual([]);
});
test('[P7-07 Documents] existing letter source keeps dirty edits on navigation Cancel and reindexes only the explicit saved revision', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const original = await json<CoverLetter>(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cover-letter`, { headers: session.owner.headers, data: { text: 'Original source letter — æøå', source: 'manual' } })); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; const from = indexPath(page); await keyboard(page, row(page, item).getByRole('link', { name: copy('documentsOpenSource'), exact: true })); await expect(page).toHaveURL(new RegExp(`/jobs/${parent.id}\\?section=application&sub=letter$`)); const field = page.getByRole('textbox', { name: copy('jobDetailsCoverLetter'), exact: true }); await expect(field).toHaveValue(original.text!); const dirty = 'Reviewed local source change survives Documents navigation Cancel — æøå'; await field.fill(dirty);
const navigation = page.getByRole('button', { name: copy('documentsTitle'), exact: true }); await keyboard(page, navigation); const dialog = page.getByRole('dialog', { name: copy('workspaceUnsavedTitle'), exact: true }); await keyboard(page, dialog.getByRole('button', { name: copy('workspaceKeepEditing'), exact: true })); await expect(dialog).toHaveCount(0); await expect(field).toHaveValue(dirty); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`)).toHaveLength(0); expect(await json<CoverLetter>(await page.request.get(`${apiUrl}/jobapplications/${parent.id}/cover-letter`))).toEqual(original); await capture(page, info, desktop, 'existing-letter-dirty-navigation-cancel');
const body = page.getByRole('region', { name: copy('workspaceCoverLetter'), exact: true }); const saved = waitResponse(page, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`); await keyboard(page, body.getByRole('button', { name: copy('save'), exact: true })); const revision = await json<CoverLetter>(await saved); expect(revision.text).toBe(dirty); expect(revision.currentVersion).toBe(2); await expect(body.getByRole('button', { name: copy('save'), exact: true })).toBeDisabled(); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`).map(value => value.body)).toEqual([{ text: dirty, source: 'manual' }]); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from); const indexed = (await readIndex(page.request)).items[0]; expect(indexed).toMatchObject({ id: item.id, historyCount: 2, version: 2, hasCurrentContent: true }); await expect(row(page, indexed)).toContainText(copy('documentsHistoryVersions', { count: 2 })); expect(writes(session)).toHaveLength(1);
});
test('[P7-07 Documents] eight finite bilingual theme widths retain long metadata keyboard actions and complete axe vectors', async ({ page, session }, info) => {
test.setTimeout(300_000); const seeded = await seven(page, session.owner, true); const samples: Sample[] = [{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 }, { language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 }, { language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 }, { language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 }];
for (const sample of samples) {
await enter(page, session.owner, sample); await expect(rows(page, sample.language)).toHaveCount(7); const control = search(page, sample.language); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Tab'); expect(await page.evaluate(() => document.activeElement !== document.body && document.activeElement?.isConnected)).toBe(true); const frozen = seeded.items.find(value => value.type === 'submitted-package')!; const action = row(page, frozen, sample.language).getByRole('link', { name: copy('documentsOpenSubmissionHistory', {}, sample.language), exact: true }); await action.focus(); await action.scrollIntoViewIfNeeded(); await expect(action).toBeFocused(); await expect(action).toHaveAttribute('href', frozen.sourceHref);
const beforeCapture = await action.boundingBox(); expect(beforeCapture).not.toBeNull(); expect(beforeCapture!.y).toBeGreaterThanOrEqual(0); expect(beforeCapture!.y + beforeCapture!.height).toBeLessThanOrEqual(sample.height); await capture(page, info, sample, 'long-metadata-keyboard');
await action.scrollIntoViewIfNeeded(); await expect(action).toBeFocused(); const afterCapture = await action.boundingBox(); expect(afterCapture).not.toBeNull(); expect(afterCapture!.y).toBeGreaterThanOrEqual(0); expect(afterCapture!.y + afterCapture!.height).toBeLessThanOrEqual(sample.height); await info.attach(`p707-documents-${sample.language}-${sample.width}-focus-bounds`, { body: JSON.stringify({ beforeFullPageCapture: beforeCapture, afterScrollRestoration: afterCapture, viewport: sample, qualification: 'Full-page screenshot can change scroll. Both actual viewport bounds are asserted after explicit focus/scroll, without relaxing the 0..height requirement.' }), contentType: 'application/json' });
if ([320, 1280].includes(sample.width)) await info.attach(`p707-documents-${sample.language}-${sample.width}-focused-viewport`, { body: await page.screenshot({ fullPage: false, animations: 'disabled' }), contentType: 'image/png' });
}
noImplicitSourceWork(session); expect((await readIndex(page.request)).items).toEqual(seeded.items);
});