const assert = require('node:assert/strict'); const path = require('node:path'); const { createRequire } = require('node:module'); const { mkdirSync, mkdtempSync } = require('node:fs'); const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs'); const root = path.resolve(__dirname, '../..'); const { chromium, expect } = createRequire(path.join(root, 'client/package.json'))('@playwright/test'); module.exports = async function lenderAckBrowser(h) { const { app, finance, ordinary, password, prisma, config, lenderAck } = h; const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); const address = app.server.address(); const origin = address ? `http://127.0.0.1:${address.port}` : await app.listen({ host: '127.0.0.1', port: 0 }); const oldOrigin = config.appOrigin; config.appOrigin = origin; const base = path.join(root, 'artifacts/qa-flow/loan-lender-ack-20261010'); mkdirSync(base, { recursive: true }); const output = mkdtempSync(path.join(base, 'browser-')); const checks = []; const apiRoot = '/api/loan-lender-acknowledgements'; try { for (const s of lenderAck.browserScenarios) { const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : ''; const contexts = []; const lenderPages = new Map(); async function openNotice(page, acknowledgementId) { const loaded = page.waitForResponse(r => { const url = new URL(r.url()); return r.request().method() === 'GET' && r.status() === 200 && url.pathname === '/api/notifications' && url.searchParams.get('type') === 'loan.lender_acknowledgement_requested' && url.searchParams.get('page') === '1' && url.searchParams.get('pageSize') === '20' && url.searchParams.get('unreadOnly') === '0'; }); await page.goto(`${origin}${prefix}/notifications?type=loan.lender_acknowledgement_requested`); const loadedResponse = await loaded; const items = (await loadedResponse.json()).data.items; const index = items.findIndex(item => item.targetType === 'loan_lender_acknowledgement' && item.targetId === acknowledgementId); assert.ok(index >= 0, `the actual lender inbox must include this statement notice: ${JSON.stringify({ query: new URL(loadedResponse.url()).search, items: items.length, mode: s.mode, })}`); if (mobile) { const cards = page.getByTestId('mobile-notification-card'); await expect(cards).toHaveCount(items.length); await cards.nth(index).click(); } else { const entry = page.getByRole('link', { name: '查看详情', exact: true }).and( page.locator(`[href="${prefix}/company-loans?lenderAcknowledgementId=${acknowledgementId}"]`)); await expect(entry).toBeVisible(); await entry.click(); } await expect(page.getByRole('dialog')).toBeVisible(); assert.equal(new URL(page.url()).searchParams.get('lenderAcknowledgementId'), acknowledgementId); } async function login(who, acknowledgementId) { const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block', timezoneId: 'UTC' }); contexts.push(context); await context.route('**/*', (route) => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); if (who.id === finance.id) page.on('response', async response => { const url = new URL(response.url()); if (url.pathname === '/api/loan-lenders' && response.status() === 200) { const body = await response.json().catch(() => null); if (body?.data?.items) lenderPages.set(Number(url.searchParams.get('page') ?? 1), body.data.items); } }); const viaNotice = who.id === s.signer.id; const target = viaNotice ? mobile ? '/mobile/me' : '/portal' : `${prefix}/company-loans`; await page.goto(`${origin}/login?redirect=${encodeURIComponent(target)}`); await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); if (viaNotice) { await expect(page.getByTestId('login-submit')).toHaveCount(0); if (mobile) await expect(page.getByRole('button', { name: '退出登录', exact: true })).toBeVisible(); else await expect(page.locator('nav').first()).toBeVisible(); await expect(page.getByRole('link', { name: '公司借款', exact: true })).toHaveCount(0); await openNotice(page, acknowledgementId); await expect(page.getByRole('heading', { name: '出借人借款声明', exact: true })).toBeVisible(); checks.push(`${s.mode}_own_lender_has_no_permanent_navigation_and_enters_statement_from_notice`); } else await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); return page; } let fp; let lp; try { fp = await login(finance); await fp.getByRole('tab', { name: '出借人确认', exact: true }).click(); async function openProposalUi(selected) { await fp.getByRole('button', { name: '发起出借人确认', exact: true }).click(); const dialog = fp.getByRole('dialog'); const select = dialog.getByLabel('声明出借人'); await expect(select).toBeVisible(); for (let pageNumber = 1; pageNumber <= 20; pageNumber++) { await expect.poll(() => lenderPages.has(pageNumber)).toBe(true); const expected = ['', ...lenderPages.get(pageNumber) .filter(l => l.employeeId && l.employeeId !== finance.id && l.identityStatus === 'verified').map(l => l.id)]; await expect.poll(() => select.locator('option').evaluateAll(options => options.map(o => o.value))).toEqual(expected); if (await select.locator(`option[value="${s.lender.id}"]`).count()) break; const next = dialog.getByRole('button', { name: '下一页出借人', exact: true }); await expect(next).toBeEnabled(); await next.click(); } await select.selectOption(s.lender.id); for (const loan of selected) await dialog.getByRole('checkbox', { name: new RegExp(loan.loanNo) }).check(); return dialog; } async function proposeUi(selected) { const dialog = await openProposalUi(selected); await dialog.getByLabel('发起说明', { exact: true }).fill('合成界面借款事实及余额核对'); const response = fp.waitForResponse(r => new URL(r.url()).pathname === apiRoot && r.request().method() === 'POST'); await dialog.getByRole('button', { name: '发送给出借人', exact: true }).click(); const result = await response; assert.equal(result.status(), 200); await expect(dialog).not.toBeVisible(); return (await result.json()).data; } const record = await proposeUi(s.loans.slice(0, 2)); const returned = await proposeUi([s.loans[2]]); const financeCard = fp.locator('section.card-std').filter({ hasText: s.lender.name }).filter({ hasText: '2 笔借款' }).first(); await financeCard.getByRole('button', { name: '查看借款确认记录' }).click(); await expect(fp.getByRole('dialog').getByRole('button', { name: '确认借款声明', exact: true })).toHaveCount(0); await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); checks.push(`${s.mode}_finance_proposes_multiple_loans_but_cannot_sign`); // A URL detail must override a draft in the same document; switching A // to B and browser-back to A must query/render the corresponding record. await fp.evaluate(() => { window.__syntheticAckHistoryDocument = 'same-document'; }); const draft = await openProposalUi([s.loans[2]]); await expect(draft.getByLabel('声明出借人')).toHaveValue(s.lender.id); await expect(draft.locator('legend')).toContainText('已选 1 笔'); await draft.getByLabel('发起说明', { exact: true }).fill('合成未发送草稿'); async function selectStatementInSpa(id) { await fp.evaluate((id) => { const url = new URL(window.location.href); url.searchParams.set('lenderAcknowledgementId', id); window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate')); }, id); } async function expectStatement(id, included, excluded) { assert.equal(new URL(fp.url()).searchParams.get('lenderAcknowledgementId'), id); const details = fp.getByRole('dialog'); await expect(details.getByRole('heading', { name: '出借人借款声明', exact: true })).toBeVisible(); for (const loan of included) await expect(details).toContainText(loan.loanNo); for (const loan of excluded) await expect(details).not.toContainText(loan.loanNo); await expect(details.getByLabel('发起说明', { exact: true })).toHaveCount(0); } await selectStatementInSpa(record.id); await expectStatement(record.id, s.loans.slice(0, 2), [s.loans[2]]); await selectStatementInSpa(returned.id); await expectStatement(returned.id, [s.loans[2]], s.loans.slice(0, 2)); await fp.goBack(); await expectStatement(record.id, s.loans.slice(0, 2), [s.loans[2]]); await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); await fp.getByRole('button', { name: '发起出借人确认', exact: true }).click(); await expect(fp.getByRole('dialog').getByLabel('发起说明', { exact: true })).toHaveValue(''); await expect(fp.getByRole('dialog').getByLabel('声明出借人')).toHaveValue(''); await expect(fp.getByRole('dialog').locator('legend')).toContainText('已选 0 笔'); assert.equal(await fp.evaluate(() => window.__syntheticAckHistoryDocument), 'same-document'); await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); checks.push(`${s.mode}_statement_url_overrides_draft_switches_records_and_browser_back_without_stale_draft`); lp = await login(s.signer, record.id); await expect(lp.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0); await expect(lp.getByRole('button', { name: '发起出借人确认', exact: true })).toHaveCount(0); let dialog = lp.getByRole('dialog'); for (const loan of s.loans.slice(0, 2)) await expect(dialog).toContainText(loan.loanNo); await expect(dialog).toContainText('2025年3月1日'); await expect(dialog).toContainText('2026年1月1日'); assert.ok(await dialog.evaluate(el => el.scrollWidth - el.clientWidth) <= 1, 'statement dialog has no horizontal overflow'); await lp.screenshot({ path: path.join(output, `${s.mode}-statement.png`), fullPage: true }); const [downloadResponse, download] = await Promise.all([ lp.waitForResponse(r => r.url().endsWith('/download') && r.request().method() === 'POST'), lp.waitForEvent('download'), dialog.getByRole('button', { name: '查看原借入凭据:synthetic.pdf', exact: true }).first().click(), ]); assert.equal(downloadResponse.status(), 200); assert.match(downloadResponse.request().headers()['content-type'], /application\/json/); assert.equal(await download.failure(), null); const confirm = dialog.getByRole('button', { name: '确认借款声明', exact: true }); await expect(confirm).toBeDisabled(); await dialog.getByRole('checkbox').check(); await dialog.getByLabel('确认或退回说明').fill('合成本人核对原件及金额无误'); // Same identity retains one normal token refresh, without duplicate decisions. let attempts = 0; const expireOnce = route => ++attempts === 1 ? route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-expired', error: { code: 'AUTH_001', message: '合成过期' } }) }) : route.continue(); await lp.route(`**${apiRoot}/${record.id}/decision`, expireOnce); try { await Promise.all([lp.waitForResponse(r => r.url().endsWith(`/${record.id}/decision`) && r.status() === 200), confirm.click()]); await expect(dialog).not.toBeVisible(); assert.equal(attempts, 2); } finally { await lp.unroute(`**${apiRoot}/${record.id}/decision`, expireOnce); } assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: record.id } }), 1); await expect(lp.getByText('出借人已确认', { exact: true })).toBeVisible(); assert.ok(await lp.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) <= 1); await lp.screenshot({ path: path.join(output, `${s.mode}-confirmed.png`), fullPage: true }); checks.push(`${s.mode}_own_lender_downloads_original_checks_statement_and_confirms_with_same_actor_refresh`); // Finance still needs accounting evidence; actual UI upload then confirms balance. await fp.getByRole('tab', { name: '借款台账', exact: true }).click(); await fp.reload(); const loan = s.loans[0]; const card = fp.locator('section.card-std').filter({ has: fp.getByRole('heading', { name: `${s.lender.name} · ${loan.loanNo}`, exact: true }) }); for (let i = 0; i < 30 && !await card.count(); i++) { const first = fp.locator('section.card-std').first(); await expect(first).toBeVisible(); if (await card.count()) break; const oldText = await first.textContent(); const next = fp.getByRole('button', { name: '下一页', exact: true }); await expect(next).toBeEnabled(); await Promise.all([fp.waitForResponse(r => new URL(r.url()).pathname === '/api/company-loans'), next.click()]); await expect(first).not.toHaveText(oldText); } await card.locator('summary').click(); await expect(card).toContainText('已有有效的出借人在线确认'); await card.getByRole('button', { name: '确认期初余额', exact: true }).click(); dialog = fp.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账明细科目和期间'); const denied = fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`)); await dialog.getByRole('button', { name: '确认', exact: true }).click(); assert.equal((await denied).status(), 409); await expect(dialog.getByRole('alert')).toBeVisible(); await dialog.press('Escape'); await expect(dialog).not.toBeVisible(); const uploaded = fp.waitForResponse(r => r.url().endsWith('/evidences/accounting_confirmation')); await card.getByLabel('余额核对材料', { exact: true }).setInputFiles({ name: 'synthetic-accounting.pdf', mimeType: 'application/pdf', buffer: await syntheticBankReceiptPdf({ reference: `SYN-ACCOUNTING-${s.mode}`, amount: '100.00' }) }); assert.equal((await uploaded).status(), 200); await card.getByRole('button', { name: '确认期初余额', exact: true }).click(); dialog = fp.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账明细科目和期间'); await Promise.all([fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`) && r.status() === 200), dialog.getByRole('button', { name: '确认', exact: true }).click()]); await expect(dialog).not.toBeVisible(); await expect(card).toContainText('余额已确认'); checks.push(`${s.mode}_accounting_required_ui_upload_then_balance_confirmation`); const beforeRenewal = await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } }); await lenderAck.upload(loan); await openNotice(lp, record.id); await expect(lp.getByRole('dialog')).toContainText('内容或来源已变化,需重新发起'); await expect(lp.getByRole('dialog').getByRole('button', { name: '确认借款声明', exact: true })).toHaveCount(0); await lp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); checks.push(`${s.mode}_historical_notice_opens_invalid_statement_without_reactivating_confirmation`); await fp.getByRole('tab', { name: '出借人确认', exact: true }).click(); const renewed = await proposeUi([loan]); await openNotice(lp, renewed.id); dialog = lp.getByRole('dialog'); await dialog.getByRole('checkbox').check(); await dialog.getByLabel('确认或退回说明').fill('合成本人重新核对新原件及原截至日余额'); await Promise.all([lp.waitForResponse(r => r.url().endsWith(`/${renewed.id}/decision`) && r.status() === 200), dialog.getByRole('button', { name: '确认借款声明', exact: true }).click()]); await expect(dialog).not.toBeVisible(); assert.deepEqual(await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } }), beforeRenewal); checks.push(`${s.mode}_invalid_statement_can_be_renewed_after_balance_confirmation_without_changing_balance`); await fp.getByRole('tab', { name: '出借人确认', exact: true }).click(); await lp.reload(); await lp.getByRole('button', { name: '核对并处理借款声明', exact: true }).click(); dialog = lp.getByRole('dialog'); await dialog.getByLabel('确认或退回说明').fill('合成借款金额待财务重新核对'); await Promise.all([lp.waitForResponse(r => r.url().endsWith(`/${returned.id}/decision`) && r.status() === 200), dialog.getByRole('button', { name: '退回财务', exact: true }).click()]); await expect(dialog).not.toBeVisible(); await expect(lp.getByText('已退回财务', { exact: true })).toBeVisible(); const reissued = await proposeUi([s.loans[2]]); assert.notEqual(reissued.id, returned.id); checks.push(`${s.mode}_lender_returns_and_finance_reissues_immutable_request`); // Hold a decision, switch to another real account in the same SPA, deliver // a late 401: the old mutation is aborted and never replayed as new actor. await lp.reload(); await lp.getByRole('button', { name: '核对并处理借款声明', exact: true }).click(); dialog = lp.getByRole('dialog'); await dialog.getByRole('checkbox').check(); await dialog.getByLabel('确认或退回说明').fill('合成账号切换中的旧确认'); let release; const gate = new Promise(resolve => { release = resolve; }); let attemptsSwitch = 0; let firstRequest; let aborted = false; let started; const holding = new Promise(resolve => { started = resolve; }); const onFailed = request => { if (request === firstRequest) aborted = /aborted/i.test(request.failure()?.errorText ?? ''); }; const handler = async route => { attemptsSwitch++; firstRequest = route.request(); started(); await gate; await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late', error: { code: 'AUTH_001', message: '合成迟到响应' } }) }).catch(() => {}); }; lp.on('requestfailed', onFailed); await lp.route(`**${apiRoot}/${reissued.id}/decision`, handler); try { await lp.evaluate(() => { window.__syntheticAckDocument = 'same-document'; }); await dialog.getByRole('button', { name: '确认借款声明', exact: true }).click(); await holding; await lp.evaluate(() => { window.history.pushState({}, '', '/mobile/me'); window.dispatchEvent(new PopStateEvent('popstate')); }); await lp.getByRole('button', { name: '退出登录', exact: true }).click(); await expect(lp.getByTestId('login-submit')).toBeVisible(); await lp.getByTestId('login-identifier').fill(ordinary.email); await lp.getByTestId('login-password').fill(password); await lp.getByTestId('login-submit').click(); await expect(lp.getByTestId('login-submit')).toHaveCount(0); release(); await expect.poll(() => aborted).toBe(true); assert.equal(await lp.evaluate(() => window.__syntheticAckDocument), 'same-document'); assert.equal(attemptsSwitch, 1); assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: reissued.id } }), 0); } finally { release(); lp.off('requestfailed', onFailed); await lp.unroute(`**${apiRoot}/${reissued.id}/decision`, handler); } checks.push(`${s.mode}_late_401_decision_aborted_across_real_actor_switch_without_replay`); } catch (error) { for (const [name, page] of [['finance', fp], ['lender', lp]]) if (page) await page.screenshot({ path: path.join(output, `${s.mode}-${name}-failure.png`), fullPage: true, timeout: 5000 }).catch(() => {}); throw error; } finally { for (const context of contexts) await context.close(); } } } finally { config.appOrigin = oldOrigin; await browser.close(); } console.log(JSON.stringify({ stage: 'loan_lender_ack_browser', status: 'PASS', checks, output: path.relative(root, output) })); return checks; };