文件
hyb-oa/scripts/qa/company-loan-lender-ack-browser.cjs
T
wangsli和Claude Opus 5.5 c251729267
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
借款材料区按需显示并前置缺项提示(09-17-company-loan-principal-repayment)
- 材料区只为仍缺的材料提供上传:原件已存显示可查看,在线确认后收起协议框,历史还款依据仅在登记期初小于原本金时出现
- 余额材料改中性文案(公司银行流水或代账明细账),确认字段改为“余额依据说明”,按钮改为“确认借款余额”并前置列出缺项
- 确认弹窗跟随最新列表状态:刷新中、刷新失败、版本变化、已确认或缺项时禁用按钮与提交
- 浏览器测试覆盖显隐、缺项、材料失效、在线确认失效、刷新与弹窗打开后状态变化;同步操作手册
- 只读复核 P2 已修复关闭;不改后端、接口与数据库

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:58:10 +08:00

382 行
29 KiB
JavaScript

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 }) });
const showCard = async () => {
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 showCard();
await card.locator('summary').click(); await expect(card).toContainText('出借人已在线确认');
// With the original saved and a valid online statement, only the balance
// material is still needed: no agreement or repayment-evidence upload, and
// confirmation stays disabled with the gap named up front.
await expect(card).toContainText('已保存,可查看');
await expect(card.locator(`[id="${loan.id}-agreement"]`)).toHaveCount(0);
await expect(card.locator(`[id="${loan.id}-incoming"]`)).toHaveCount(0);
await expect(card.locator(`[id="${loan.id}-adjustment"]`)).toHaveCount(0);
await expect(card.locator(`[id="${loan.id}-missing"]`)).toHaveText('还缺:余额核对材料');
await expect(card.getByRole('button', { name: '确认借款余额', exact: true })).toBeDisabled();
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 expect(card.locator(`[id="${loan.id}-missing"]`)).toHaveCount(0);
await expect(card.getByRole('button', { name: '确认借款余额', exact: true })).toBeEnabled();
// Display rules against edge states the real list can carry; only this
// loan's fields in the list response are altered, saving stays real.
const listUrl = (u) => new URL(u).pathname === '/api/company-loans';
const asListed = async (patch, check) => {
const handler = async (route) => {
if (route.request().method() !== 'GET') return route.continue();
const response = await route.fetch(); const body = await response.json();
for (const item of body.data?.items ?? []) if (item.id === loan.id) patch(item);
await route.fulfill({ response, json: body });
};
await fp.route(listUrl, handler);
try { await fp.reload(); await showCard(); await card.locator('summary').click(); await check(); }
finally { await fp.unroute(listUrl, handler); }
};
const confirmButton = card.getByRole('button', { name: '确认借款余额', exact: true });
const missingLine = card.locator(`[id="${loan.id}-missing"]`);
await asListed((item) => { item.outstanding = '40.00'; }, async () => {
await expect(card.locator(`[id="${loan.id}-adjustment"]`)).toHaveCount(0); await expect(confirmButton).toBeEnabled();
});
await asListed((item) => { item.openingOutstandingPrincipal = '80.00'; }, async () => {
await expect(card.locator(`[id="${loan.id}-adjustment"]`)).toHaveCount(1);
await expect(missingLine).toHaveText('还缺:系统外已还本金依据'); await expect(confirmButton).toBeDisabled();
});
await asListed((item) => { for (const e of item.evidences) if (e.kind === 'accounting_confirmation') e.governanceStatus = 'quarantined'; }, async () => {
await expect(missingLine).toHaveText('还缺:余额核对材料'); await expect(confirmButton).toBeDisabled();
await expect(card).toContainText('(未通过检查,不计入)');
});
await asListed((item) => { item.lenderAcknowledgement = null; }, async () => {
await expect(card.locator(`[id="${loan.id}-agreement"]`)).toHaveCount(1);
await expect(missingLine).toHaveText('还缺:协议或借款确认书'); await expect(confirmButton).toBeDisabled();
await expect(card.getByRole('button', { name: '去出借人确认查看或办理', exact: true })).toBeVisible();
});
// A failed refresh after an upload must not leave confirmation enabled on the old snapshot.
await fp.reload(); await showCard(); await card.locator('summary').click(); await expect(confirmButton).toBeEnabled();
const failRefresh = (route) => route.request().method() === 'GET' ? route.fulfill({ status: 500, json: { error: { code: 'SYS_500', message: 'synthetic' } } }) : route.continue();
await fp.route(listUrl, failRefresh);
try {
const again = fp.waitForResponse(r => r.url().endsWith('/evidences/accounting_confirmation'));
await card.getByLabel('上传余额核对材料', { exact: true }).setInputFiles({ name: 'synthetic-accounting-2.pdf', mimeType: 'application/pdf',
buffer: await syntheticBankReceiptPdf({ reference: `SYN-ACCOUNTING-2-${s.mode}`, amount: '100.00' }) });
assert.equal((await again).status(), 200);
// Wait for the final failure (after the retry), not the in-flight refresh.
await expect(fp.getByRole('alert').first()).toBeVisible({ timeout: 20000 });
// A failed list hides the cards entirely; either way no confirmation is offered.
await expect.poll(async () => !await confirmButton.count() || await confirmButton.isDisabled()).toBe(true);
} finally { await fp.unroute(listUrl, failRefresh); }
await fp.reload(); await showCard(); await card.locator('summary').click(); await expect(confirmButton).toBeEnabled();
checks.push(`${s.mode}_material_slots_follow_registered_balance_validity_statement_and_refresh_state`);
await card.getByRole('button', { name: '确认借款余额', exact: true }).click();
dialog = fp.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: '确认借款余额', exact: true })).toBeVisible();
await expect(dialog).toContainText('示例:公司银行流水');
await expect(dialog.getByLabel('余额依据说明')).toHaveValue('');
await dialog.getByLabel('余额依据说明').fill('合成公司银行流水,借入日至核对截止日');
// With the dialog already open, a reconnect refresh (past the 5 s staleTime)
// that is pending, finally fails, or drops the online statement blocks saving
// — button and keyboard submit alike — until the live list is good again.
let confirmPosts = 0;
const countConfirm = (r) => { if (r.url().endsWith(`/${loan.id}/confirm`) && r.method() === 'POST') confirmPosts++; };
fp.on('request', countConfirm);
const dialogConfirm = dialog.getByRole('button', { name: '确认', exact: true });
const blocker = dialog.locator('#loan-confirm-blocker');
const reconnect = async () => {
await fp.waitForTimeout(5200);
await fp.evaluate(() => { window.dispatchEvent(new Event('offline')); window.dispatchEvent(new Event('online')); });
};
const blockedSubmit = async () => {
await expect(dialogConfirm).toBeDisabled(); await dialog.getByLabel('余额依据说明').press('Enter');
await fp.waitForTimeout(300); assert.equal(confirmPosts, 0, 'a blocked dialog must not save');
};
try {
let release; const gate = new Promise((resolve) => { release = resolve; });
// After a failed assertion the cleanup may unroute first; the late continue is then moot.
const held = async (route) => { if (route.request().method() === 'GET') await gate; await route.continue().catch(() => {}); };
await fp.route(listUrl, held);
try {
await reconnect(); await expect(blocker).toHaveText('借款列表正在刷新,请稍候。'); await blockedSubmit();
const landed = fp.waitForResponse((r) => listUrl(r.url()) && r.request().method() === 'GET'); release(); await landed;
} finally { release(); await fp.unroute(listUrl, held); }
await expect(blocker).toHaveCount(0); await expect(dialogConfirm).toBeEnabled();
await fp.route(listUrl, failRefresh);
try {
await reconnect(); await expect(blocker).toHaveText('借款列表未能刷新,请关闭后重新打开。', { timeout: 20000 }); await blockedSubmit();
} finally { await fp.unroute(listUrl, failRefresh); }
await reconnect(); await expect(blocker).toHaveCount(0, { timeout: 20000 }); await expect(dialogConfirm).toBeEnabled();
const lapsed = async (route) => {
if (route.request().method() !== 'GET') return route.continue();
const response = await route.fetch(); const body = await response.json();
for (const item of body.data?.items ?? []) if (item.id === loan.id) item.lenderAcknowledgement = null;
await route.fulfill({ response, json: body });
};
await fp.route(listUrl, lapsed);
try {
await reconnect(); await expect(blocker).toHaveText('还缺:协议或借款确认书', { timeout: 20000 }); await blockedSubmit();
} finally { await fp.unroute(listUrl, lapsed); }
await reconnect(); await expect(blocker).toHaveCount(0, { timeout: 20000 }); await expect(dialogConfirm).toBeEnabled();
} finally { fp.off('request', countConfirm); }
checks.push(`${s.mode}_open_confirm_dialog_follows_live_refresh_error_and_statement_state`);
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('余额已确认');
// The failed-refresh case remounted the cards, which collapses the section again.
if (!await card.locator('details').evaluate((d) => d.open)) await card.locator('summary').click();
await expect(card.getByRole('button', { name: '查看或办理公司代表授权', exact: true })).toBeVisible();
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;
};