- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号 - 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE - 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效 - 电脑/手机共用对话框,写请求在卸载或身份切换时取消 - 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断 Co-Authored-By: Claude Opus 5.5 <[email protected]>
251 行
20 KiB
JavaScript
251 行
20 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
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 serverRequire = createRequire(path.join(root, 'server/package.json'));
|
|
const { chromium, expect } = clientRequire('@playwright/test');
|
|
|
|
module.exports = async function employeeBrowser(h) {
|
|
const { finance, password, prisma, config, employeeSources } = h;
|
|
const own = !h.browser;
|
|
const browser = h.browser ?? await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
|
|
const origin = h.origin ?? await h.app.listen({ host: '127.0.0.1', port: 0 });
|
|
const base = path.join(root, 'artifacts/qa-flow/loan-employee-source-20261009'); mkdirSync(base, { recursive: true });
|
|
const output = h.output ?? mkdtempSync(path.join(base, 'browser-'));
|
|
const oldOrigin = config.appOrigin; config.appOrigin = origin;
|
|
const checks = [];
|
|
const { encryptStringOrThrow } = serverRequire('./src/services/crypto');
|
|
try {
|
|
for (const s of employeeSources.ui) {
|
|
const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : '';
|
|
const contexts = [];
|
|
async function login(who) {
|
|
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
|
contexts.push(context); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
|
|
const page = await context.newPage();
|
|
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${prefix}/company-loans`)}`);
|
|
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password);
|
|
await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
|
|
return page;
|
|
}
|
|
async function submit(page, label = '确认', expected = 200) {
|
|
const pending = page.waitForResponse(r => r.url().startsWith(`${origin}/api/`) && !r.url().includes('/auth/') && r.request().method() === 'POST');
|
|
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click();
|
|
const response = await pending;
|
|
assert.equal(response.status(), expected, `employee ${s.mode} ${label}: ${response.status()}`);
|
|
if (expected === 200) await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
return response;
|
|
}
|
|
async function submitAfterSameActor401(page, label, endpoint) {
|
|
let attempts = 0;
|
|
const handler = async route => {
|
|
attempts++;
|
|
if (attempts === 1) return route.fulfill({ status: 401, contentType: 'application/json',
|
|
body: JSON.stringify({ requestId: 'synthetic-expired-access', error: { code: 'AUTH_001', message: '合成过期令牌' } }) });
|
|
return route.continue();
|
|
};
|
|
await page.route(`**${endpoint}`, handler);
|
|
try {
|
|
const final = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST' && r.status() === 200);
|
|
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click();
|
|
const response = await final;
|
|
assert.equal(attempts, 2, 'same-actor expiry keeps the normal single refresh retry');
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
return response;
|
|
} finally { await page.unroute(`**${endpoint}`, handler); }
|
|
}
|
|
async function findCard(page, card) {
|
|
for (let i = 0; i < 20; i++) {
|
|
await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
|
|
if (await card.count()) return;
|
|
const next = page.getByRole('button', { name: '下一页', exact: true }); await expect(next).toBeEnabled();
|
|
const label = await page.getByText(/^第 \d+ 页$/).first().innerText();
|
|
await next.click(); await expect(page.getByText(label, { exact: true })).toHaveCount(0);
|
|
}
|
|
throw new Error('synthetic employee record not found within ledger pages');
|
|
}
|
|
try {
|
|
const page = await login(finance);
|
|
async function selectEmployee(who = s.staff, query = `浏览器-${s.mode}`) {
|
|
await page.getByRole('button', { name: '登记出借人', exact: true }).click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog.getByRole('heading', { name: '从员工选择出借人' })).toBeVisible();
|
|
await dialog.getByLabel('检索员工', { exact: true }).fill(query);
|
|
await dialog.getByLabel('已有员工', { exact: true }).selectOption(who.id);
|
|
await expect(dialog.getByLabel('借款所属公司', { exact: true })).toHaveValue(s.company.id);
|
|
await expect(dialog.getByLabel('员工资料摘要')).toBeVisible();
|
|
await expect(dialog.getByRole('textbox', { name: '收款账号', exact: true })).toHaveCount(0);
|
|
await expect(dialog.getByRole('textbox', { name: '出借人姓名', exact: true })).toHaveCount(0);
|
|
return dialog;
|
|
}
|
|
let dialog = await selectEmployee();
|
|
assert.ok(!(await dialog.innerText()).includes('2'.repeat(16)), 'full employee account is never rendered');
|
|
await expect(dialog.getByRole('button', { name: '确认关联员工' })).toBeDisabled();
|
|
await page.screenshot({ path: path.join(output, `employee-${s.mode}-source.png`), fullPage: true });
|
|
const noOverflow = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
|
|
assert.ok(noOverflow, `${s.mode} employee selection has no horizontal overflow`);
|
|
// The page's old preview must fail after a real source-row update.
|
|
await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)) } });
|
|
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
|
|
const stale = await submit(page, '确认关联员工', 409);
|
|
assert.equal((await stale.json()).error.details.reasonCode, 'loan.employee_source_changed');
|
|
await expect(dialog.getByRole('alert')).toContainText('员工资料已变化');
|
|
assert.equal(await prisma.loanLender.count({ where: { employee_id: s.staff.id } }), 0);
|
|
await dialog.getByRole('button', { name: '重新加载员工资料' }).click();
|
|
const checked = dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' });
|
|
await expect(checked).not.toBeChecked(); await expect(checked).toBeEnabled(); await checked.check();
|
|
const linked = await submitAfterSameActor401(page, '确认关联员工', '/api/loan-lenders/from-employee');
|
|
const sent = linked.request().postDataJSON();
|
|
assert.deepEqual(Object.keys(sent).sort(), ['detailsConfirmed', 'employeeId', 'expectedSourceVersion', 'legalEntityId', 'requestKey'].sort());
|
|
let lender = (await linked.json()).data.lender;
|
|
assert.equal(lender.employeeId, s.staff.id); assert.equal(lender.identityStatus, 'verified');
|
|
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } })).created_by, finance.id);
|
|
dialog = await selectEmployee();
|
|
await expect(dialog).toContainText('将使用已有记录');
|
|
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
|
|
assert.equal((await (await submit(page, '使用已有出借人')).json()).data.lender.id, lender.id);
|
|
assert.equal(await prisma.loanLender.count({ where: { employee_id: s.staff.id, legal_entity_id: s.company.id } }), 1);
|
|
|
|
// Missing data points back to personnel maintenance, with no replacement
|
|
// account field in either the new-link or refresh dialog.
|
|
await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: '' } });
|
|
dialog = await selectEmployee();
|
|
await expect(dialog.getByRole('alert').filter({ hasText: '员工收款账号未维护' })).toBeVisible();
|
|
await expect(dialog.getByRole('button', { name: '确认关联员工', exact: true })).toBeDisabled();
|
|
await expect(dialog.getByRole('textbox', { name: '收款账号', exact: true })).toHaveCount(0);
|
|
await dialog.press('Escape');
|
|
await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '4'.repeat(16)) } });
|
|
const lenderCard = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${s.company.name}`, exact: true }) });
|
|
await findCard(page, lenderCard);
|
|
await lenderCard.getByRole('button', { name: '使用当前员工资料', exact: true }).click();
|
|
dialog = page.getByRole('dialog');
|
|
await expect(dialog).toContainText('原公司代表授权须重新登记确认');
|
|
await expect(dialog.getByLabel('员工资料摘要')).toContainText('4444');
|
|
await dialog.getByLabel('更新依据', { exact: true }).fill('合成核对员工档案中的新收款资料');
|
|
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
|
|
lender = (await (await submitAfterSameActor401(page, '确认更新出借人资料', `/api/loan-lenders/${lender.id}/employee-refresh`)).json()).data;
|
|
assert.equal(lender.accountVersion, 2); assert.equal(lender.identityVersion, 2);
|
|
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } })).updated_by, finance.id);
|
|
await findCard(page, lenderCard);
|
|
checks.push(`${s.mode}_employee_selection_masked_stale_refusal_reuse_missing_and_explicit_refresh`);
|
|
|
|
await page.getByRole('button', { name: '登记借入', exact: true }).click();
|
|
dialog = page.getByRole('dialog'); await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id);
|
|
const reference = `合成员工历史借入-${s.mode}`;
|
|
await dialog.getByLabel('原借入凭据唯一引用').fill(reference);
|
|
await dialog.getByLabel('原始本金', { exact: true }).fill('200.00');
|
|
await dialog.getByLabel('核对日未还本金').fill('200.00');
|
|
await dialog.getByLabel('实际到账日期').fill('2025-05-01');
|
|
await dialog.getByLabel('余额核对截止日').fill('2026-01-01');
|
|
await submit(page);
|
|
const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference, lender_id: lender.id } });
|
|
await page.getByRole('tab', { name: '借款台账', exact: true }).click();
|
|
const loanCard = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${loan.loan_no}`, exact: true }) });
|
|
await findCard(page, loanCard); await loanCard.locator('summary').click();
|
|
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) {
|
|
const result = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST');
|
|
await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf',
|
|
buffer: Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n') });
|
|
assert.equal((await result).status(), 200);
|
|
}
|
|
await loanCard.getByRole('button', { name: '确认期初余额', exact: true }).click();
|
|
await page.getByRole('dialog').getByLabel('代账余额确认引用').fill('合成会计往来余额核对记录'); await submit(page);
|
|
await expect(loanCard).toContainText('余额已确认');
|
|
await page.getByRole('tab', { name: '公司代表授权', exact: true }).click();
|
|
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
|
|
dialog = page.getByRole('dialog');
|
|
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached();
|
|
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${lender.id}"]`).count()); i++) {
|
|
assert.ok(i < 20);
|
|
const response = page.waitForResponse(r => r.url().includes('/loan-lenders?page='));
|
|
await dialog.getByRole('button', { name: '下一页出借人', exact: true }).click(); await response;
|
|
}
|
|
await dialog.getByLabel('授权出借人').selectOption(lender.id);
|
|
await dialog.getByRole('checkbox', { name: new RegExp(loan.loan_no) }).check();
|
|
await dialog.getByLabel('代表权及授权范围依据').fill('合成财务登记已有公司代表权及此笔历史借款范围'); await submit(page);
|
|
await expect(page.getByRole('button', { name: '代表公司确认授权范围', exact: true })).toHaveCount(0);
|
|
const managerPage = await login(s.staff);
|
|
await expect(managerPage.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0);
|
|
await managerPage.getByRole('button', { name: '代表公司确认授权范围', exact: true }).click();
|
|
await expect(managerPage.getByRole('dialog')).toContainText(loan.loan_no);
|
|
await managerPage.getByRole('dialog').getByLabel('确认或停止的理由').fill('合成公司代表核对本人收款身份与授权范围'); await submit(managerPage);
|
|
const auth = await prisma.loanRelatedAuthorization.findFirstOrThrow({ where: { lender_id: lender.id }, include: { events: true } });
|
|
assert.equal(auth.approver_id, s.staff.id); assert.equal(auth.proposed_by, finance.id);
|
|
assert.ok(auth.events.some(event => event.action === 'confirm' && event.actor_id === s.staff.id));
|
|
assert.equal(await prisma.loanRepayment.count({ where: { lender_id: lender.id } }), 0, 'source and authority setup never makes a repayment');
|
|
await managerPage.screenshot({ path: path.join(output, `employee-${s.mode}-authorization.png`), fullPage: true });
|
|
checks.push(`${s.mode}_employee_history_balance_finance_authority_and_manager_confirmation`);
|
|
|
|
// Keep the same document alive across real logout/login, so document
|
|
// teardown cannot accidentally provide the request cancellation guard.
|
|
await page.evaluate(() => { window.__syntheticEmployeeDocument = 'same-document'; });
|
|
async function navigateWithinDocument(to) {
|
|
await page.evaluate(url => { window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate')); }, to);
|
|
}
|
|
async function switchActor(who) {
|
|
await navigateWithinDocument('/mobile/me');
|
|
await page.getByRole('button', { name: '退出登录', exact: true }).click();
|
|
await expect(page.getByTestId('login-submit')).toBeVisible();
|
|
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password);
|
|
await page.getByTestId('login-submit').click(); await expect(page.getByTestId('login-submit')).toHaveCount(0);
|
|
await navigateWithinDocument(`${prefix}/company-loans`);
|
|
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
|
|
assert.equal(await page.evaluate(() => window.__syntheticEmployeeDocument), 'same-document');
|
|
}
|
|
for (const action of ['link', 'refresh']) {
|
|
if (action === 'refresh') await switchActor(finance);
|
|
else await navigateWithinDocument(`${prefix}/company-loans`);
|
|
if (action === 'link') dialog = await selectEmployee(s.switchStaff, `浏览器切换-${s.mode}`);
|
|
else {
|
|
await page.getByRole('tab', { name: '出借人', exact: true }).click(); await findCard(page, lenderCard);
|
|
await lenderCard.getByRole('button', { name: '使用当前员工资料', exact: true }).click(); dialog = page.getByRole('dialog');
|
|
await dialog.getByLabel('更新依据', { exact: true }).fill('合成旧会话操作不得转交新登录财务');
|
|
}
|
|
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
|
|
const endpoint = action === 'link' ? '/api/loan-lenders/from-employee' : `/api/loan-lenders/${lender.id}/employee-refresh`;
|
|
const before = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
|
|
const auditBefore = await prisma.auditLog.count({ where: { target_id: lender.id, action: 'LOAN_LENDER_EMPLOYEE_REFRESHED' } });
|
|
let release; const gate = new Promise(resolve => { release = resolve; });
|
|
let started; const held = new Promise(resolve => { started = resolve; });
|
|
let handled; const finished = new Promise(resolve => { handled = resolve; });
|
|
let attempts = 0; let firstRequest; let aborted = false;
|
|
const onFailed = request => { if (request === firstRequest) aborted = /aborted/i.test(request.failure()?.errorText ?? ''); };
|
|
const handler = async route => {
|
|
attempts++;
|
|
if (attempts > 1) return route.continue();
|
|
firstRequest = route.request(); started(); await gate;
|
|
try {
|
|
await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late-401',
|
|
error: { code: 'AUTH_001', message: '合成旧会话迟到响应' } }) });
|
|
} finally { handled(); }
|
|
};
|
|
page.on('requestfailed', onFailed); await page.route(`**${endpoint}`, handler);
|
|
try {
|
|
await dialog.getByRole('button', { name: action === 'link' ? '确认关联员工' : '确认更新出借人资料', exact: true }).click();
|
|
await held; await switchActor(employeeSources.otherFinance);
|
|
release(); await finished;
|
|
await expect.poll(() => aborted, { message: 'leaving the source form must abort its old write request' }).toBe(true);
|
|
assert.equal(attempts, 1, 'a late 401 must never replay the old write as the new actor');
|
|
assert.equal(await prisma.loanLender.count({ where: { employee_id: s.switchStaff.id } }), 0);
|
|
const after = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
|
|
assert.equal(after.identity_version, before.identity_version); assert.equal(after.account_version, before.account_version);
|
|
assert.equal(after.updated_by, before.updated_by);
|
|
assert.equal(await prisma.auditLog.count({ where: { target_id: lender.id, action: 'LOAN_LENDER_EMPLOYEE_REFRESHED' } }), auditBefore);
|
|
checks.push(`${s.mode}_${action}_late_401_aborted_across_real_actor_switch`);
|
|
} finally { release(); page.off('requestfailed', onFailed); await page.unroute(`**${endpoint}`, handler); }
|
|
}
|
|
} catch (error) {
|
|
for (const [index, context] of contexts.entries()) {
|
|
const page = context.pages()[0];
|
|
if (page) await page.screenshot({ path: path.join(output, `employee-${s.mode}-failure-${index}.png`), fullPage: true, timeout: 5000 }).catch(() => {});
|
|
}
|
|
throw error;
|
|
} finally { for (const context of contexts) await context.close(); }
|
|
}
|
|
} finally { config.appOrigin = oldOrigin; if (own) await browser.close(); }
|
|
console.log(JSON.stringify({ stage: 'loan_employee_browser', status: 'PASS', checks, output: path.relative(root, output) }));
|
|
return checks;
|
|
};
|