const assert = require('node:assert/strict'); const path = require('node:path'); const { createHash, randomUUID } = require('node:crypto'); const { mkdtempSync, mkdirSync } = require('node:fs'); const { createRequire } = require('node:module'); const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs'); const root = path.resolve(__dirname, '../..'); const clientRequire = createRequire(path.join(root, 'client/package.json')); const { chromium, expect } = clientRequire('@playwright/test'); // N5-P desktop and mobile: upload once, check prefilled values, correct, save // loan and original together; restricted files, late results and actor switches. module.exports = async function incomingBrowser(h) { const { app, finance, otherFinance, password, prisma, config, company, incomingReceipt } = h; const lender = incomingReceipt.lender; const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); const origin = await app.listen({ host: '127.0.0.1', port: 0 }); const base = path.join(root, 'artifacts/qa-flow/loan-incoming-prefill-20261009'); mkdirSync(base, { recursive: true }); const output = mkdtempSync(path.join(base, 'browser-')); const oldOrigin = config.appOrigin; config.appOrigin = origin; const checks = []; const sha = (buffer) => createHash('sha256').update(buffer).digest('hex'); const pdf = (options = {}) => syntheticBankReceiptPdf({ direction: 'credit', status: 'booked', amount: '50,000.00', reference: `UI-IN-${randomUUID().slice(0, 8)}`, bookedOn: '2025-03-14', extraLines: [`付款人名称 ${lender.name}`, `收款人名称 ${company.name}`], ...options }); try { for (const mode of ['desktop', 'mobile']) { const mobile = mode === 'mobile'; const prefix = mobile ? '/mobile' : ''; const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); try { await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${prefix}/company-loans`)}`); await page.getByTestId('login-identifier').fill(finance.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); const openDialog = async () => { await page.getByRole('button', { name: '上传回单登记借入', exact: true }).click(); const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: '上传回单登记借入' })).toBeVisible(); return dialog; }; const choose = async (dialog, buffer) => dialog.getByLabel('原借入回单', { exact: true }) .setInputFiles({ name: 'incoming.pdf', mimeType: 'application/pdf', buffer }); const recognized = () => page.waitForResponse(r => r.url().endsWith('/api/company-loans/incoming-receipt/recognize') && r.request().method() === 'POST'); // Normal path: prefilled values are checked, one corrected, saved with the original. const reference = `UI-IN-${mode}-${randomUUID().slice(0, 6)}`.toUpperCase(); const file = await pdf({ reference }); let dialog = await openDialog(); const first = recognized(); await choose(dialog, file); assert.equal((await first).status(), 200); await expect(dialog.getByLabel('原始本金', { exact: true })).toHaveValue('50000.00'); await expect(dialog.getByLabel('实际到账日期', { exact: true })).toHaveValue('2025-03-14'); await expect(dialog.getByLabel('原借入凭据唯一引用', { exact: true })).toHaveValue(reference); await expect(dialog.getByLabel('出借人', { exact: true })).toHaveValue(lender.id); await expect(dialog.getByLabel('回单识别结果')).toContainText('已从 PDF 文字读取'); await dialog.getByLabel('实际到账日期', { exact: true }).fill('2025-03-15'); await expect(dialog.getByText('已人工修改')).toBeVisible(); await dialog.getByLabel('核对日未还本金', { exact: true }).fill('30000.00'); await dialog.getByLabel('余额核对截止日', { exact: true }).fill('2026-01-01'); const saveButton = dialog.getByRole('button', { name: '保存借款及原件', exact: true }); await expect(saveButton).toBeDisabled(); await dialog.getByRole('checkbox', { name: '已查看完整原件,金额、日期和凭据号与原件一致' }).check(); await dialog.getByRole('checkbox', { name: '已核对这笔入账是向该出借人的借款,且收款主体为所选公司' }).check(); // The same actor's expired access token is refreshed once and the save completes. let saveAttempts = 0; const expireOnce = async route => { saveAttempts++; if (saveAttempts === 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('**/api/company-loans/from-incoming-receipt', expireOnce); try { const saved = page.waitForResponse(r => r.url().endsWith('/api/company-loans/from-incoming-receipt') && r.request().method() === 'POST' && r.status() === 200); await saveButton.click(); await saved; await expect(page.getByRole('dialog')).not.toBeVisible(); assert.equal(saveAttempts, 2, 'same-actor expiry keeps the normal single refresh retry'); } finally { await page.unroute('**/api/company-loans/from-incoming-receipt', expireOnce); } const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference, lender_id: lender.id }, include: { evidences: true } }); assert.equal(loan.confirmation_status, 'unconfirmed'); assert.equal(loan.received_date.toISOString().slice(0, 10), '2025-03-15'); assert.deepEqual(loan.evidences.map(e => [e.kind, e.file_sha256]), [['incoming', sha(file)]]); const card = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${loan.loan_no}`, exact: true }) }); await expect(card).toContainText('余额待核实'); await card.locator('summary').click(); await expect(card.getByRole('button', { name: 'incoming.pdf' })).toBeVisible(); const scroll = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); assert.ok(scroll <= 1, `${mode} has no horizontal overflow`); await page.screenshot({ path: path.join(output, `incoming-${mode}-saved.png`), fullPage: true }); checks.push(`${mode}_upload_prefill_correct_same_actor_refresh_and_save_with_original`); // A restricted document cannot be saved through this entry. dialog = await openDialog(); const restricted = recognized(); await choose(dialog, await pdf({ final: false })); await restricted; await expect(dialog.getByRole('alert')).toContainText('不能作为原借入回单'); await expect(dialog.getByRole('button', { name: '保存借款及原件', exact: true })).toBeDisabled(); await page.screenshot({ path: path.join(output, `incoming-${mode}-blocked.png`), fullPage: true }); // A late result for a replaced file never fills the new form. let release; const gate = new Promise(resolve => { release = resolve; }); let held = 0; const delay = async route => { held++; if (held === 1) await gate; return route.continue(); }; await page.route('**/api/company-loans/incoming-receipt/recognize', delay); try { const late = pdf({ amount: '11.00' }); const latest = pdf({ amount: '12.00' }); await choose(dialog, await late); await expect.poll(() => held).toBe(1); const second = recognized(); await choose(dialog, await latest); await second; release(); await expect(dialog.getByLabel('原始本金', { exact: true })).toHaveValue('12.00'); await page.waitForTimeout(300); await expect(dialog.getByLabel('原始本金', { exact: true })).toHaveValue('12.00'); } finally { release(); await page.unroute('**/api/company-loans/incoming-receipt/recognize', delay); } checks.push(`${mode}_restricted_blocked_and_late_result_cannot_overwrite_new_file`); // A refreshed lender list with a new version clears the checks; saving // needs a fresh review and then carries the new version. const versionRef = `UI-VER-${mode}-${randomUUID().slice(0, 6)}`.toUpperCase(); const versionFile = await pdf({ reference: versionRef, amount: '14.00' }); const fourth = recognized(); await choose(dialog, versionFile); await fourth; await dialog.getByLabel('核对日未还本金', { exact: true }).fill('14.00'); await dialog.getByLabel('余额核对截止日', { exact: true }).fill('2026-01-01'); const originalBox = dialog.getByRole('checkbox', { name: '已查看完整原件,金额、日期和凭据号与原件一致' }); const natureBox = dialog.getByRole('checkbox', { name: '已核对这笔入账是向该出借人的借款,且收款主体为所选公司' }); await originalBox.check(); await natureBox.check(); const row = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } }); await prisma.loanLender.update({ where: { id: lender.id }, data: { account_version: { increment: 1 } } }); await page.waitForTimeout(5500); const refetched = page.waitForResponse(r => new URL(r.url()).pathname === '/api/loan-lenders' && r.request().method() === 'GET'); await context.setOffline(true); await context.setOffline(false); await refetched; await expect(dialog.getByText('所选出借人的资料已更新,请重新核对后再勾选确认。')).toBeVisible(); await expect(originalBox).not.toBeChecked(); await expect(natureBox).not.toBeChecked(); await expect(dialog.getByRole('button', { name: '保存借款及原件', exact: true })).toBeDisabled(); await originalBox.check(); await natureBox.check(); const versionSave = page.waitForResponse(r => r.url().endsWith('/api/company-loans/from-incoming-receipt') && r.request().method() === 'POST'); await dialog.getByRole('button', { name: '保存借款及原件', exact: true }).click(); const versionResponse = await versionSave; assert.equal(versionResponse.status(), 200); assert.equal(JSON.parse(versionResponse.request().postDataBuffer().toString('latin1').match(/name="payload"\r\n\r\n(.*?)\r\n/s)[1]).expectedLenderAccountVersion, row.account_version + 1); assert.equal(await prisma.companyLoan.count({ where: { origin_reference: versionRef } }), 1); await expect(page.getByRole('dialog')).not.toBeVisible(); checks.push(`${mode}_refreshed_lender_version_clears_checks_and_requires_new_review`); dialog = await openDialog(); // A held save answered with a late 401 after a real actor switch is aborted, not replayed. const switchRef = `UI-SWITCH-${mode}-${randomUUID().slice(0, 6)}`.toUpperCase(); const switchFile = await pdf({ reference: switchRef, amount: '13.00' }); const third = recognized(); await choose(dialog, switchFile); await third; await dialog.getByLabel('核对日未还本金', { exact: true }).fill('13.00'); await dialog.getByLabel('余额核对截止日', { exact: true }).fill('2026-01-01'); await dialog.getByRole('checkbox', { name: '已查看完整原件,金额、日期和凭据号与原件一致' }).check(); await dialog.getByRole('checkbox', { name: '已核对这笔入账是向该出借人的借款,且收款主体为所选公司' }).check(); await page.evaluate(() => { window.__syntheticIncomingDocument = 'same-document'; }); const within = (to) => page.evaluate(url => { window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate')); }, to); let releaseSave; const saveGate = new Promise(resolve => { releaseSave = resolve; }); let started; const holding = new Promise(resolve => { started = 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 saveGate; await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late-401', error: { code: 'AUTH_001', message: '合成旧会话迟到响应' } }) }).catch(() => {}); }; page.on('requestfailed', onFailed); await page.route('**/api/company-loans/from-incoming-receipt', handler); try { await dialog.getByRole('button', { name: '保存借款及原件', exact: true }).click(); await holding; await within('/mobile/me'); await page.getByRole('button', { name: '退出登录', exact: true }).click(); await expect(page.getByTestId('login-submit')).toBeVisible(); await page.getByTestId('login-identifier').fill(otherFinance.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); await expect(page.getByTestId('login-submit')).toHaveCount(0); assert.equal(await page.evaluate(() => window.__syntheticIncomingDocument), 'same-document'); releaseSave(); await expect.poll(() => aborted, { message: 'leaving the form must abort its old save' }).toBe(true); assert.equal(attempts, 1, 'a late 401 must never replay the old save as the new actor'); assert.equal(await prisma.companyLoan.count({ where: { origin_reference: switchRef } }), 0); } finally { releaseSave(); page.off('requestfailed', onFailed); await page.unroute('**/api/company-loans/from-incoming-receipt', handler); } checks.push(`${mode}_late_401_save_aborted_across_real_actor_switch`); } catch (error) { await page.screenshot({ path: path.join(output, `incoming-${mode}-failure.png`), fullPage: true, timeout: 5000 }).catch(() => {}); throw error; } finally { await context.close(); } } } finally { config.appOrigin = oldOrigin; await browser.close(); } console.log(JSON.stringify({ stage: 'loan_incoming_browser', status: 'PASS', checks, output: path.relative(root, output) })); return checks; };