172 行
11 KiB
JavaScript
172 行
11 KiB
JavaScript
const assert = require("node:assert/strict");
|
|
const { randomBytes } = require("node:crypto");
|
|
const { readFileSync } = require("node:fs");
|
|
const { createRequire } = require("node:module");
|
|
const path = require("node:path");
|
|
|
|
module.exports = async function verifyPaymentExperience({
|
|
app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword, handoffCheck,
|
|
payee = employee, fixtureCode = "SYN-PAY",
|
|
}) {
|
|
app.config.fieldEncryptionKey ??= randomBytes(32).toString("base64");
|
|
app.config.paymentBatchEnabled = true;
|
|
const { encryptStringOrThrow } = serverRequire("./src/services/crypto");
|
|
const company = await prisma.legalEntity.create({ data: { code: fixtureCode, name: "合成测试主体" } });
|
|
const bank = await prisma.bankInstitution.upsert({
|
|
where: { code: "CCB" }, create: { code: "CCB", name: "中国建设银行" }, update: {},
|
|
});
|
|
await prisma.employee.update({ where: { id: payee.id }, data: {
|
|
legal_entity_id: company.id, bank_institution_id: bank.id, bank_name: bank.name,
|
|
bank_account_encrypted: encryptStringOrThrow(app.config, `6222${"0".repeat(8)}5678`),
|
|
} });
|
|
await prisma.paymentAccount.create({ data: {
|
|
legal_entity_id: company.id, bank_institution_id: bank.id, account_name: "合成测试主体",
|
|
account_number_encrypted: encryptStringOrThrow(app.config, `6217${"0".repeat(8)}1234`),
|
|
} });
|
|
await prisma.featureFlag.upsert({
|
|
where: { flag_key: "payment_batch_v1" },
|
|
create: { flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] },
|
|
update: {},
|
|
});
|
|
await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 1);
|
|
await handoffCheck?.(approvers.ceo, "reimbursement_batch", form.id, 0);
|
|
const readProgress = async (actor) => {
|
|
const response = await fetch(`${origin}/api/reimbursements/${form.id}`, {
|
|
headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
return (await response.json()).data.processingProgress;
|
|
};
|
|
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_created").state, "current");
|
|
const created = await post("/payment-batches", {
|
|
entityType: "reimbursement", entityIds: [form.id], defaultPaymentChannel: "bank",
|
|
}, approvers.finance.id);
|
|
assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`);
|
|
const batch = created.body.data.item;
|
|
await handoffCheck?.(approvers.finance, "reimbursement_batch", form.id, 0);
|
|
await handoffCheck?.(approvers.ceo, "payment_review", batch.id, 1);
|
|
await handoffCheck?.(approvers.finance, "payment_review", batch.id, 0);
|
|
const reviewStage = (await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_review");
|
|
assert.equal(reviewStage.state, "current");
|
|
assert.equal(reviewStage.detail, "待非制单人复核付款");
|
|
const download = (actor) => fetch(`${origin}/api/payment-batches/${batch.id}/payment-order`, {
|
|
method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${tokens.get(actor.id)}` },
|
|
body: "{}", signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal((await download(approvers.finance)).status, 409);
|
|
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.finance.id)).status, 403);
|
|
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.ceo.id)).status, 200);
|
|
await handoffCheck?.(approvers.ceo, "payment_review", batch.id, 0);
|
|
await handoffCheck?.(approvers.finance, "payment_execute", batch.id, 1);
|
|
await handoffCheck?.(approvers.ceo, "payment_execute", batch.id, 0);
|
|
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").state, "current");
|
|
assert.equal((await download(approvers.ceo)).status, 403);
|
|
assert.equal((await download(employee)).status, 403);
|
|
for (const count of [1, 2]) {
|
|
const response = await download(approvers.finance);
|
|
assert.equal(response.status, 200);
|
|
assert.ok(decodeURIComponent(response.headers.get("content-disposition")).includes(`报销制单_${batch.batchNo}.xls`));
|
|
assert.equal(response.headers.get("cache-control"), "no-store");
|
|
const workbook = Buffer.from(await response.arrayBuffer());
|
|
assert.equal(workbook.subarray(0, 8).toString("hex"), "d0cf11e0a1b11ae1");
|
|
const current = await prisma.paymentBatch.findUnique({ where: { id: batch.id } });
|
|
assert.equal(current.payment_order_export_count, count);
|
|
assert.equal(current.status, "reviewed");
|
|
const source = await prisma.reimbursementForm.findUnique({ where: { id: form.id } });
|
|
assert.equal(source.status, "approved");
|
|
assert.equal(source.paid_at, null);
|
|
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").detail,
|
|
"已生成银行制单,等待付款完成并上传回单,非审批环节");
|
|
}
|
|
if (payee.id === employee.id) {
|
|
assert.equal((await readProgress(employee)).stages.some((stage) => stage.targetHref), false);
|
|
} else {
|
|
const unrelatedRead = await fetch(`${origin}/api/reimbursements/${form.id}`, {
|
|
headers: { authorization: `Bearer ${tokens.get(employee.id)}` },
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(unrelatedRead.status, 403);
|
|
}
|
|
const audits = await prisma.auditLog.findMany({ where: { target_id: batch.id, action: { in: ["PAYMENT_ORDER_EXPORTED", "PAYMENT_ORDER_REEXPORTED"] } } });
|
|
assert.equal(audits.length, 2);
|
|
for (const audit of audits) {
|
|
assert.equal(audit.extra.purpose, "reimbursement_bank_transfer");
|
|
assert.equal(audit.extra.purposeSource, "system");
|
|
assert.equal(Object.hasOwn(audit.extra, "reason"), false);
|
|
}
|
|
if (browser && preview) {
|
|
const clientRequire = createRequire(path.resolve(__dirname, "../../client/package.json"));
|
|
const { expect } = clientRequire("@playwright/test");
|
|
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
|
|
for (const mobile of [false, true]) {
|
|
const context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
|
|
try {
|
|
const fingerprint = `synthetic-payment-${mobile}`;
|
|
await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
|
|
const login = await context.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: approvers.finance.email, password: loginPassword } });
|
|
assert.equal(login.status(), 200);
|
|
const page = await context.newPage();
|
|
await page.goto(`${uiOrigin}${mobile ? "/mobile" : ""}/reimbursements/${form.id}`);
|
|
const sourceActions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel");
|
|
await expect(sourceActions).toContainText("已生成银行制单");
|
|
await expect(page.getByRole("link", { name: "创建批次", exact: true })).toHaveCount(0);
|
|
const sourceLink = page.getByRole("link", { name: "查看所在付款批次", exact: true });
|
|
await expect(sourceLink).toHaveCount(1);
|
|
const linkBounds = await sourceLink.boundingBox();
|
|
assert.ok(linkBounds.height >= 44);
|
|
if (!mobile) assert.ok(linkBounds.width < 180);
|
|
await sourceLink.click();
|
|
const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel");
|
|
await expect(actions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
|
|
await actions.getByText("更多", { exact: true }).click();
|
|
const redownload = page.getByRole("menuitem", { name: "重新下载制单", exact: true });
|
|
await expect(redownload).toHaveCount(1);
|
|
await expect(redownload).toBeVisible();
|
|
await redownload.click();
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog).toContainText("请勿重复提交");
|
|
await expect(dialog.getByRole("textbox")).toHaveCount(0);
|
|
const received = page.waitForEvent("download");
|
|
await dialog.getByRole("button", { name: "确认下载" }).click();
|
|
const downloaded = await received;
|
|
assert.equal(downloaded.suggestedFilename(), `报销制单_${batch.batchNo}.xls`);
|
|
assert.equal(readFileSync(await downloaded.path()).subarray(0, 8).toString("hex"), "d0cf11e0a1b11ae1");
|
|
await expect(dialog).toBeHidden();
|
|
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
|
|
await page.goBack();
|
|
await expect(sourceActions).toContainText("已生成银行制单");
|
|
await expect(page.getByRole("link", { name: "创建批次", exact: true })).toHaveCount(0);
|
|
await expect(sourceLink).toHaveCount(1);
|
|
const sourceProgress = page.getByTestId(mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress");
|
|
await expect(sourceProgress).toHaveCount(1);
|
|
const actionBounds = await sourceActions.boundingBox();
|
|
const progressBounds = await sourceProgress.boundingBox();
|
|
assert.ok(progressBounds.y > actionBounds.y + actionBounds.height);
|
|
if (!mobile) assert.ok(Math.abs(progressBounds.x - actionBounds.x) < 35);
|
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
passed.push("payment: real authenticated desktop/mobile reimbursement-to-batch download and return agree on stage, unique link, action-before-progress; no reason input; repeat warning retained");
|
|
}
|
|
passed.push("payment: real finance create, CEO review, unauthorized export refused, Chinese BIFF8 first/re-download without reason, audited and still unpaid");
|
|
if (process.env.RUN_HANDOFF_PROJECTION_TEST === "1") {
|
|
await handoffCheck(approvers.finance, "payment_execute", batch.id, 1);
|
|
assert.equal((await post(`/payment-batches/${batch.id}/execute`, {}, approvers.finance.id)).status, 409);
|
|
const receipt = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jD1sAAAAASUVORK5CYII=", "base64");
|
|
const upload = new FormData();
|
|
upload.append("files", new Blob([receipt], { type: "image/png" }), "synthetic-receipt.png");
|
|
const uploaded = await fetch(`${origin}/api/payment-batches/${batch.id}/receipt-attachments`, {
|
|
method: "POST", headers: { authorization: `Bearer ${tokens.get(approvers.finance.id)}` }, body: upload, signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(uploaded.status, 200, "synthetic receipt upload");
|
|
const executed = await post(`/payment-batches/${batch.id}/execute`, {}, approvers.finance.id);
|
|
assert.equal(executed.status, 200);
|
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: form.id } })).status, "paid");
|
|
await handoffCheck(approvers.finance, "payment_execute", batch.id, 0);
|
|
await handoffCheck(approvers.finance, "reimbursement_batch", form.id, 0);
|
|
passed.push("handoffs: real read-only portal return/resubmit/create/review/export/synthetic receipt/paid lifecycle; unique stable counts, no self-review, no bank connection");
|
|
}
|
|
};
|