const assert = require('node:assert/strict'); const path = require('node:path'); const { mkdtempSync, mkdirSync } = require('node:fs'); const { createRequire } = require('node:module'); const root = path.resolve(__dirname, '../..'); const clientRequire = createRequire(path.join(root, 'client/package.json')); const serverRequire = createRequire(path.join(root, 'server/package.json')); const { chromium, expect } = clientRequire('@playwright/test'); module.exports = async function employeeBrowser(h) { const { finance, password, prisma, config, employeeSources } = h; const own = !h.browser; const browser = h.browser ?? await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); const origin = h.origin ?? await h.app.listen({ host: '127.0.0.1', port: 0 }); const base = path.join(root, 'artifacts/qa-flow/loan-employee-source-20261009'); mkdirSync(base, { recursive: true }); const output = h.output ?? mkdtempSync(path.join(base, 'browser-')); const oldOrigin = config.appOrigin; config.appOrigin = origin; const checks = []; const { encryptStringOrThrow } = serverRequire('./src/services/crypto'); try { for (const s of employeeSources.ui) { const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : ''; const contexts = []; async function login(who) { const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); contexts.push(context); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${prefix}/company-loans`)}`); await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); return page; } async function submit(page, label = '确认', expected = 200) { const pending = page.waitForResponse(r => r.url().startsWith(`${origin}/api/`) && !r.url().includes('/auth/') && r.request().method() === 'POST'); await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click(); const response = await pending; assert.equal(response.status(), expected, `employee ${s.mode} ${label}: ${response.status()}`); if (expected === 200) await expect(page.getByRole('dialog')).not.toBeVisible(); return response; } async function submitAfterSameActor401(page, label, endpoint) { let attempts = 0; const handler = async route => { attempts++; if (attempts === 1) return route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-expired-access', error: { code: 'AUTH_001', message: '合成过期令牌' } }) }); return route.continue(); }; await page.route(`**${endpoint}`, handler); try { const final = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST' && r.status() === 200); await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click(); const response = await final; assert.equal(attempts, 2, 'same-actor expiry keeps the normal single refresh retry'); await expect(page.getByRole('dialog')).not.toBeVisible(); return response; } finally { await page.unroute(`**${endpoint}`, handler); } } async function findCard(page, card) { for (let i = 0; i < 20; i++) { await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0); if (await card.count()) return; const next = page.getByRole('button', { name: '下一页', exact: true }); await expect(next).toBeEnabled(); const label = await page.getByText(/^第 \d+ 页$/).first().innerText(); await next.click(); await expect(page.getByText(label, { exact: true })).toHaveCount(0); } throw new Error('synthetic employee record not found within ledger pages'); } try { const page = await login(finance); async function selectEmployee(who = s.staff, query = `浏览器-${s.mode}`) { await page.getByRole('button', { name: '登记出借人', exact: true }).click(); const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: '从员工选择出借人' })).toBeVisible(); await dialog.getByLabel('检索员工', { exact: true }).fill(query); await dialog.getByLabel('已有员工', { exact: true }).selectOption(who.id); await expect(dialog.getByLabel('借款所属公司', { exact: true })).toHaveValue(s.company.id); await expect(dialog.getByLabel('员工资料摘要')).toBeVisible(); await expect(dialog.getByRole('textbox', { name: '收款账号', exact: true })).toHaveCount(0); await expect(dialog.getByRole('textbox', { name: '出借人姓名', exact: true })).toHaveCount(0); return dialog; } let dialog = await selectEmployee(); assert.ok(!(await dialog.innerText()).includes('2'.repeat(16)), 'full employee account is never rendered'); await expect(dialog.getByRole('button', { name: '确认关联员工' })).toBeDisabled(); await page.screenshot({ path: path.join(output, `employee-${s.mode}-source.png`), fullPage: true }); const noOverflow = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1); assert.ok(noOverflow, `${s.mode} employee selection has no horizontal overflow`); // The page's old preview must fail after a real source-row update. await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)) } }); await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check(); const stale = await submit(page, '确认关联员工', 409); assert.equal((await stale.json()).error.details.reasonCode, 'loan.employee_source_changed'); await expect(dialog.getByRole('alert')).toContainText('员工资料已变化'); assert.equal(await prisma.loanLender.count({ where: { employee_id: s.staff.id } }), 0); await dialog.getByRole('button', { name: '重新加载员工资料' }).click(); const checked = dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }); await expect(checked).not.toBeChecked(); await expect(checked).toBeEnabled(); await checked.check(); const linked = await submitAfterSameActor401(page, '确认关联员工', '/api/loan-lenders/from-employee'); const sent = linked.request().postDataJSON(); assert.deepEqual(Object.keys(sent).sort(), ['detailsConfirmed', 'employeeId', 'expectedSourceVersion', 'legalEntityId', 'requestKey'].sort()); let lender = (await linked.json()).data.lender; assert.equal(lender.employeeId, s.staff.id); assert.equal(lender.identityStatus, 'verified'); assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } })).created_by, finance.id); dialog = await selectEmployee(); await expect(dialog).toContainText('将使用已有记录'); await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check(); assert.equal((await (await submit(page, '使用已有出借人')).json()).data.lender.id, lender.id); assert.equal(await prisma.loanLender.count({ where: { employee_id: s.staff.id, legal_entity_id: s.company.id } }), 1); // Missing data points back to personnel maintenance, with no replacement // account field in either the new-link or refresh dialog. await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: '' } }); dialog = await selectEmployee(); await expect(dialog.getByRole('alert').filter({ hasText: '员工收款账号未维护' })).toBeVisible(); await expect(dialog.getByRole('button', { name: '确认关联员工', exact: true })).toBeDisabled(); await expect(dialog.getByRole('textbox', { name: '收款账号', exact: true })).toHaveCount(0); await dialog.press('Escape'); await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '4'.repeat(16)) } }); const lenderCard = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${s.company.name}`, exact: true }) }); await findCard(page, lenderCard); await lenderCard.getByRole('button', { name: '使用当前员工资料', exact: true }).click(); dialog = page.getByRole('dialog'); await expect(dialog).toContainText('原公司代表授权须重新登记确认'); await expect(dialog.getByLabel('员工资料摘要')).toContainText('4444'); await dialog.getByLabel('更新依据', { exact: true }).fill('合成核对员工档案中的新收款资料'); await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check(); lender = (await (await submitAfterSameActor401(page, '确认更新出借人资料', `/api/loan-lenders/${lender.id}/employee-refresh`)).json()).data; assert.equal(lender.accountVersion, 2); assert.equal(lender.identityVersion, 2); assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } })).updated_by, finance.id); await findCard(page, lenderCard); checks.push(`${s.mode}_employee_selection_masked_stale_refusal_reuse_missing_and_explicit_refresh`); await page.getByRole('button', { name: '登记借入', exact: true }).click(); dialog = page.getByRole('dialog'); await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id); const reference = `合成员工历史借入-${s.mode}`; await dialog.getByLabel('原借入凭据唯一引用').fill(reference); await dialog.getByLabel('原始本金', { exact: true }).fill('200.00'); await dialog.getByLabel('核对日未还本金').fill('200.00'); await dialog.getByLabel('实际到账日期').fill('2025-05-01'); await dialog.getByLabel('余额核对截止日').fill('2026-01-01'); await submit(page); const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference, lender_id: lender.id } }); await page.getByRole('tab', { name: '借款台账', exact: true }).click(); const loanCard = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${loan.loan_no}`, exact: true }) }); await findCard(page, loanCard); await loanCard.locator('summary').click(); for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) { const result = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST'); await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf', buffer: Buffer.from(`%PDF-1.4\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) }); assert.equal((await result).status(), 200); } await loanCard.getByRole('button', { name: '确认借款余额', exact: true }).click(); await page.getByRole('dialog').getByLabel('余额依据说明').fill('合成会计往来余额核对记录'); await submit(page); await expect(loanCard).toContainText('余额已确认'); await page.getByRole('tab', { name: '公司代表授权', exact: true }).click(); await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click(); dialog = page.getByRole('dialog'); await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached(); // D1: a response arriving is not the page being rendered. Step only after // the option list has loaded a different page (also covers cache hits). const lenderOptions = () => dialog.getByLabel('授权出借人').locator('option').evaluateAll(os => os.map(o => o.value).join(',')); for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${lender.id}"]`).count()); i++) { assert.ok(i < 20, 'authorized lender not found on any page'); const before = await lenderOptions(); const next = dialog.getByRole('button', { name: '下一页出借人', exact: true }); await expect(next).toBeEnabled({ timeout: 10000 }); await next.click(); await expect.poll(async () => { const now = await lenderOptions(); return now.split(',').length > 1 && now !== before; }, { timeout: 15000 }).toBe(true); } await dialog.getByLabel('授权出借人').selectOption(lender.id); await dialog.getByRole('checkbox', { name: new RegExp(loan.loan_no) }).check(); await dialog.getByLabel('代表权及授权范围依据').fill('合成财务登记已有公司代表权及此笔历史借款范围'); await submit(page); await expect(page.getByRole('button', { name: '代表公司确认授权范围', exact: true })).toHaveCount(0); const managerPage = await login(s.staff); await expect(managerPage.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0); await managerPage.getByRole('button', { name: '代表公司确认授权范围', exact: true }).click(); await expect(managerPage.getByRole('dialog')).toContainText(loan.loan_no); await managerPage.getByRole('dialog').getByLabel('确认或停止的理由').fill('合成公司代表核对本人收款身份与授权范围'); await submit(managerPage); const auth = await prisma.loanRelatedAuthorization.findFirstOrThrow({ where: { lender_id: lender.id }, include: { events: true } }); assert.equal(auth.approver_id, s.staff.id); assert.equal(auth.proposed_by, finance.id); assert.ok(auth.events.some(event => event.action === 'confirm' && event.actor_id === s.staff.id)); assert.equal(await prisma.loanRepayment.count({ where: { lender_id: lender.id } }), 0, 'source and authority setup never makes a repayment'); await managerPage.screenshot({ path: path.join(output, `employee-${s.mode}-authorization.png`), fullPage: true }); checks.push(`${s.mode}_employee_history_balance_finance_authority_and_manager_confirmation`); // Keep the same document alive across real logout/login, so document // teardown cannot accidentally provide the request cancellation guard. await page.evaluate(() => { window.__syntheticEmployeeDocument = 'same-document'; }); async function navigateWithinDocument(to) { await page.evaluate(url => { window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate')); }, to); } async function switchActor(who) { await navigateWithinDocument('/mobile/me'); await page.getByRole('button', { name: '退出登录', exact: true }).click(); await expect(page.getByTestId('login-submit')).toBeVisible(); await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); await expect(page.getByTestId('login-submit')).toHaveCount(0); await navigateWithinDocument(`${prefix}/company-loans`); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); assert.equal(await page.evaluate(() => window.__syntheticEmployeeDocument), 'same-document'); } for (const action of ['link', 'refresh']) { if (action === 'refresh') await switchActor(finance); else await navigateWithinDocument(`${prefix}/company-loans`); if (action === 'link') dialog = await selectEmployee(s.switchStaff, `浏览器切换-${s.mode}`); else { await page.getByRole('tab', { name: '出借人', exact: true }).click(); await findCard(page, lenderCard); await lenderCard.getByRole('button', { name: '使用当前员工资料', exact: true }).click(); dialog = page.getByRole('dialog'); await dialog.getByLabel('更新依据', { exact: true }).fill('合成旧会话操作不得转交新登录财务'); } await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check(); const endpoint = action === 'link' ? '/api/loan-lenders/from-employee' : `/api/loan-lenders/${lender.id}/employee-refresh`; const before = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } }); const auditBefore = await prisma.auditLog.count({ where: { target_id: lender.id, action: 'LOAN_LENDER_EMPLOYEE_REFRESHED' } }); let release; const gate = new Promise(resolve => { release = resolve; }); let started; const held = new Promise(resolve => { started = resolve; }); let handled; const finished = new Promise(resolve => { handled = resolve; }); let attempts = 0; let firstRequest; let aborted = false; const onFailed = request => { if (request === firstRequest) aborted = /aborted/i.test(request.failure()?.errorText ?? ''); }; const handler = async route => { attempts++; if (attempts > 1) return route.continue(); firstRequest = route.request(); started(); await gate; try { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late-401', error: { code: 'AUTH_001', message: '合成旧会话迟到响应' } }) }); } finally { handled(); } }; page.on('requestfailed', onFailed); await page.route(`**${endpoint}`, handler); try { await dialog.getByRole('button', { name: action === 'link' ? '确认关联员工' : '确认更新出借人资料', exact: true }).click(); await held; await switchActor(employeeSources.otherFinance); release(); await finished; await expect.poll(() => aborted, { message: 'leaving the source form must abort its old write request' }).toBe(true); assert.equal(attempts, 1, 'a late 401 must never replay the old write as the new actor'); assert.equal(await prisma.loanLender.count({ where: { employee_id: s.switchStaff.id } }), 0); const after = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } }); assert.equal(after.identity_version, before.identity_version); assert.equal(after.account_version, before.account_version); assert.equal(after.updated_by, before.updated_by); assert.equal(await prisma.auditLog.count({ where: { target_id: lender.id, action: 'LOAN_LENDER_EMPLOYEE_REFRESHED' } }), auditBefore); checks.push(`${s.mode}_${action}_late_401_aborted_across_real_actor_switch`); } finally { release(); page.off('requestfailed', onFailed); await page.unroute(`**${endpoint}`, handler); } } } catch (error) { for (const [index, context] of contexts.entries()) { const page = context.pages()[0]; if (page) await page.screenshot({ path: path.join(output, `employee-${s.mode}-failure-${index}.png`), fullPage: true, timeout: 5000 }).catch(() => {}); } throw error; } finally { for (const context of contexts) await context.close(); } } } finally { config.appOrigin = oldOrigin; if (own) await browser.close(); } console.log(JSON.stringify({ stage: 'loan_employee_browser', status: 'PASS', checks, output: path.relative(root, output) })); return checks; };