import { expect, test as base, type APIRequestContext, type APIResponse } from '@playwright/test'; import { translations } from '../src/i18n/translations'; import type { CvVariant } from '../src/cvBuilder'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; type Owner = { id: string; email: string; headers: Record }; type SourceItem = { id?: string; [key: string]: unknown }; type CareerProfile = Record & { contact: Record; jobs: SourceItem[]; education: SourceItem[]; projects: SourceItem[]; certifications: SourceItem[]; }; type CareerReadback = { profile: CareerProfile; completeness: unknown; cvText: string | null }; type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean }; type Preview = { themeId: string; html: string; suggestedFileName: string }; async function json(response: APIResponse, status = 200): Promise { expect(response.status(), `${response.url()} must return ${status}`).toBe(status); return response.json(); } async function login(api: APIRequestContext, email: string): Promise { 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 csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } }; } async function createOwner(admin: APIRequestContext, headers: Record) { const email = `p7-career-ids-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200); return email; } const test = base.extend<{ owner: Owner }>({ owner: async ({ page, context, playwright }, provide) => { const admin = await playwright.request.newContext(); try { const adminOwner = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await createOwner(admin, adminOwner.headers)); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: 'en' } })).status()).toBe(204); await context.route('**/*', route => { const url = new URL(route.request().url()); return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort(); }); await context.addInitScript(() => { localStorage.setItem('uiLanguage', 'en'); localStorage.setItem('jobtracker.themeMode', 'light'); }); await provide(owner); } finally { await admin.dispose(); } }, }); function sourceIds(profile: CareerProfile) { return Object.fromEntries((['jobs', 'education', 'projects', 'certifications'] as const) .map(key => [key, profile[key].map(item => item.id)])); } function historyMetadata(versions: CareerVersion[]) { return versions.map(({ version, source, createdAtUtc }) => ({ version, source, createdAtUtc })); } test('[P7-03 PRES-C01 C03 C06 C07 C10 W05 W06] unrelated canonical UI save preserves four source IDs, existing CV overrides, sibling and frozen package', async ({ page, owner }, info) => { test.setTimeout(120_000); const raw = 'Exact owner source — æøå\nReviewed work and qualifications.\n'; const profile = { version: '1', metadata: { fields: { 'contact.fullName': { confidence: 0.9, method: 'manual', sourceSnippet: 'Owner-reviewed contact', reviewState: 'reviewed' } } }, contact: { fullName: 'Åse Source-ID', headline: 'Canonical engineer', phone: '+47 900 00 001', location: 'Oslo, Norge', website: 'https://example.test/portfolio', linkedIn: 'https://www.linkedin.com/in/source-fixture', gitHub: 'https://github.com/source-fixture', links: [{ label: 'Reviewed work', url: 'https://example.test/work' }] }, summary: ['A source-backed professional summary.'], jobs: [{ id: 'job-source', title: 'Canonical role', company: 'Nordlys AS', start: 'Jan 2020', end: 'Dec 2022', bullets: ['Canonical work evidence.'], skills: ['C#'] }], education: [{ id: 'education-source', qualification: 'Computer science', qualificationLevel: 'Bachelor', institution: 'Fixture University', location: 'Oslo', start: 'Aug 2016', end: 'Jun 2019', details: ['Reviewed qualification.'] }], projects: [{ id: 'project-source', name: 'Recorded project', role: 'Developer', location: 'Remote', start: '2023-01', end: '2023-12', bullets: ['Reviewed project evidence.'], skills: ['React'] }], certifications: [{ id: 'cert-source', name: 'Recorded certification', issuer: 'Fixture Academy', location: 'Remote', date: 'Jun 2022', details: ['Reviewed certification detail.'] }], skills: ['C#', 'Norsk'], languages: [{ name: 'Norwegian Sign Language', level: 'Professional working proficiency', notes: 'Reviewed language note.' }], interests: ['Documented community work'], awards: ['Reviewed award'], publications: ['Reviewed publication'], organisations: ['Reviewed organisation'], references: ['Available by owner agreement'], otherSections: [{ title: 'Custom factual section', items: ['Recorded custom fact.', 'Another reviewed custom fact.'] }], }; await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } })); const before = await json(await page.request.get(`${apiUrl}/career/profile`)); expect(sourceIds(before.profile)).toEqual({ jobs: ['job-source'], education: ['education-source'], projects: ['project-source'], certifications: ['cert-source'] }); const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Source-ID fixture AS' } }), 201); const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: 'Source-ID preservation', status: 'Saved' } }), 201); const retainedTitle = 'CV-only retained role'; const retainedBullet = 'CV-only retained evidence'; let selected = await json(await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name: 'Selected source-ID CV', jobApplicationId: job.id, settings: { themeId: 'minimal', overrides: { [before.profile.jobs[0].id!]: { title: retainedTitle, bullets: [retainedBullet] } } } } })); const sibling = await json(await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name: 'Untouched source-ID sibling', settings: { themeId: 'minimal' } } })); const selectedHistoryBefore = await json(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/versions`)); const siblingHistoryBefore = await json(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/versions`)); const selectedPreviewBefore = await json(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/preview`)); const siblingPreviewBefore = await json(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/preview`)); expect(selectedPreviewBefore.html).toContain(retainedTitle); expect(selectedPreviewBefore.html).toContain(retainedBullet); expect(siblingPreviewBefore.html).toContain('Canonical role'); expect((await page.request.put(`${apiUrl}/jobapplications/${job.id}/cv`, { headers: owner.headers, data: { variantId: selected.id } })).status()).toBe(200); const packageSummary = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications/${job.id}/submitted-packages`, { headers: owner.headers })); const packageUrl = `${apiUrl}/jobapplications/${job.id}/submitted-packages/${packageSummary.id}`; const frozenBefore = await json(await page.request.get(packageUrl)); // Linking the selected CV updates its timestamp during setup. The preservation // baseline must include that setup mutation before the unrelated career edit. selected = await json(await page.request.get(`${apiUrl}/cv/variants/${selected.id}`)); await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/career?section=profile'); const t = translations.en; const phone = page.getByLabel(t.profileCvContactPhone, { exact: true }); await expect(phone).toHaveValue(String(before.profile.contact.phone)); await phone.fill('+47 900 00 002'); const requestPromise = page.waitForRequest(request => request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT'); const responsePromise = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT'); await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus(); await page.keyboard.press('Enter'); const savedRequest = await requestPromise; const savedResponse = await responsePromise; const savedStatus = savedResponse.status(); const requested = savedRequest.postDataJSON() as { profile: CareerProfile; cvText: string }; const after = await json(await page.request.get(`${apiUrl}/career/profile`)); const selectedAfter = await json(await page.request.get(`${apiUrl}/cv/variants/${selected.id}`)); const siblingAfter = await json(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}`)); const selectedHistoryAfter = await json(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/versions`)); const siblingHistoryAfter = await json(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/versions`)); const selectedPreviewAfter = await json(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/preview`)); const siblingPreviewAfter = await json(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/preview`)); const frozenAfter = await json(await page.request.get(packageUrl)); // Collect the persisted IDs AND effective override output before the first preservation // assertion, so the RED attempt retains both consequences of the existing browser save. await info.attach('native-career-source-id-readbacks', { body: JSON.stringify({ savedStatus, requested, ids: { before: sourceIds(before.profile), requested: sourceIds(requested.profile), after: sourceIds(after.profile) }, observed: { requestedIdsEqual: JSON.stringify(sourceIds(requested.profile)) === JSON.stringify(sourceIds(before.profile)), persistedIdsEqual: JSON.stringify(sourceIds(after.profile)) === JSON.stringify(sourceIds(before.profile)), overrideTitleBefore: selectedPreviewBefore.html.includes(retainedTitle), overrideTitleAfter: selectedPreviewAfter.html.includes(retainedTitle), overrideBulletBefore: selectedPreviewBefore.html.includes(retainedBullet), overrideBulletAfter: selectedPreviewAfter.html.includes(retainedBullet), selectedDtoUnchanged: JSON.stringify(selectedAfter) === JSON.stringify(selected), siblingDtoUnchanged: JSON.stringify(siblingAfter) === JSON.stringify(sibling), selectedHistoryUnchanged: JSON.stringify(selectedHistoryAfter) === JSON.stringify(selectedHistoryBefore), siblingHistoryUnchanged: JSON.stringify(siblingHistoryAfter) === JSON.stringify(siblingHistoryBefore), frozenDetailUnchanged: JSON.stringify(frozenAfter) === JSON.stringify(frozenBefore), }, canonical: { before, after }, selected: { before: selected, after: selectedAfter, historyBefore: selectedHistoryBefore, historyAfter: selectedHistoryAfter, previewBefore: selectedPreviewBefore, previewAfter: selectedPreviewAfter }, sibling: { before: sibling, after: siblingAfter, historyBefore: siblingHistoryBefore, historyAfter: siblingHistoryAfter, previewBefore: siblingPreviewBefore, previewAfter: siblingPreviewAfter }, frozen: { before: frozenBefore, after: frozenAfter }, qualification: 'Actual canonical browser Save and local SQLite HTTP readbacks. No PDF/model/provider call; frozen full-detail/HTML proof only, no new attachment-byte proof or concurrency-protection claim.' }), contentType: 'application/json' }); await info.attach('native-career-source-id-editor', { body: await page.screenshot({ fullPage: true }), contentType: 'image/png' }); expect(savedStatus).toBe(200); expect(sourceIds(requested.profile)).toEqual(sourceIds(before.profile)); expect(sourceIds(after.profile)).toEqual(sourceIds(before.profile)); const { contact: beforeContact, ...nonContactBefore } = before.profile; const { contact: afterContact, ...nonContactAfter } = after.profile; expect(nonContactAfter).toEqual(nonContactBefore); expect(afterContact).toEqual({ ...beforeContact, phone: '+47 900 00 002' }); expect(requested.cvText).toBe(raw); expect(after.cvText).toBe(raw); expect(selectedAfter).toEqual(selected); expect(siblingAfter).toEqual(sibling); expect(selectedHistoryAfter).toEqual(selectedHistoryBefore); expect(siblingHistoryAfter).toEqual(siblingHistoryBefore); expect(selectedPreviewAfter.html).toContain(retainedTitle); expect(selectedPreviewAfter.html).toContain(retainedBullet); expect(siblingPreviewAfter.html).toContain('Canonical role'); expect(siblingPreviewAfter.html).not.toContain(retainedTitle); expect(frozenAfter).toEqual(frozenBefore); await page.reload(); await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 002'); }); test('[P7-03 PRES-C01 C03 E05 E09] real HTTP bounds canonical last-writer saves and structured-history recovery without claiming stale-write protection', async ({ page, owner, playwright }, info) => { test.setTimeout(90_000); const initialProfile = { version: '1', contact: { fullName: 'Canonical recovery owner', headline: 'Initial source' }, jobs: [{ id: 'recovery-source', title: 'Recorded role', company: 'Fixture AS', bullets: ['Recorded evidence.'] }], skills: ['Norsk'] }; const initialRaw = 'Initial raw source — æøå\n'; await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: initialProfile, cvText: initialRaw } })); const staleSnapshot = await json(await page.request.get(`${apiUrl}/career/profile`)); const initialHistory = await json(await page.request.get(`${apiUrl}/career/profile/versions`)); expect(initialHistory.map(row => row.version)).toEqual([1]); const secondSession = await playwright.request.newContext(); const admin = await playwright.request.newContext(); const other = await playwright.request.newContext(); const anonymous = await playwright.request.newContext(); try { const secondOwner = await login(secondSession, owner.email); expect(secondOwner.id).toBe(owner.id); const secondSnapshot = await json(await secondSession.get(`${apiUrl}/career/profile`)); expect(secondSnapshot).toEqual(staleSnapshot); const remoteRaw = 'Remote current raw source — æøå\n'; const remote = await json(await secondSession.put(`${apiUrl}/career/profile`, { headers: secondOwner.headers, data: { profile: { ...secondSnapshot.profile, contact: { ...secondSnapshot.profile.contact, headline: 'Remote reviewed source' } }, cvText: remoteRaw } })); const remoteHistory = await json(await page.request.get(`${apiUrl}/career/profile/versions`)); expect(remoteHistory.map(row => row.version)).toEqual([2, 1]); const currentRaw = 'Last writer raw source remains current after structured restore — æøå\n'; const staleBody = { profile: { ...staleSnapshot.profile, contact: { ...staleSnapshot.profile.contact, headline: 'Stale snapshot accepted as last writer' } }, cvText: currentRaw }; const staleWrite = await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: staleBody }); const staleStatus = staleWrite.status(); const lastWriter = await json(staleWrite); const afterStale = await json(await secondSession.get(`${apiUrl}/career/profile`)); const historyAfterStale = await json(await secondSession.get(`${apiUrl}/career/profile/versions`)); expect(staleStatus).toBe(200); expect(staleBody).not.toHaveProperty('expectedVersion'); expect(afterStale).toEqual(lastWriter); expect(afterStale.profile.contact.headline).toBe('Stale snapshot accepted as last writer'); expect(historyAfterStale.map(row => row.version)).toEqual([3, 2, 1]); expect(historyMetadata(historyAfterStale.slice(1))).toEqual(historyMetadata(remoteHistory)); const restored = await json(await secondSession.post(`${apiUrl}/career/profile/versions/2/restore`, { headers: secondOwner.headers })); const recovered = await json(await page.request.get(`${apiUrl}/career/profile`)); const restoredHistory = await json(await page.request.get(`${apiUrl}/career/profile/versions`)); expect(restored.profile).toEqual(remote.profile); expect(recovered).toEqual(restored); expect(recovered.cvText).toBe(currentRaw); expect(recovered.cvText).not.toBe(remoteRaw); expect(restoredHistory.map(row => row.version)).toEqual([4, 3, 2, 1]); expect(restoredHistory[0]).toMatchObject({ source: 'restore:v2', isCurrent: true }); expect(restoredHistory.slice(1).every(row => !row.isCurrent)).toBe(true); expect(historyMetadata(restoredHistory.slice(1))).toEqual(historyMetadata(historyAfterStale)); expect((await page.request.post(`${apiUrl}/career/profile/versions/99999/restore`, { headers: owner.headers })).status()).toBe(404); const adminOwner = await login(admin, 'e2e@example.test'); const otherOwner = await login(other, await createOwner(admin, adminOwner.headers)); const otherBefore = await json(await other.get(`${apiUrl}/career/profile`)); expect(await json(await other.get(`${apiUrl}/career/profile?ownerUserId=${encodeURIComponent(owner.id)}`))).toEqual(otherBefore); expect(await json(await other.get(`${apiUrl}/career/profile/versions?ownerUserId=${encodeURIComponent(owner.id)}`))).toEqual([]); expect((await other.post(`${apiUrl}/career/profile/versions/2/restore`, { headers: otherOwner.headers })).status()).toBe(404); await json(await other.put(`${apiUrl}/career/profile`, { headers: otherOwner.headers, data: { ownerUserId: owner.id, profile: { version: '1', contact: { fullName: 'Other account only' }, skills: ['Other owner fact'] }, cvText: 'Other owner raw source' } })); expect((await json(await other.get(`${apiUrl}/career/profile`))).profile.contact.fullName).toBe('Other account only'); expect((await anonymous.get(`${apiUrl}/career/profile`)).status()).toBe(401); expect((await anonymous.get(`${apiUrl}/career/profile/versions`)).status()).toBe(401); expect((await anonymous.put(`${apiUrl}/career/profile`, { data: staleBody })).status()).toBe(401); expect((await anonymous.post(`${apiUrl}/career/profile/versions/2/restore`)).status()).toBe(401); expect(await json(await page.request.get(`${apiUrl}/career/profile`))).toEqual(recovered); expect(await json(await page.request.get(`${apiUrl}/career/profile/versions`))).toEqual(restoredHistory); await info.attach('native-canonical-stale-contract-characterization', { body: JSON.stringify({ initial: staleSnapshot, remote, staleRequest: staleBody, staleStatus, lastWriter: afterStale, historyAfterStale, recovered, restoredHistory, ownerIsolation: { forgedOwnerQueryCannotReadForeign: true, foreignStructuredVersion404: true, forgedBodyWritesOnlyOwnProfile: true, anonymous401: true }, qualification: 'Two real authenticated HTTP sessions in disposable SQLite. Canonical stale saves remain accepted last-writer replacements; history recovery appends a structured revision and does not restore raw cvText. No conflict prevention, browser draft-recovery, attachment-byte, model/provider or production acceptance is claimed.' }), contentType: 'application/json' }); } finally { await secondSession.dispose(); await admin.dispose(); await other.dispose(); await anonymous.dispose(); } });