import { test, expect, type APIRequestContext, type PlaywrightWorkerArgs } from '../../../../../job-tracker-ui/node_modules/@playwright/test'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; const base = 'http://localhost:5304/api'; const password = 'E2ePassword123!'; async function login(api: APIRequestContext, email: string) { expect((await api.post(`${base}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await (await api.get(`${base}/auth/me`)).json(); return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string }; } async function owner(playwright: PlaywrightWorkerArgs['playwright']) { const admin = await playwright.request.newContext(); const api = await playwright.request.newContext(); try { const headers = await login(admin, 'e2e@example.test'); const email = `p9-volume-${Date.now()}@example.test`; const response = await admin.post(`${base}/users`, { headers, data: { email, password, roles: [] } }); expect(response.status()).toBe(200); return { api, email, id: (await response.json()).id as string, headers: await login(api, email) }; } finally { await admin.dispose(); } } test('P9 volume: 100 CV revisions retain exact history, stale rejection and additive restore', async ({ playwright }, info) => { test.setTimeout(180000); const account = await owner(playwright); try { const { api, headers } = account; expect((await api.put(`${base}/career/profile`, { headers, data: { profile: { version: '1', contact: { fullName: 'Volume fixture' }, summary: ['Canonical unchanged'] } } })).status()).toBe(200); const source = await (await api.get(`${base}/career/profile`)).json(); const created = await api.post(`${base}/cv/variants`, { headers, data: { name: 'Volume CV', settings: { themeId: 'minimal' } } }); expect(created.status()).toBe(200); let cv = await created.json(); const id = cv.id; for (let version = 2; version <= 100; version++) { const response = await api.put(`${base}/cv/variants/${id}`, { headers, data: { expectedVersion: cv.version, name: cv.name, settings: { ...cv.settings, headline: `Revision ${version}` }, source: 'manual' } }); expect(response.status()).toBe(200); cv = await response.json(); expect(cv.version).toBe(version); } const history = await (await api.get(`${base}/cv/variants/${id}/versions`)).json(); expect(history.map((row: { version: number }) => row.version)).toEqual(Array.from({ length: 100 }, (_, index) => 100 - index)); expect((await api.put(`${base}/cv/variants/${id}`, { headers, data: { expectedVersion: 99, settings: cv.settings } })).status()).toBe(409); expect(await (await api.get(`${base}/cv/variants/${id}/versions`)).json()).toEqual(history); const restored = await api.post(`${base}/cv/variants/${id}/versions/2/restore`, { headers, data: { expectedVersion: 100 } }); expect(restored.status()).toBe(200); expect(await restored.json()).toMatchObject({ version: 101, settings: { headline: 'Revision 2' } }); expect((await (await api.get(`${base}/cv/variants/${id}/versions`)).json()).length).toBe(101); expect(await (await api.get(`${base}/career/profile`)).json()).toEqual(source); await info.attach('phase9-volume-cv', { body: JSON.stringify({ savedRevisions: 100, finalVersions: 101, staleStatus: 409, canonicalUnchanged: true, limits: 'Native isolated SQLite API; no100-row history UI or MariaDB/long-term capacity claim.' }), contentType: 'application/json' }); } finally { await account.api.dispose(); } }); test('P9 volume: 100 manual timeline messages retain exact membership and foreign-owner denial', async ({ playwright }, info) => { test.setTimeout(180000); const account = await owner(playwright); const other = await owner(playwright); try { const { api, headers } = account; const company = await api.post(`${base}/companies`, { headers, data: { name: 'Synthetic volume timeline' } }); expect(company.status()).toBe(201); const job = await api.post(`${base}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Volume timeline fixture', status: 'Saved' } }); expect(job.status()).toBe(201); const jobId = (await job.json()).id; const ids: number[] = []; for (let index = 0; index < 100; index++) { const response = await api.post(`${base}/correspondence`, { headers, data: { jobApplicationId: jobId, from: 'Synthetic manual note', content: `Timeline evidence ${index} ${'Documented progress. '.repeat(20)}`, channel: 'manual', direction: 'incoming' } }); expect(response.status()).toBe(201); ids.push((await response.json()).id); } const response = await api.get(`${base}/correspondence/${jobId}`); expect(response.status()).toBe(200); const rows = await response.json(); expect(rows.map((row: { id: number }) => row.id).sort((a: number, b: number) => a - b)).toEqual([...ids].sort((a, b) => a - b)); expect(rows.every((row: { content: string }) => row.content.startsWith('Timeline evidence '))).toBe(true); expect((await other.api.get(`${base}/correspondence/${jobId}`)).status()).toBe(404); await info.attach('phase9-volume-timeline', { body: JSON.stringify({ messages: rows.length, exactMembership: true, foreignRead: 404, limits: 'Native persisted manual correspondence; no external mail delivery or100-message browser rendering claim.' }), contentType: 'application/json' }); } finally { await account.api.dispose(); await other.api.dispose(); } }); test('P9 volume: 100 native cancellation notices preserve bounded lists, unread counts and ownership', async ({ playwright }, info) => { test.setTimeout(300000); const account = await owner(playwright); const other = await owner(playwright); try { const dataRoot = info.config.metadata.isolatedDataRoot; expect(typeof dataRoot).toBe('string'); expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); for (let index = 0; index < 100; index++) { // Existing helper admits one inert insert only after temp-path/schema/owner verification. const seeded = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || 'python', [path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: account.id, ownerEmail: account.email }), encoding: 'utf8', timeout: 15000 }); expect(seeded.status, seeded.stderr).toBe(0); const id = JSON.parse(seeded.stdout).operationId; expect((await account.api.post(`${base}/operations/${id}/cancel`, { headers: account.headers })).status()).toBe(200); } const list = await account.api.get(`${base}/notifications?limit=100`); expect(list.status()).toBe(200); const rows = await list.json(); expect(rows.length).toBe(100); expect(new Set(rows.map((row: { id: string }) => row.id)).size).toBe(100); expect((await (await account.api.get(`${base}/notifications`)).json()).length).toBe(25); expect(await (await account.api.get(`${base}/notifications/unread-count`)).json()).toEqual({ count: 100 }); expect((await account.api.get(`${base}/notifications?limit=101`)).status()).toBe(400); expect(await (await other.api.get(`${base}/notifications`)).json()).toEqual([]); expect((await other.api.post(`${base}/notifications/${rows[0].id}/read`, { headers: other.headers })).status()).toBe(404); expect((await account.api.post(`${base}/notifications/${rows[0].id}/read`, { headers: account.headers })).status()).toBe(204); expect((await account.api.post(`${base}/notifications/${rows[0].id}/read`, { headers: account.headers })).status()).toBe(204); expect(await (await account.api.get(`${base}/notifications/unread-count`)).json()).toEqual({ count: 99 }); expect((await account.api.delete(`${base}/notifications/${rows[1].id}`, { headers: account.headers })).status()).toBe(204); expect(await (await account.api.get(`${base}/notifications/unread-count`)).json()).toEqual({ count: 98 }); expect((await (await account.api.get(`${base}/notifications?limit=100`)).json()).length).toBe(99); await info.attach('phase9-volume-notifications', { body: JSON.stringify({ notices: 100, defaultList: 25, maxList: 100, repeatedReadCount: 99, afterUnreadDismiss: 98, foreignMutation: 404, limits: 'Existing guarded inert operation seed + native cancel/read/dismiss. No enabled worker/provider/model or100-item bell UI claim.' }), contentType: 'application/json' }); } finally { await account.api.dispose(); await other.api.dispose(); } });