const assert = require('node:assert/strict'); const { randomUUID } = require('node:crypto'); const { mkdtempSync, rmSync, writeFileSync } = require('node:fs'); const { tmpdir } = require('node:os'); const path = require('node:path'); const { createRequire } = require('node:module'); module.exports = async function receiptBrowser({ root, prisma, config, employee, reviewer, manualFixture, serverRequire, passed }) { assert.equal(process.env.RUN_ISOLATED_DB_TEST, '1'); assert.ok(!process.argv.some(argument => /^dotenv_config_/i.test(argument))); assert.match(process.env.DATABASE_URL, /^postgresql:\/\/postgres:[^@]+@127\.0\.0\.1:\d+\/postgres$/); const directory = mkdtempSync(path.join(tmpdir(), 'receipt-browser-')); const previous = { ...process.env }; let app; let browser; let preview; let stage = 'setup'; try { const envFile = path.join(directory, 'synthetic.env'); writeFileSync(envFile, '', { mode: 0o600 }); process.env.DOTENV_CONFIG_PATH = envFile; delete process.env.DOTENV_CONFIG_OVERRIDE; for (const key of Object.keys(process.env)) { if (/^(WECOM_|TENCENT_|SMTP_|LIVE_SMOKE_)/.test(key)) delete process.env[key]; } Object.assign(process.env, { NODE_ENV: 'test', JWT_SECRET: randomUUID(), AUTH_CODE_PEPPER: randomUUID(), FIELD_ENCRYPTION_KEY: config.fieldEncryptionKey, STORAGE_DIR: directory, STORAGE_DRIVER: 'local', JOBS_ENABLED: 'false', OCR_PROVIDER: 'off', EMAIL_ENABLED: 'false', IM_NOTIFIER_ENABLED: 'false', ERP_SYNC_ENABLED: 'false', SERVE_CLIENT_DIST: 'false', COOKIE_SECURE: 'false', APP_ORIGIN: '', PAYMENT_BATCH_ENABLED: 'true', OPENAPI_DOCS_ENABLED: 'false' }); const password = `Synthetic!${randomUUID()}`; await prisma.employee.updateMany({ where: { id: { in: [employee.id, reviewer.id] } }, data: { password_hash: await serverRequire('./src/utils/crypto').hashPassword(password), must_change_password: false, } }); const context = await manualFixture('SYN-BROWSER'); await prisma.paymentBatch.update({ where: { id: context.batch.id }, data: { reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: 'bank', } }); await prisma.paymentBatchReceiptAttachment.updateMany({ data: { archived_at: new Date() } }); const sourceItems = await prisma.paymentBatchItem.findMany({ where: { payment_batch: { entity_type: 'reimbursement' } } }); for (const item of sourceItems) { await prisma.reimbursementForm.upsert({ where: { id: item.entity_id }, update: {}, create: { id: item.entity_id, submitter_id: employee.id, owner_id: employee.id, department_id: employee.department_id, reimbursement_date: new Date(), status: 'approved', total_amount: item.amount, } }); } const plugin = serverRequire('fastify-plugin'); app = await serverRequire('./src/app').buildApp({ prismaPlugin: plugin(async instance => { instance.decorate('prisma', prisma); }), startJobs: async () => {}, }); app.log.level = 'silent'; await app.listen({ host: '127.0.0.1', port: 0 }); const apiOrigin = `http://127.0.0.1:${app.server.address().port}`; const clientRequire = createRequire(path.join(root, 'client/package.json')); const vite = await import(clientRequire.resolve('vite')); stage = 'preview'; preview = await (vite.default ?? vite).preview({ configFile: false, envFile: false, root: path.join(root, 'client'), preview: { host: '127.0.0.1', port: 0, proxy: { '/api': apiOrigin } } }); const origin = `http://127.0.0.1:${preview.httpServer.address().port}`; app.config.appOrigin = origin; const { chromium, expect } = clientRequire('@playwright/test'); stage = 'browser_launch'; browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ serviceWorkers: 'block', viewport: { width: 1440, height: 1000 } }); page.setDefaultTimeout(15000); page.on('response', async response => { if (response.status() >= 400 && response.url().includes('/api/')) { const body = await response.json().catch(() => ({})); console.error(JSON.stringify({ status: 'SYNTHETIC_API_DIAGNOSTIC', endpoint: new URL(response.url()).pathname.replace(/[0-9a-f-]{36}/g, '[id]'), httpStatus: response.status(), code: body.error?.code, reason: body.error?.details?.reasonCode })); } }); let externalRequests = 0; await page.route('**/*', route => { const url = new URL(route.request().url()); if (url.origin !== origin && !['data:', 'blob:'].includes(url.protocol)) { externalRequests += 1; return route.abort(); } return route.continue(); }); stage = 'login'; await page.goto(origin + '/login'); await page.getByTestId('login-identifier').fill(employee.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); await expect(page).not.toHaveURL(/\/login/, { timeout: 20000 }); console.error(JSON.stringify({status: 'SYNTHETIC_COOKIE_DIAGNOSTIC', refreshPresent: (await page.context().cookies()).some(cookie => cookie.name === 'refresh_token')})); stage = 'upload'; await page.goto(origin + '/payment-batches?id=' + context.batch.id); const document = await serverRequire('pdf-lib').PDFDocument.create(); document.addPage().drawText('Synthetic receipt only'); await page.getByTestId('payment-batch-receipt-file-input').setInputFiles({ name: 'synthetic-receipt.pdf', mimeType: 'application/pdf', buffer: Buffer.from(await document.save()), }); await expect.poll(async () => prisma.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: context.batch.id, archived_at: null, receipt_ocr_state: 'queued' }, })).toBe(1); const { runReceiptOcrOnce } = serverRequire('./src/jobs/paymentReceiptOcrJob'); const { normalizeBankSlip } = serverRequire('./src/services/paymentReceiptMatching'); let providerCalls = 0; stage = 'worker'; const result = await runReceiptOcrOnce({ prisma, config: app.config, recognize: async () => { providerCalls += 1; return { status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, ...normalizeBankSlip(Object.entries({ 小写金额: '10.00', 币别: '人民币', 付款人账号: '000011112222', 收款人账号: '000033334444', 单据状态: '交易成功', 凭证号: 'SYN-BROWSER' }) .map(([Name, Value]) => ({ Name, Value }))) }] }; } }); assert.equal(result.status, 'matched'); assert.equal(providerCalls, 1); stage = 'register'; await page.reload(); await page.getByRole('button', { name: '登记付款完成', exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: /确认/ }).click(); await expect.poll(async () => (await prisma.reimbursementForm.findUnique({ where: { id: context.batch.items[0].entity_id }, })).status).toBe('paid'); assert.ok(await prisma.paymentReceiptClaim.findUnique({ where: { item_id: context.batch.items[0].id } })); assert.ok(await prisma.auditLog.count({ where: { action: 'PAYMENT_BATCH_EXECUTED', target_id: context.batch.id } })); assert.equal(externalRequests, 0); passed.push('browser_password_login_upload_worker_match_and_payment_same_database'); for (const scenario of ['wrong_amount', 'system_failure', 'duplicate_transaction']) { stage = scenario; const blocked = await manualFixture('SYN-' + scenario); await prisma.paymentBatch.update({ where: { id: blocked.batch.id }, data: { reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: 'bank', } }); await prisma.paymentBatchReceiptAttachment.update({ where: { id: blocked.attachment.id }, data: { archived_at: new Date() } }); await page.goto(origin + '/payment-batches?id=' + blocked.batch.id); await page.getByTestId('payment-batch-receipt-file-input').setInputFiles({ name: 'synthetic-' + scenario + '.pdf', mimeType: 'application/pdf', buffer: Buffer.from(await document.save()), }); await expect.poll(async () => prisma.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: blocked.batch.id, archived_at: null, receipt_ocr_state: 'queued', } })).toBe(1); const outcome = await runReceiptOcrOnce({ prisma, config: scenario === 'system_failure' ? { ...app.config, fieldEncryptionKey: '' } : app.config, recognize: async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, ...normalizeBankSlip(Object.entries({ 小写金额: scenario === 'wrong_amount' ? '11.00' : '10.00', 币别: '人民币', 付款人账号: '000011112222', 收款人账号: '000033334444', 单据状态: '交易成功', 凭证号: 'SYN-BROWSER' }).map(([Name, Value]) => ({ Name, Value }))) }] }), }); assert.equal(outcome.status, scenario === 'wrong_amount' ? 'needs_review' : scenario === 'system_failure' ? 'key_failed' : 'matched'); await page.reload(); await expect(page.getByText('回单尚未满足付款登记条件', { exact: false })).toBeVisible(); await expect(page.getByRole('button', { name: '登记付款完成', exact: true })).toHaveCount(0); if (scenario === 'system_failure') { await expect(page.getByRole('status').filter({ hasText: '回单尚未满足付款登记条件' })).toContainText('存在系统核验故障'); await expect(page.getByText('请查看核验详情', { exact: false })).toHaveCount(0); await expect(page.getByRole('button', { name: '重新识别', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '核验详情', exact: true })).toHaveCount(0); } assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: blocked.batch.items[0].entity_id } })).status, 'approved'); assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: blocked.batch.items[0].id } }), 0); passed.push('browser_' + scenario + '_does_not_record_payment'); } stage = 'identity_switch'; for (const actor of [reviewer, employee]) { await page.getByTestId('topbar-profile-trigger').click(); await page.getByRole('menuitem', { name: '退出登录', exact: true }).click(); await expect(page).toHaveURL(/\/login/); await expect(page.getByTestId('payment-batch-receipt-file-input')).toHaveCount(0); await page.getByTestId('login-identifier').fill(actor.email); await page.getByTestId('login-password').fill(password); const profileResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/me' && response.status() === 200); await page.getByTestId('login-submit').click(); const profile = await (await profileResponse).json(); assert.equal(profile.data.id, actor.id); await expect(page).not.toHaveURL(/\/login/); await expect(page.getByRole('dialog')).toHaveCount(0); } assert.equal(externalRequests, 0); passed.push('browser_real_logout_reviewer_login_and_finance_relogin'); } catch (error) { console.error(JSON.stringify({ status: 'RECEIPT_BROWSER_FAILED', stage, errorType: error.name, httpStatus: typeof error.actual === 'number' ? error.actual : null })); throw error; } finally { try { if (browser) await browser.close(); } finally { try { if (preview) await new Promise((resolve, reject) => preview.httpServer.close(error => error ? reject(error) : resolve())); } finally { try { if (app) await app.close(); } finally { rmSync(directory, { recursive: true, force: true }); for (const key of Object.keys(process.env)) if (!(key in previous)) delete process.env[key]; Object.assign(process.env, previous); } } } } };