文件
hyb-oa/scripts/qa/test-reimbursement-retained-delete.cjs
T
wangsli 229867f5fd
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
fix: 财务本人报销由总经理独立审批(09-04-reimbursement-state-gap-finance-view)
2026-09-12 20:03:29 +08:00

186 行
12 KiB
JavaScript

const assert = require("node:assert/strict");
const { readFileSync, writeFileSync } = require("node:fs");
const { createHash, randomUUID } = require("node:crypto");
const { createRequire } = require("node:module");
const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
module.exports = async function verifyRetainedDeletion(context) {
const {
prisma, pool, origin, tokens, employee, manager, expenseType,
makeForm, makeInvoice, bind, post, uploadedRecord, storageDir, passed,
root, browser, preview, loginPassword,
} = context;
const remove = (form, actor) => post(`/reimbursements/${form.id}`, {}, actor, "DELETE");
const get = async (url, actor = employee.id) => {
const response = await fetch(`${origin}/api${url}`, {
headers: { authorization: `Bearer ${tokens.get(actor)}` }, signal: AbortSignal.timeout(15000),
});
return { status: response.status, data: (await response.json()).data };
};
const populate = async (form, invoice) => {
assert.equal((await bind(invoice, form)).status, 200);
const item = await prisma.reimbursementItem.findFirst({ where: { reimbursement_id: form.id } });
assert.equal((await post(`/reimbursements/${form.id}/items/classify`, { itemIds: [item.id], expenseTypeId: expenseType.id })).status, 200);
assert.equal((await post(`/reimbursements/${form.id}/items/${item.id}`, { description: "synthetic retained history" }, null, "PATCH")).status, 200);
};
const prepare = async (action) => {
const form = await makeForm();
const fileName = `synthetic-delete-${randomUUID()}.pdf`;
const bytes = Buffer.concat([readFileSync(path.join(storageDir, uploadedRecord.storage_path)), Buffer.from(`\n% ${fileName}\n`)]);
writeFileSync(path.join(storageDir, fileName), bytes);
const invoice = await makeInvoice({
storage_path: fileName,
file_sha256: createHash("sha256").update(bytes).digest("hex"),
file_size: bytes.length,
});
await populate(form, invoice);
const submitted = await post(`/reimbursements/${form.id}/submit`, {});
assert.equal(submitted.status, 200);
const instanceId = submitted.body.data.workflowInstanceId;
const task = await prisma.workflowTask.findFirst({ where: { instance_id: instanceId, status: "pending" } });
if (action === "withdraw") {
assert.equal((await post(`/reimbursements/${form.id}/withdraw`, { comment: "synthetic withdraw" })).status, 200);
} else {
assert.equal((await post(`/workflow/tasks/${task.id}/${action}`, { comment: "synthetic correction" }, manager.id)).status, 200);
}
return { form, invoice, instanceId };
};
const verify = async ({ form, invoice, instanceId }, action) => {
const record = await prisma.reimbursementForm.findUnique({ where: { id: form.id } });
assert.equal(record.status, "cancelled");
assert.ok(record.deleted_at);
assert.equal(record.deleted_by, employee.id);
assert.ok(await prisma.workflowInstance.findUnique({ where: { id: instanceId } }));
assert.ok(await prisma.workflowHistory.count({ where: { instance_id: instanceId } }));
assert.ok(await prisma.reimbursementItem.count({ where: { reimbursement_id: form.id } }));
const detail = await get(`/reimbursements/${form.id}`);
assert.equal(detail.status, 200);
assert.ok(detail.data.deletedAt);
assert.equal(detail.data.invoices.length, 0);
if (action !== "withdraw") {
const evidence = await prisma.reimbursementAttachment.findFirst({ where: { reimbursement_id: form.id, attachment_type: "invoice" } });
assert.ok(evidence);
const response = await fetch(`${origin}/api/reimbursements/${form.id}/attachments/${evidence.id}/file`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } });
assert.equal(response.status, 200);
const bytes = Buffer.from(await response.arrayBuffer());
assert.equal(createHash("sha256").update(bytes).digest("hex"), evidence.file_sha256);
assert.deepEqual(bytes, readFileSync(path.join(storageDir, invoice.storage_path)));
}
for (const scope of ["own", "all"]) {
const listing = await get(`/reimbursements?scope=${scope}&status=cancelled`, scope === "all" ? manager.id : employee.id);
assert.equal(listing.status, 200);
assert.ok(!listing.data.items.some((item) => item.id === form.id));
}
const summary = await get("/reimbursements/stats/summary", manager.id);
assert.equal(summary.status, 200);
assert.equal(summary.data.byStatus.cancelled.count, 0);
const serverRequire = createRequire(path.join(root, "server/package.json"));
const ledgerWhere = serverRequire("./src/services/financeLedger").buildFinanceLedgerWhere({ status: "cancelled" });
assert.equal(await prisma.reimbursementForm.count({ where: ledgerWhere }), 0);
assert.equal((await remove(form)).status, 200);
assert.equal(await prisma.auditLog.count({ where: { action: "REIMBURSEMENT_DELETED", target_id: form.id } }), 1);
assert.equal((await post(`/reimbursements/${form.id}/submit`, {})).status, 409);
assert.equal((await post(`/reimbursements/${form.id}`, { description: "forbidden" }, null, "PATCH")).status, 409);
assert.equal((await bind(await makeInvoice(), form)).status, 409);
const replacement = await makeForm();
await populate(replacement, invoice);
assert.equal((await remove(form)).status, 200);
assert.equal((await prisma.invoicePool.findUnique({ where: { id: invoice.id } })).linked_reimbursement_id, replacement.id);
assert.equal((await post(`/reimbursements/${replacement.id}/submit`, {})).status, 200);
const historicalDetail = await get(`/reimbursements/${form.id}`);
assert.ok(historicalDetail.data.deletedAt);
assert.equal(historicalDetail.data.invoices.length, 0);
assert.equal(historicalDetail.data.attachments.length, detail.data.attachments.length);
assert.ok(historicalDetail.data.processingProgress.stages.every((stage) => !["pending", "current"].includes(stage.state)));
};
for (const action of ["reject", "return", "withdraw"]) {
const fixture = await prepare(action);
assert.equal((await remove(fixture.form, manager.id)).status, 403);
assert.equal((await remove(fixture.form)).status, 200);
await verify(fixture, action);
}
passed.push("reject/return/withdraw -> retained deletion -> old history readable -> invoice reused and new form submitted; old record immutable; lists/raw stats/ledger exclude deleted records");
const rollback = await prepare("reject");
await prisma.$executeRawUnsafe("CREATE FUNCTION synthetic_reject_delete_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'REIMBURSEMENT_DELETED' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$");
await prisma.$executeRawUnsafe("CREATE TRIGGER synthetic_reject_delete_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_reject_delete_audit()");
assert.equal((await remove(rollback.form)).status, 500);
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: rollback.form.id } })).status, "rejected");
assert.equal((await prisma.invoicePool.findUnique({ where: { id: rollback.invoice.id } })).linked_reimbursement_id, rollback.form.id);
assert.equal(await prisma.reimbursementAttachment.count({ where: { reimbursement_id: rollback.form.id } }), 0);
await prisma.$executeRawUnsafe("DROP TRIGGER synthetic_reject_delete_audit ON audit_logs");
passed.push("audit failure atomically rolls back deletion, invoice release and evidence attachment");
for (const competitor of ["submit", "bind", "delete"]) {
const fixture = await prepare("reject");
const blocker = await pool.connect();
const waitForLocks = async (count) => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const result = await pool.query("SELECT count(*)::int AS count FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock' AND query LIKE '%reimbursement_forms%'");
if (result.rows[0].count >= count) return;
await delay(20);
}
assert.fail("delete concurrency barrier timed out");
};
let deletion;
let competing;
try {
await blocker.query("BEGIN");
await blocker.query("SELECT id FROM reimbursement_forms WHERE id = $1 FOR UPDATE", [fixture.form.id]);
deletion = remove(fixture.form);
await waitForLocks(1);
competing = competitor === "delete" ? remove(fixture.form)
: competitor === "submit" ? post(`/reimbursements/${fixture.form.id}/submit`, {})
: bind(await makeInvoice(), fixture.form);
await waitForLocks(2);
} finally {
await blocker.query("ROLLBACK");
blocker.release();
}
const results = await Promise.all([deletion, competing]);
assert.deepEqual(results.map((result) => result.status), [200, competitor === "delete" ? 200 : 409]);
assert.equal(await prisma.auditLog.count({ where: { action: "REIMBURSEMENT_DELETED", target_id: fixture.form.id } }), 1);
}
passed.push("real PostgreSQL barriers: deletion vs submit/bind/repeated delete has one legal terminal state");
if (browser && preview) {
const { expect } = createRequire(path.join(root, "client/package.json"))("@playwright/test");
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
for (const mobile of [false, true]) {
const fixture = await prepare("reject");
const browserContext = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
try {
const fingerprint = `synthetic-delete-${mobile}`;
await browserContext.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
const login = await browserContext.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: employee.email, password: loginPassword } });
assert.equal(login.status(), 200);
const page = await browserContext.newPage();
const target = `${uiOrigin}/${mobile ? "mobile/" : ""}reimbursements/${fixture.form.id}`;
await page.goto(target);
if (!mobile) await page.getByTestId("reimbursement-more-toggle").click();
else await page.getByTestId("mobile-reimbursement-more").click();
const deleteAction = page.getByRole(mobile ? "button" : "menuitem", { name: "删除报销单", exact: true });
await expect(deleteAction).toHaveCount(1);
await deleteAction.click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("审批记录仍保留");
await page.getByTestId("action-confirm-cancel").click();
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at, null);
if (!mobile) await page.getByTestId("reimbursement-more-toggle").click();
await deleteAction.click();
await page.getByTestId("action-confirm-submit").click();
await expect.poll(async () => Boolean((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at)).toBe(true);
await page.goto(target);
await expect(page.getByText("已删除", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("reimbursement-submit")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-invoice-add")).toHaveCount(0);
if (!mobile && await page.getByTestId("reimbursement-more-toggle").count()) await page.getByTestId("reimbursement-more-toggle").click();
await expect(page.getByRole("menuitem", { name: "删除报销单", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "删除报销单", exact: true })).toHaveCount(0);
await verify(fixture, "reject");
} finally { await browserContext.close(); }
}
passed.push("desktop/mobile real browser: rejected deletion cancel/confirm, persisted historical deep link, no edit/submit/delete actions");
}
};