文件
hyb-oa/scripts/qa/company-loan-pagination-diagnostic.cjs

193 行
14 KiB
JavaScript

const assert = require('node:assert/strict');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { mkdtempSync, mkdirSync } = require('node:fs');
const { createRequire } = require('node:module');
const root = path.resolve(__dirname, '../..');
const clientRequire = createRequire(path.join(root, 'client/package.json'));
const { chromium, expect } = clientRequire('@playwright/test');
// D1 diagnostic: the company-representative authorization dialog pages its
// lender list (30 per page). With the target lender on page 3, compare the old
// test loop (response-only wait) with a loop that waits for the option list to
// change, under CPU throttling that models a loaded host. Synthetic data only.
async function authorizationDialog(page) {
await page.getByRole('tab', { name: '公司代表授权', exact: true }).click({ timeout: 30000 });
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached({ timeout: 30000 });
return dialog;
}
const optionValues = (dialog) => dialog.getByLabel('授权出借人').locator('option').evaluateAll((os) => os.map((o) => o.value).join(','));
// The original loop from company-loan-employee-browser.cjs (before D1).
async function oldLoop(page, dialog, targetId, timeout) {
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${targetId}"]`).count()); i++) {
assert.ok(i < 20);
await Promise.all([page.waitForResponse((r) => r.url().includes('/loan-lenders?page='), { timeout }),
dialog.getByRole('button', { name: '下一页出借人', exact: true }).click({ timeout })]);
}
}
// Wait for the rendered options to change after each page step (works for
// network responses and cache hits alike).
async function stepTo(dialog, direction) {
const before = await optionValues(dialog);
const button = dialog.getByRole('button', { name: direction === 'next' ? '下一页出借人' : '上一页出借人', exact: true });
await expect(button).toBeEnabled({ timeout: 10000 });
await button.click();
// The list empties while a page loads; only a loaded, different page counts.
await expect.poll(async () => { const now = await optionValues(dialog); return now.split(',').length > 1 && now !== before; },
{ timeout: 15000 }).toBe(true);
}
async function newLoop(dialog, targetId) {
const target = dialog.getByLabel('授权出借人').locator(`option[value="${targetId}"]`);
while (await dialog.getByRole('button', { name: '上一页出借人', exact: true }).isEnabled()) await stepTo(dialog, 'previous');
for (let i = 0; !(await target.count()); i++) { assert.ok(i < 20, 'target lender not found on any page'); await stepTo(dialog, 'next'); }
}
module.exports = async function paginationDiagnostic(h) {
const { app, prisma, call, finance, manager, password, config, verifiedLender, confirmedLoan } = h;
const checks = [];
// Fillers are eligible for the dialog (verified, linked to their own employee),
// so the target is reached only by real paging, with or without a server filter.
for (let n = 0; n < 64; n++) {
const staff = await h.actor(['employee']);
await verifiedLender(`合成翻页-${String(n).padStart(2, '0')}`, staff.id, '5');
}
const target = await verifiedLender('合成翻页-目标', manager.id, '3');
const loan = await confirmedLoan(target.id, '100.00', '合成翻页目标借款');
// Explicit exclusions prove that filtering is applied before pagination and
// count, including the current finance actor and an unverified linked lender.
const own = await verifiedLender('合成翻页-本人排除', finance.id, '4');
const unverified = await call(finance, 'POST', '/loan-lenders', { legalEntityId: h.company.id, name: '合成翻页-未核实排除',
bankInstitutionId: h.bank.id, bankName: '合成', bankAccount: '9'.repeat(16), accountName: '合成翻页-未核实排除', requestKey: randomUUID() });
// The dialog pages the server-filtered list of lenders it can offer.
const pages = [];
for (let p = 1; ; p++) {
const r = await call(finance, 'GET', `/loan-lenders?page=${p}&pageSize=30&eligible=related_authority`);
assert.ok(r.items.every((i) => i.identityStatus === 'verified' && i.employeeId && i.employeeId !== finance.id), 'filter returns only offerable lenders');
pages.push(r.items.map((i) => i.id)); if (p * 30 >= r.total) break;
}
const all = await call(finance, 'GET', '/loan-lenders?page=1&pageSize=30');
await call(finance, 'GET', '/loan-lenders?page=1&pageSize=30&eligible=other', undefined, 400);
for (const who of [manager, h.ordinary]) await call(who, 'GET', '/loan-lenders?eligible=related_authority', undefined, 403);
assert.ok(!pages.flat().includes(own.id) && !pages.flat().includes(unverified.id));
assert.equal(pages.flat().length, 65, 'eligible total excludes unlinked, unverified and self lenders');
checks.push(`eligible_filter_total_${pages.flat().length}_of_${all.total}`);
const targetPage = pages.findIndex((ids) => ids.includes(target.id)) + 1;
assert.ok(targetPage >= 3, `target lender must be on page 3 or later, observed ${targetPage}`);
checks.push(`target_on_page_${targetPage}_of_${pages.length}`);
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-browser-diagnostics-20261010/authorization-pagination'); mkdirSync(base, { recursive: true });
const output = mkdtempSync(path.join(base, 'run-'));
const oldOrigin = config.appOrigin; config.appOrigin = origin;
const results = [];
const record = (entry) => { results.push(entry); console.log(JSON.stringify({ stage: 'loan_authorization_pagination_step', ...entry })); };
try {
for (const mode of ['desktop', 'mobile']) {
const context = await browser.newContext({ viewport: mode === 'mobile' ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
await context.route('**/*', (route) => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
await context.tracing.start({ screenshots: true, snapshots: true });
const page = await context.newPage();
const requests = [];
page.on('request', (r) => { if (r.url().includes('/loan-lenders?page=')) requests.push(new URL(r.url()).searchParams.get('page')); });
try {
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mode === 'mobile' ? '/mobile' : ''}/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 cdp = await context.newCDPSession(page);
// 0. Counterexample before any paging (only page 1 cached): page 2 is answered
// with page 1's data, so the list never changes and the new wait must fail.
let dialog = await authorizationDialog(page);
await page.route('**/api/loan-lenders?page=*', (route) => {
const url = new URL(route.request().url()); url.searchParams.set('page', '1');
return route.continue({ url: url.toString() });
});
await stepTo(dialog, 'next').then(() => { throw new Error('a page that never changes must not satisfy the wait'); },
(error) => { assert.ok(/toBe|Timeout|expect/i.test(String(error.message)), String(error.message).slice(0, 200)); });
await page.unroute('**/api/loan-lenders?page=*');
record({ mode, step: 'unchanged_page_counterexample', outcome: 'new wait fails as required' });
await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0);
await page.reload(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible({ timeout: 30000 });
requests.length = 0;
// 1. Old loop under a loaded host: does it reproduce the timeout?
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 20 });
dialog = await authorizationDialog(page);
let oldOutcome = 'pass';
try { await oldLoop(page, dialog, target.id, 8000); } catch (error) { oldOutcome = error.name === 'TimeoutError' ? 'timeout' : `error:${error.name}`; }
const landed = await dialog.getByLabel('授权出借人').locator(`option[value="${target.id}"]`).count();
await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0);
record({ mode, step: 'old_loop_throttled', outcome: oldOutcome, targetVisibleAfter: landed > 0, requests: [...requests] });
// 2. Reopen: which lender page does the dialog start on?
requests.length = 0;
dialog = await authorizationDialog(page);
const firstPage = (await optionValues(dialog)).split(',').filter(Boolean);
const startsOnPage = pages.findIndex((ids) => ids.some((id) => firstPage.includes(id))) + 1;
record({ mode, step: 'reopen_start_page', startsOnPage });
assert.equal(startsOnPage, 1, 'reopening the dialog starts from the first lender page');
assert.deepEqual(firstPage, pages[0], 'reopening renders precisely the first page');
// 3. New loop, throttled, including returning to page 1 first.
await newLoop(dialog, target.id);
assert.equal(await dialog.getByLabel('授权出借人').locator(`option[value="${target.id}"]`).count(), 1);
assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 1]);
// 4. Cache: step back and forward again; the forward step may be served from cache.
const beforeCache = requests.length;
await stepTo(dialog, 'previous');
assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 2]);
await stepTo(dialog, 'next');
assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 1]);
record({ mode, step: 'new_loop_throttled_with_cache', outcome: 'pass', requestsDuringBackForward: requests.length - beforeCache });
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
// The third-page target must be usable, not merely visible. Finance
// proposes this scope and its designated representative confirms it.
await dialog.getByLabel('授权出借人').selectOption(target.id);
await dialog.getByRole('checkbox', { name: new RegExp(loan.loanNo) }).check();
await dialog.getByLabel('代表权及授权范围依据').fill('合成第三页目标公司代表权及借款范围');
const proposed = page.waitForResponse(r => new URL(r.url()).pathname === '/api/loan-related-authorizations' && r.request().method() === 'POST');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
const proposalResponse = await proposed; assert.equal(proposalResponse.status(), 200);
const authorizationId = (await proposalResponse.json()).data.id;
await expect(dialog).not.toBeVisible();
const representative = await browser.newContext({ viewport: mode === 'mobile' ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
try {
await representative.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const rp = await representative.newPage();
await rp.goto(`${origin}/login?redirect=${encodeURIComponent(`${mode === 'mobile' ? '/mobile' : ''}/company-loans`)}`);
await rp.getByTestId('login-identifier').fill(manager.email); await rp.getByTestId('login-password').fill(password);
await rp.getByTestId('login-submit').click(); await expect(rp.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
await rp.getByRole('button', { name: '代表公司确认授权范围', exact: true }).click();
const rd = rp.getByRole('dialog'); await expect(rd).toContainText(loan.loanNo);
await rd.getByLabel('确认或停止的理由').fill('合成本人核对第三页借款及公司代表范围');
const confirmed = rp.waitForResponse(r => new URL(r.url()).pathname === `/api/loan-related-authorizations/${authorizationId}/actions` && r.request().method() === 'POST');
await rd.getByRole('button', { name: '确认', exact: true }).click();
assert.equal((await confirmed).status(), 200); await expect(rd).not.toBeVisible();
} finally { await representative.close(); }
const saved = await prisma.loanRelatedAuthorization.findUniqueOrThrow({ where: { id: authorizationId }, include: { events: true, loans: true } });
assert.equal(saved.approver_id, manager.id); assert.equal(saved.proposed_by, finance.id);
assert.deepEqual(saved.loans.map(r => r.loan_id), [loan.id]);
assert.ok(saved.events.some(r => r.action === 'confirm' && r.actor_id === manager.id));
assert.ok(await prisma.auditLog.count({ where: { action: 'LOAN_RELATED_AUTHORIZATION_DECIDED', target_id: authorizationId } }));
assert.equal(await prisma.loanRepayment.count({ where: { lender_id: target.id } }), 0);
record({ mode, step: 'third_page_scope_proposed_and_confirmed', outcome: 'pass', zeroRepayments: true });
checks.push(`${mode}_pagination_diagnosed`);
} finally {
await context.tracing.stop({ path: path.join(output, `trace-${mode}.zip`) });
await page.screenshot({ path: path.join(output, `final-${mode}.png`), fullPage: true }).catch(() => {});
await context.close();
}
}
} finally { config.appOrigin = oldOrigin; await browser.close(); }
assert.ok(loan.id);
console.log(JSON.stringify({ stage: 'loan_authorization_pagination_diagnostic', status: 'PASS', checks, results, output: path.relative(root, output) }));
return results;
};