修复授权分页并补齐追回到账诊断验证(09-17-company-loan-principal-repayment)
这个提交包含在:
@@ -10,12 +10,23 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
for (const mobile of [false, true]) {
|
||||
const mode = mobile ? 'mobile' : 'desktop';
|
||||
let view;
|
||||
let traceNumber = 0;
|
||||
async function closeView() {
|
||||
if (!view) return;
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund') {
|
||||
await view.context.tracing.stop({ path: path.join(output, `${mode}-refund-${++traceNumber}.zip`) });
|
||||
}
|
||||
await view.context.close(); view = null;
|
||||
}
|
||||
const uploadedByKind = new Map();
|
||||
async function login(who, batch) {
|
||||
if (view) await view.context.close();
|
||||
await closeView();
|
||||
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();
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund') {
|
||||
await context.tracing.start({ screenshots: true, snapshots: true });
|
||||
}
|
||||
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batch.id}`)}`);
|
||||
await page.getByTestId('login-identifier').fill(who.email);
|
||||
await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click();
|
||||
@@ -63,6 +74,91 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
await d.getByLabel('回单流水号').fill(reference);
|
||||
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成官方查询逐一排除其他同金额付款和退款交易');
|
||||
}
|
||||
async function diagnoseRefund(late) {
|
||||
const page = view.page;
|
||||
const endpoint = `/api/payment-batches/${late.id}/loan-bank-results`;
|
||||
const detail = url => new URL(url).pathname === `/api/payment-batches/${late.id}`;
|
||||
const post = r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST';
|
||||
const record = (step, extra = {}) => console.log(JSON.stringify({ stage: 'loan_refund_submission_diagnostic', mode, step, batchId: late.id, ...extra }));
|
||||
// Hold the actual refresh, rather than hoping an arbitrary sleep outlasts
|
||||
// the browser. The old strategy must select the sole placeholder.
|
||||
let releaseRefresh;
|
||||
const refreshGate = new Promise(resolve => { releaseRefresh = resolve; });
|
||||
const started = page.waitForRequest(r => detail(r.url()) && r.method() === 'GET');
|
||||
const slow = async route => { if (route.request().method() === 'GET') await refreshGate; await route.continue(); };
|
||||
await page.route(detail, slow);
|
||||
let submitted = 0;
|
||||
const countPost = request => { if (new URL(request.url()).pathname === endpoint && request.method() === 'POST') submitted++; };
|
||||
page.on('request', countPost);
|
||||
try {
|
||||
await upload('incoming_return'); await started;
|
||||
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click();
|
||||
const d = page.getByRole('dialog'); const select = d.getByLabel('对应的成功银行回单');
|
||||
const values = await select.locator('option').evaluateAll(os => os.map(o => o.value));
|
||||
assert.deepEqual(values, ['']); await select.selectOption(values.at(-1));
|
||||
await d.getByLabel('回单付款账号').fill(late.payee); await d.getByLabel('回单收款账号').fill(late.payer);
|
||||
await d.getByLabel('回单人民币金额').fill('15.00'); await d.getByLabel('回单流水号').fill(`${late.reference}-OLD-PICK`);
|
||||
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成原选项策略对照'); await d.getByLabel('依据与说明').fill('合成旧策略原生表单校验探针');
|
||||
assert.deepEqual(await d.locator('input:invalid, select:invalid, textarea:invalid').evaluateAll(es => es.map(el => el.getAttribute('aria-label'))), ['对应的成功银行回单']);
|
||||
await select.evaluate(el => { el.dataset.syntheticInvalid = '0'; el.addEventListener('invalid', () => { el.dataset.syntheticInvalid = '1'; }); });
|
||||
await d.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(select).toHaveAttribute('data-synthetic-invalid', '1');
|
||||
assert.equal(submitted, 0); await expect(d).toBeVisible(); assert.deepEqual(await d.getByRole('alert').allTextContents(), []);
|
||||
record('native_validation_no_post', { oldSelectionPicks: 'placeholder', postCount: submitted, formBlocked: true });
|
||||
releaseRefresh();
|
||||
const uploadedId = uploadedByKind.get('incoming_return');
|
||||
await expect(select.locator(`option[value="${uploadedId}"]`)).toBeAttached({ timeout: 20000 });
|
||||
await page.keyboard.press('Escape'); await expect(d).not.toBeVisible();
|
||||
} finally { releaseRefresh(); page.off('request', countPost); await page.unroute(detail, slow); }
|
||||
|
||||
// A real business refusal is distinguishable from native validation and
|
||||
// leaves a nonempty error tied to this exact POST and server request ID.
|
||||
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click();
|
||||
let d = page.getByRole('dialog');
|
||||
await bankFields(d, late, '15.00', `${late.reference}-WRONG-DIRECTION`, true);
|
||||
await d.getByLabel('回单付款账号').fill(late.payer); await d.getByLabel('回单收款账号').fill(late.payee);
|
||||
await d.getByLabel('依据与说明').fill('合成错误方向拒绝诊断');
|
||||
const refused = page.waitForResponse(post);
|
||||
await d.getByRole('button', { name: '确认', exact: true }).click();
|
||||
const refusal = await refused; assert.equal(refusal.status(), 409);
|
||||
const safeError = await refusal.json(); assert.equal(safeError.error.details.reasonCode, 'loan.bank_result_reconcile_required'); assert.ok(safeError.requestId);
|
||||
await expect(d.getByRole('alert')).toContainText(/\S/);
|
||||
record('server_refused', { method: 'POST', status: 409, requestId: safeError.requestId, reasonCode: safeError.error.details.reasonCode });
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: late.id, kind: 'return' } }), 0);
|
||||
await page.keyboard.press('Escape'); await expect(d).not.toBeVisible();
|
||||
|
||||
// Hold the registration request, then hold the refresh after a real 200.
|
||||
// Database checks separate request pending from committed-but-stale UI.
|
||||
let releasePost;
|
||||
const postGate = new Promise(resolve => { releasePost = resolve; });
|
||||
const eventRoute = url => new URL(url).pathname === endpoint;
|
||||
const atPost = page.waitForRequest(r => eventRoute(r.url()) && r.method() === 'POST');
|
||||
const delayedPost = async route => { await postGate; await route.continue(); };
|
||||
await page.route(eventRoute, delayedPost);
|
||||
let releaseAfter;
|
||||
const afterGate = new Promise(resolve => { releaseAfter = resolve; });
|
||||
const atAfter = page.waitForRequest(r => detail(r.url()) && r.method() === 'GET');
|
||||
const delayedAfter = async route => { if (route.request().method() === 'GET') await afterGate; await route.continue(); };
|
||||
await page.route(detail, delayedAfter);
|
||||
try {
|
||||
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click(); d = page.getByRole('dialog');
|
||||
await bankFields(d, late, '15.00', `${late.reference}-RETURN-15.00`, true); await d.getByLabel('依据与说明').fill('合成请求与成功后刷新延迟诊断');
|
||||
const succeeded = page.waitForResponse(post);
|
||||
await d.getByRole('button', { name: '确认', exact: true }).click(); await atPost;
|
||||
await expect(d.getByRole('button', { name: '处理中…', exact: true })).toBeDisabled();
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: late.id, kind: 'return' } }), 0);
|
||||
record('post_pending', { method: 'POST', eventCount: 0 });
|
||||
releasePost(); const response = await succeeded; assert.equal(response.status(), 200);
|
||||
const result = await response.json(); const event = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: result.data.id } });
|
||||
assert.equal(event.evidence_id, uploadedByKind.get('incoming_return')); assert.equal(event.amount.toFixed(2), '15.00'); assert.equal(event.status, 'proposed');
|
||||
const parent = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: event.parent_id } });
|
||||
assert.equal(parent.payment_batch_id, late.id); assert.equal(parent.kind, 'late_success'); assert.equal(parent.status, 'booked');
|
||||
await atAfter; await expect(d).not.toBeVisible();
|
||||
record('post_200_refresh_pending', { method: 'POST', status: 200, requestId: result.requestId, eventId: event.id, evidenceId: event.evidence_id });
|
||||
releaseAfter(); await expect(view.panel).toContainText(/追回到账 \d+ · ¥15\.00 · 待复核/);
|
||||
record('refresh_rendered', { eventId: event.id });
|
||||
} finally { releasePost(); releaseAfter(); await page.unroute(eventRoute, delayedPost); await page.unroute(detail, delayedAfter); }
|
||||
}
|
||||
try {
|
||||
const failed = fixtures.failures[Number(mobile)];
|
||||
await login(finance, failed);
|
||||
@@ -111,8 +207,12 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
await review(); await login(finance, late); await action('登记补记或追回');
|
||||
await expect(view.panel).toContainText('待追回 ¥40.00');
|
||||
for (const [amount, remaining] of [['15.00', '25.00'], ['25.00', '0.00']]) {
|
||||
await upload('incoming_return');
|
||||
await action('登记追回到账', d => bankFields(d, late, amount, `${late.reference}-RETURN-${amount}`, true));
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund' && amount === '15.00') {
|
||||
await diagnoseRefund(late);
|
||||
} else {
|
||||
await upload('incoming_return');
|
||||
await action('登记追回到账', d => bankFields(d, late, amount, `${late.reference}-RETURN-${amount}`, true));
|
||||
}
|
||||
await login(manager, late); await review(); await login(finance, late); await action('登记补记或追回');
|
||||
await expect(view.panel).toContainText(`待追回 ¥${remaining}`);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lenderId, status: 'active' } }), remaining === '0.00' ? 0 : 1);
|
||||
@@ -137,7 +237,7 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
|
||||
await view.page.screenshot({ path: path.join(output, `${mode}-late-return-and-dismissal.png`), fullPage: true });
|
||||
checks.push(`${mode}_evidenced_duplicate_false_alarm_closed_by_independent_reviewer_via_ui`);
|
||||
} finally { if (view) await view.context.close(); }
|
||||
} finally { await closeView(); }
|
||||
}
|
||||
return checks;
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户