import { test as base, expect, type APIRequestContext, type APIResponse, type TestInfo } from '../../../../../job-tracker-ui/node_modules/@playwright/test'; import { execFileSync } from 'node:child_process'; import path from 'node:path'; import { translations } from '../../../../../job-tracker-ui/src/i18n/translations'; import type { StructuredCvProfile } from '../../../../../job-tracker-ui/src/profileCv'; const api = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; const t = translations.en; type Career = { profile: StructuredCvProfile; revision: number; cvText: string | null }; type Job = { id: number; version: number; jobTitle: string; notes: string | null; [key: string]: unknown }; type Owner = { id: string; headers: Record }; type DbRow = Record; type DbProof = { identity: DbRow; career: DbRow | null; careerHistory: DbRow[]; children: Record; job: DbRow | null; opportunity: DbRow | null; events: DbRow[] }; async function json(response: Pick, status = 200): Promise { expect(response.status(), `${response.url()} status`).toBe(status); return response.json(); } async function login(request: APIRequestContext, email: string): Promise { expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await json<{ id: string; sessionContext: string }>(await request.get(`${api}/auth/me`)); const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined(); return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } }; } const test = base.extend<{ owner: Owner; second: { request: APIRequestContext; owner: Owner }; email: string }>({ email: async ({ playwright }, use, info) => { const admin = await playwright.request.newContext(); try { const adminOwner = await login(admin, 'e2e@example.test'); const email = `rd21-proof-${info.testId}-${Date.now()}@example.test`; expect((await admin.post(`${api}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200); await use(email); } finally { await admin.dispose(); } }, owner: async ({ page, context, email }, use) => { 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 page.addInitScript(() => localStorage.setItem('uiLanguage', 'en')); await use(await login(page.request, email)); }, second: async ({ playwright, email }, use) => { const request = await playwright.request.newContext(); try { await use({ request, owner: await login(request, email) }); } finally { await request.dispose(); } }, }); function db(info: TestInfo, owner: Owner, jobId?: number): DbProof { const dataRoot = info.config.metadata.isolatedDataRoot; expect(typeof dataRoot, 'Only the isolated test database may be inspected').toBe('string'); const args = ['proxy', 'python', path.join(__dirname, 'readonly-db-proof.py'), path.join(dataRoot, 'jobtracker.db'), owner.id]; if (jobId !== undefined) args.push(String(jobId)); return JSON.parse(execFileSync('rtk', args, { encoding: 'utf8' })); } async function attach(info: TestInfo, name: string, body: unknown) { await info.attach(name, { body: JSON.stringify(body, null, 2), contentType: 'application/json' }); } async function seedCareer(request: APIRequestContext, owner: Owner) { const absent = await json(await request.get(`${api}/career/profile`)); expect(absent.revision).toBe(0); return json(await request.put(`${api}/career/profile`, { headers: owner.headers, data: { expectedVersion: 0, profile: { version: '1', contact: { fullName: 'Synthetic concurrent owner', phone: '+47 900 00 001' }, summary: ['Initial summary'], jobs: [{ id: 'retained-experience', title: 'Recorded role', company: 'Synthetic employer', bullets: ['Retain this reviewed fact.'] }], skills: ['C#'] }, cvText: 'Original synthetic source retained through edits.' } })); } function assertCareer(proof: DbProof, expected: Career) { expect(proof.career?.Version).toBe(expected.revision); const stored = JSON.parse(proof.career!.ProfileJson as string); expect(stored.summary).toEqual(expected.profile.summary); expect(stored.contact).toMatchObject({ fullName: expected.profile.contact.fullName, phone: expected.profile.contact.phone }); expect(stored.jobs).toHaveLength(expected.profile.jobs.length); expect(stored.jobs[0]).toMatchObject({ id: 'retained-experience', title: 'Recorded role', company: 'Synthetic employer', bullets: ['Retain this reviewed fact.'] }); expect(JSON.parse(proof.identity.ProfileCvStructureJson as string)).toEqual(stored); expect(proof.identity.ProfileCvText).toBe(expected.cvText); expect(proof.careerHistory).toHaveLength(expected.revision); expect(proof.careerHistory.at(-1)?.Version).toBe(expected.revision); expect(JSON.parse(proof.careerHistory.at(-1)!.ProfileJson as string)).toEqual(stored); expect(proof.children.CareerExperiences).toEqual([expect.objectContaining({ ItemKey: 'retained-experience', Title: 'Recorded role', OwnerUserId: proof.identity.Id })]); } async function createJob(request: APIRequestContext, owner: Owner) { const company = await json<{ id: number }>(await request.post(`${api}/companies`, { headers: owner.headers, data: { name: 'Synthetic stale job fixture' } }), 201); return json(await request.post(`${api}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: 'Original job title', status: 'Saved', notes: 'Retain original notes', location: 'Trondheim' } }), 201); } test('RD21 original two-session ordering rejects stale snapshots and preserves each acknowledged SQLite commit across navigation and a fresh session', async ({ page, owner, second, playwright, email }, info) => { await seedCareer(page.request, owner); const careerA = await json(await page.request.get(`${api}/career/profile`)); const careerB = await json(await second.request.get(`${api}/career/profile`)); careerA.profile.summary = ['First editor committed summary']; careerB.profile.contact.headline = 'Second editor headline'; const acknowledged = await json(await page.request.put(`${api}/career/profile`, { headers: owner.headers, data: { profile: careerA.profile, expectedVersion: careerA.revision } })); const immediateCareer = db(info, owner); assertCareer(immediateCareer, acknowledged); const staleCareer = await second.request.put(`${api}/career/profile`, { headers: second.owner.headers, data: { profile: careerB.profile, expectedVersion: careerB.revision } }); expect(staleCareer.status()).toBe(409); expect(await staleCareer.json()).toMatchObject({ code: 'revision_conflict' }); expect(db(info, owner)).toEqual(immediateCareer); expect((await json(await page.request.get(`${api}/career/profile`))).profile).toEqual(acknowledged.profile); const created = await createJob(page.request, owner); const jobA = await json(await page.request.get(`${api}/jobapplications/${created.id}`)); const jobB = await json(await second.request.get(`${api}/jobapplications/${created.id}`)); jobA.jobTitle = 'First editor committed title'; jobB.notes = 'Second editor notes'; const committed = await page.request.put(`${api}/jobapplications/${created.id}`, { headers: owner.headers, data: { ...jobA, expectedVersion: jobA.version } }); expect(committed.status()).toBe(204); const committedRevision = Number(committed.headers()['x-job-version']); expect(committedRevision).toBe(jobA.version + 1); const immediateJob = db(info, owner, created.id); assertCareer(immediateJob, acknowledged); expect(immediateJob.job).toMatchObject({ Id: created.id, OwnerUserId: owner.id, Version: committedRevision, JobTitle: jobA.jobTitle, Notes: 'Retain original notes', Location: 'Trondheim', IsDeleted: 0 }); expect(immediateJob.opportunity).toMatchObject({ Id: immediateJob.job!.JobId, OwnerUserId: owner.id, JobTitle: jobA.jobTitle, Location: 'Trondheim', CompanyId: immediateJob.job!.CompanyId }); expect(immediateJob.events.length).toBeGreaterThan(0); const staleJob = await second.request.put(`${api}/jobapplications/${created.id}`, { headers: second.owner.headers, data: { ...jobB, expectedVersion: jobB.version } }); expect(staleJob.status()).toBe(409); expect(await staleJob.json()).toMatchObject({ code: 'revision_conflict' }); expect(db(info, owner, created.id)).toEqual(immediateJob); await page.goto(`/jobs/${created.id}?section=overview`); await expect(page.getByRole('main')).toContainText(jobA.jobTitle); expect(db(info, owner, created.id)).toEqual(immediateJob); await page.reload(); await expect(page.getByRole('main')).toContainText(jobA.jobTitle); expect(db(info, owner, created.id)).toEqual(immediateJob); await page.goto('/career?section=profile'); await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(acknowledged.profile.summary[0]); expect(db(info, owner, created.id)).toEqual(immediateJob); await page.reload(); await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(acknowledged.profile.summary[0]); expect(db(info, owner, created.id)).toEqual(immediateJob); const fresh = await playwright.request.newContext(); try { await login(fresh, email); expect((await json(await fresh.get(`${api}/career/profile`))).profile).toEqual(acknowledged.profile); expect(await json(await fresh.get(`${api}/jobapplications/${created.id}`))).toMatchObject({ jobTitle: jobA.jobTitle, version: committedRevision, notes: 'Retain original notes' }); expect(db(info, owner, created.id)).toEqual(immediateJob); } finally { await fresh.dispose(); } await attach(info, 'rd21-original-ordering-committed-database', { careerAcknowledgement: acknowledged, careerStaleStatus: staleCareer.status(), immediateCareer, jobAcknowledgedStatus: committed.status(), committedRevision, jobStaleStatus: staleJob.status(), finalDatabase: immediateJob, checkpoints: ['immediate Career acknowledgement', 'Career stale rejection', 'immediate job acknowledgement', 'job stale rejection', 'navigation', 'job refresh', 'Career navigation', 'Career refresh', 'new authenticated session'] }); }); test('RD21 missing revisions fail closed without changing canonical rows, Identity projection, job opportunity or history', async ({ page, owner }, info) => { const career = await seedCareer(page.request, owner); const job = await createJob(page.request, owner); const before = db(info, owner, job.id); const profile = structuredClone(career.profile); profile.summary = ['Unversioned replacement must not persist']; const missingCareer = await page.request.put(`${api}/career/profile`, { headers: owner.headers, data: { profile } }); expect(missingCareer.status()).toBe(400); expect(db(info, owner, job.id)).toEqual(before); const negativeCareer = await page.request.put(`${api}/career/profile`, { headers: owner.headers, data: { profile, expectedVersion: -1 } }); expect(negativeCareer.status()).toBe(400); expect(db(info, owner, job.id)).toEqual(before); const missingJob = await page.request.put(`${api}/jobapplications/${job.id}`, { headers: owner.headers, data: { ...job, jobTitle: 'Unversioned job replacement must not persist' } }); expect(missingJob.status()).toBe(409); expect(db(info, owner, job.id)).toEqual(before); await attach(info, 'rd21-missing-preconditions-database', { statuses: { career: missingCareer.status(), negativeCareer: negativeCareer.status(), job: missingJob.status() }, before, after: db(info, owner, job.id) }); }); test('RD21 native editor retains rejected and network-failed drafts until deliberate review and successful retry', async ({ page, owner, second }, info) => { await seedCareer(page.request, owner); await page.goto('/career?section=profile'); const summary = page.getByLabel(t.profileCvStructuredSummary, { exact: true }); await expect(summary).toHaveValue('Initial summary'); const localDraft = 'Retained draft reviewed after conflict'; await summary.fill(localDraft); const remote = await json(await second.request.get(`${api}/career/profile`)); remote.profile.contact.phone = '+47 900 00 077'; const acknowledged = await json(await second.request.put(`${api}/career/profile`, { headers: second.owner.headers, data: { profile: remote.profile, expectedVersion: remote.revision } })); const beforeConflict = db(info, owner); assertCareer(beforeConflict, acknowledged); const rejected = page.waitForResponse(response => response.url() === `${api}/career/profile` && response.request().method() === 'PUT'); await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click(); expect((await rejected).status()).toBe(409); await expect(page.getByRole('main').getByRole('alert').filter({ hasText: t.revisionConflict })).toBeVisible(); await expect(summary).toHaveValue(localDraft); expect(db(info, owner)).toEqual(beforeConflict); // The explicit reload discards this editor snapshot; the retained draft is then reviewed against the new contact. await page.reload(); await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 077'); await expect(summary).toHaveValue('Initial summary'); await summary.fill(localDraft); const failedBodies: unknown[] = []; await page.route('**/api/career/profile', async route => { if (route.request().method() !== 'PUT') return route.continue(); failedBodies.push(route.request().postDataJSON()); return route.abort('failed'); }); const networkFailed = page.waitForEvent('requestfailed', request => request.url() === `${api}/career/profile` && request.method() === 'PUT'); await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click(); await networkFailed; await expect(page.getByRole('main').getByRole('alert').filter({ hasText: t.profileUpdateFailed })).toBeVisible(); await expect(summary).toHaveValue(localDraft); await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled(); expect(failedBodies).toHaveLength(1); expect(failedBodies[0]).toMatchObject({ expectedVersion: acknowledged.revision, profile: { summary: [localDraft], contact: { phone: '+47 900 00 077' } } }); expect(db(info, owner)).toEqual(beforeConflict); await page.unroute('**/api/career/profile'); const retried = page.waitForResponse(response => response.url() === `${api}/career/profile` && response.request().method() === 'PUT'); await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click(); const retryResponse = await retried; const recovered = await json(retryResponse); const immediateRecovery = db(info, owner); assertCareer(immediateRecovery, recovered); expect(recovered.revision).toBe(acknowledged.revision + 1); expect(recovered.profile.summary).toEqual([localDraft]); expect(recovered.profile.contact.phone).toBe('+47 900 00 077'); await page.reload(); await expect(summary).toHaveValue(localDraft); expect(db(info, owner)).toEqual(immediateRecovery); await attach(info, 'rd21-recoverable-native-editor', { rejectedStatus: 409, beforeConflict, failedBodies, recovered, immediateRecovery, qualification: 'Real stale HTTP conflict and real successful SQLite commits; network failure is a controlled browser request abort.' }); });