import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; import { translations } from '../src/i18n/translations'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; const t = translations.en; // Same disposable local account / CSRF / session-context setup as preservation tests. async function login(api: APIRequestContext, email: string) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await api.get(`${apiUrl}/auth/me`); expect(me.status()).toBe(200); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value; return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': (await me.json()).sessionContext as string }; } async function createAccount(admin: APIRequestContext, headers: Record, roles: string[] = []) { const email = `acceptance-${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; } type RouteCase = { pattern: string; path: string; final?: string; heading?: string; endpoint?: string; status?: number; public?: boolean }; function routes(jobId: number, cvId: number): RouteCase[] { return [ { pattern: '/', path: '/', public: true }, { pattern: '/login', path: '/login', heading: t.signInTitle, public: true }, { pattern: '/register', path: '/register', heading: t.createAccount, public: true }, { pattern: '/forgot-password', path: '/forgot-password', heading: t.forgotPasswordTitle, public: true }, { pattern: '/reset-password', path: '/reset-password', heading: t.resetPasswordTitle, public: true }, { pattern: '/verify-email', path: '/verify-email', heading: t.verifyEmailTitle, public: true }, { pattern: '/confirm-email-change', path: '/confirm-email-change', heading: t.confirmEmailChangeTitle, public: true }, { pattern: '/microsoft-legacy-relink', path: '/microsoft-legacy-relink', heading: t.microsoftLegacyRelinkTitle, public: true }, { pattern: '/cv/:slug', path: '/cv/acceptance-unpublished-link', endpoint: '/public-cv/acceptance-unpublished-link', status: 404, public: true }, { pattern: '/dashboard', path: '/dashboard', heading: t.dashboard, endpoint: '/jobapplications/stats' }, { pattern: '/analytics', path: '/analytics?months=6', heading: t.analytics, endpoint: '/jobapplications/stats' }, { pattern: '/discover', path: '/discover', heading: t.discoverJobs, endpoint: '/job-discovery/saved-searches' }, { pattern: '/jobs', path: '/jobs?q=Acceptance', heading: t.jobApplications, endpoint: '/jobapplications' }, { pattern: '/jobs/:id', path: `/jobs/${jobId}?section=documents`, heading: t.jobDetails, endpoint: `/jobapplications/${jobId}/workspace` }, { pattern: '/reminders', path: '/reminders', heading: t.reminders, endpoint: '/jobapplications/reminders' }, { pattern: '/operations', path: '/operations', heading: t.operations, endpoint: '/operations' }, { pattern: '/kanban', path: '/kanban?q=Acceptance&status=Saved#board', final: '/jobs?q=Acceptance&status=Saved&view=board#board', heading: t.jobApplications, endpoint: '/jobapplications/board' }, { pattern: '/companies', path: '/companies', heading: t.companies, endpoint: '/companies' }, { pattern: '/offers', path: '/offers', heading: t.offerComparisonTitle, endpoint: '/jobapplications' }, { pattern: '/correspondence', path: '/correspondence', heading: t.correspondenceInboxTitle, endpoint: '/correspondence/page' }, { pattern: '/correspondence/review', path: '/correspondence/review?route=all#review', final: '/correspondence?view=review', heading: t.correspondenceInboxTitle, endpoint: '/gmail/review-candidates', status: 409 }, { pattern: '/profile', path: '/profile', heading: t.profile, endpoint: '/auth/me' }, { pattern: '/applications/:id', path: `/applications/${jobId}?section=documents&followMode=waiting-update#workspace`, final: `/jobs/${jobId}?section=documents&followMode=waiting-update`, heading: t.jobDetails, endpoint: `/jobapplications/${jobId}/workspace` }, { pattern: '/career', path: '/career?section=profile', heading: t.careerWorkspace, endpoint: '/career/profile' }, { pattern: '/career/builder', path: '/career/builder', heading: t.cvBuilder, endpoint: '/cv/variants' }, { pattern: '/career/builder/:id', path: `/career/builder/${cvId}`, heading: t.cvBuilder, endpoint: `/cv/variants/${cvId}` }, { pattern: '/admin/audit', path: '/admin/audit', heading: t.auditLog, endpoint: '/admin/audit' }, { pattern: '/admin/users', path: '/admin/users', heading: t.users, endpoint: '/users' }, { pattern: '/admin/system', path: '/admin/system', heading: t.systemStatus, endpoint: '/admin/system' }, { pattern: '/trash', path: '/trash', heading: t.trash, endpoint: '/jobapplications' }, { pattern: '/settings', path: '/settings', heading: t.settings, endpoint: '/notification-settings' }, { pattern: '/settings/connected-accounts', path: '/settings/connected-accounts', heading: t.connectedAccounts, endpoint: '/gmail/status' }, ]; } async function publicState(page: Page, row: RouteCase) { if (row.pattern === '/') await expect(page.getByRole('heading', { name: 'Two clear plans' })).toBeVisible(); else if (row.pattern === '/cv/:slug') await expect(page.getByText(/This CV isn.t available/)).toBeVisible(); else { await expect(page.getByRole('heading', { name: row.heading!, exact: true })).toBeVisible(); const missingLinks: Record = { '/reset-password': t.missingResetLinkInfo, '/verify-email': t.missingVerifyLinkInfo, '/confirm-email-change': t.missingEmailChangeLinkInfo, '/microsoft-legacy-relink': t.microsoftLegacyRelinkMissing, }; const missingLink = missingLinks[row.pattern]; if (missingLink) { await expect(page.getByRole('alert').filter({ hasText: missingLink })).toBeVisible(); } if (row.pattern === '/register') await expect(page.getByRole('button', { name: t.createAccount, exact: true })).toBeDisabled(); } } test.beforeEach(async ({ context }) => { await context.addInitScript(() => localStorage.setItem('uiLanguage', 'en')); 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(); }); }); for (const identity of ['anonymous', 'normal', 'admin'] as const) { test(`[P5-15-01 E02 E07 A01] all 32 routes retain native entry, refresh and ${identity} permissions`, async ({ page, playwright }, info) => { test.setTimeout(300_000); const admin = await playwright.request.newContext(); const evidence: { pattern: string; mode: string; destination: string; status?: number }[] = []; try { const adminHeaders = await login(admin, 'e2e@example.test'); const email = await createAccount(admin, adminHeaders, identity === 'admin' ? ['Admin'] : []); let jobId = 2147483647; let cvId = 2147483647; if (identity !== 'anonymous') { const headers = await login(page.request, email); const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Acceptance company' } }); expect(company.status()).toBe(201); const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Acceptance engineer', status: 'Saved' } }); expect(job.status()).toBe(201); jobId = (await job.json()).id; const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Acceptance CV' } }); expect(cv.status()).toBe(200); cvId = (await cv.json()).id; } const census = routes(jobId, cvId); expect(new Set(census.map(row => row.pattern)).size).toBe(32); for (const row of census) { await test.step(row.pattern, async () => { for (const mode of ['entry', 'refresh']) { const protectedAnonymous = identity === 'anonymous' && !row.public; const endpoint = protectedAnonymous ? undefined : row.endpoint; const response = endpoint ? page.waitForResponse(response => new URL(response.url()).pathname === `/api${endpoint}` && response.request().method() === 'GET', { timeout: 20_000 }) : undefined; if (mode === 'entry') await page.goto(row.path); else await page.reload(); const destination = protectedAnonymous ? '/' : row.pattern === '/' && identity !== 'anonymous' ? '/dashboard' : row.final ?? row.path; await expect(page).toHaveURL(url => `${url.pathname}${url.search}${url.hash}` === destination); const native = response ? await response : undefined; evidence.push({ pattern: row.pattern, mode, destination, status: native?.status() }); if (native) { const denied = identity === 'normal' && row.pattern.startsWith('/admin/'); // Finish the route census even when one native read exposes a defect. expect.soft(native.status()).toBe(denied ? 403 : row.status ?? 200); if (row.pattern === '/correspondence/review') { expect(await native.json()).toMatchObject({ code: 'gmail_not_connected' }); await expect(page.getByRole('alert').filter({ hasText: 'Connect Gmail before using the Gmail review queue.' })).toBeVisible(); } if (denied) { await expect(page.getByRole('alert').first()).toBeVisible(); await expect(page.getByText('e2e@example.test', { exact: true })).toHaveCount(0); } } if (protectedAnonymous) { await publicState(page, census[0]); await expect(page.getByRole('main')).toHaveCount(0); } else if (row.public && !(row.pattern === '/' && identity !== 'anonymous')) await publicState(page, row); else { await expect(page.getByRole('heading', { level: 1, name: row.pattern === '/' ? t.dashboard : row.heading!, exact: true })).toBeVisible(); if (['/jobs/:id', '/applications/:id'].includes(row.pattern)) await expect(page.getByRole('heading', { name: 'Acceptance engineer', exact: true })).toBeVisible(); if (row.pattern === '/career/builder/:id') await expect(page.getByLabel('CV name', { exact: true })).toHaveValue('Acceptance CV'); if (row.pattern === '/jobs') await expect(page.locator(`[data-job-row-id="${jobId}"]`)).toBeVisible(); if (row.pattern === '/kanban') await expect(page.locator(`[data-kanban-card-id="${jobId}"]`)).toBeVisible(); } } }); } if (identity === 'anonymous') { await page.goto('/analytics?months=6'); await expect(page).toHaveURL(/\/$/); await page.getByRole('button', { name: 'Sign in', exact: true }).first().click(); await page.getByLabel('Username or email', { exact: true }).fill(email); await page.getByLabel('Current password', { exact: true }).fill(password); await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await expect(page).toHaveURL(/\/analytics\?months=6$/); await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true'); } else { await page.goto('/dashboard'); await page.getByRole('button', { name: t.jobApplications, exact: true }).click(); await expect(page).toHaveURL(/\/jobs$/); await page.goBack(); await expect(page).toHaveURL(/\/dashboard$/); await page.goForward(); await expect(page).toHaveURL(/\/jobs$/); await expect(page.getByTestId('admin-version-badge')).toHaveCount(identity === 'admin' ? 1 : 0); } } finally { await info.attach(`route-census-${identity}`, { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' }); await admin.dispose(); } }); } test('[P5-15-01 W01 C07 A01] foreign and missing workspace deep links deny data through entry and refresh', async ({ page, playwright }, info) => { test.setTimeout(90_000); const admin = await playwright.request.newContext(); const owner = await playwright.request.newContext(); const evidence: { kind: string; mode: string; status: number }[] = []; try { const adminHeaders = await login(admin, 'e2e@example.test'); const ownerHeaders = await login(owner, await createAccount(admin, adminHeaders)); const company = await owner.post(`${apiUrl}/companies`, { headers: ownerHeaders, data: { name: 'Foreign acceptance company' } }); expect(company.status()).toBe(201); const job = await owner.post(`${apiUrl}/jobapplications`, { headers: ownerHeaders, data: { companyId: (await company.json()).id, jobTitle: 'Foreign acceptance engineer', status: 'Saved' } }); expect(job.status()).toBe(201); const jobId = (await job.json()).id; const cv = await owner.post(`${apiUrl}/cv/variants`, { headers: ownerHeaders, data: { name: 'Foreign acceptance CV' } }); expect(cv.status()).toBe(200); const cvId = (await cv.json()).id; await login(page.request, await createAccount(admin, adminHeaders)); for (const [kind, id, editor] of [['foreign-job', jobId, false], ['missing-job', 2147483647, false], ['foreign-cv', cvId, true], ['missing-cv', 2147483647, true]] as const) { for (const mode of ['entry', 'refresh']) { const endpoint = editor ? `/cv/variants/${id}` : `/jobapplications/${id}/workspace`; const response = page.waitForResponse(response => new URL(response.url()).pathname === `/api${endpoint}` && response.request().method() === 'GET'); if (mode === 'entry') await page.goto(editor ? `/career/builder/${id}` : `/applications/${id}?section=documents&followMode=waiting-update`); else await page.reload(); const status = (await response).status(); evidence.push({ kind, mode, status }); expect(status).toBe(404); await expect(page.getByRole('alert').first()).toBeVisible(); await expect(page.getByText('Foreign acceptance engineer', { exact: true })).toHaveCount(0); if (editor) await expect(page.getByLabel('CV name', { exact: true })).toHaveCount(0); else { await expect(page).toHaveURL(new RegExp(`/jobs/${id}\\?section=documents&followMode=waiting-update$`)); await expect(page.getByRole('button', { name: 'Back to applications', exact: true })).toBeVisible(); } } } } finally { await info.attach('foreign-missing-route-denial', { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' }); await owner.dispose(); await admin.dispose(); } }); test('[P5-15-01 J05 J07] a finite 250-job Board preserves the complete matching Table population', async ({ page, playwright }, info) => { test.setTimeout(240_000); const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, 'e2e@example.test'); const headers = await login(page.request, await createAccount(admin, adminHeaders)); const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Finite Board acceptance company' } }); expect(company.status()).toBe(201); const companyId = (await company.json()).id; const seededIds: number[] = []; for (let index = 0; index < 250; index++) { const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: `Finite Board ${String(index).padStart(3, '0')}`, status: 'Saved' } }); expect(created.status()).toBe(201); seededIds.push((await created.json()).id); } // Reuse the existing Board/Table read contract: full Board vs Table's normal 15-row pages. const query = 'q=Finite%20Board&status=Saved'; const tableIds: number[] = []; for (let number = 1; tableIds.length < seededIds.length; number++) { const response = await page.request.get(`${apiUrl}/jobapplications?${query}&page=${number}&pageSize=15`); expect(response.status()).toBe(200); const table = await response.json(); expect(table.total).toBe(seededIds.length); expect(table.items.length).toBeGreaterThan(0); tableIds.push(...table.items.map((item: { id: number }) => item.id)); } expect(new Set(tableIds).size).toBe(250); expect([...tableIds].sort((a, b) => a - b)).toEqual([...seededIds].sort((a, b) => a - b)); let boardRequests = 0; page.on('request', request => { if (new URL(request.url()).pathname === '/api/jobapplications/board' && request.method() === 'GET') boardRequests++; }); const read = page.waitForResponse(response => new URL(response.url()).pathname === '/api/jobapplications/board' && response.request().method() === 'GET'); const started = Date.now(); await page.goto(`/jobs?${query}&view=board`); const boardResponse = await read; expect(boardResponse.status()).toBe(200); const bytes = await boardResponse.body(); const board = JSON.parse(bytes.toString()) as { id: number }[]; expect(board.map(item => item.id).sort((a, b) => a - b)).toEqual([...tableIds].sort((a, b) => a - b)); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(250); const renderedIds = await page.locator('[data-kanban-card-id]').evaluateAll(cards => cards.map(card => Number(card.getAttribute('data-kanban-card-id')))); expect(renderedIds.sort((a, b) => a - b)).toEqual([...tableIds].sort((a, b) => a - b)); await info.attach('finite-250-board-observation', { body: JSON.stringify({ fixtureRows: 250, matchedRows: board.length, tableRows: tableIds.length, renderedRows: renderedIds.length, boardRequests, responseBytes: bytes.length, cardPopulationReadyMs: Date.now() - started, limitation: 'Finite functional observation only; no capacity or latency acceptance threshold, and full-population O(N) debt remains.' }, null, 2), contentType: 'application/json' }); } finally { await admin.dispose(); } });