文件
hyb-oa/server/tests/unit/routes/purchase-request.errors.test.js
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

303 行
30 KiB
JavaScript

const { enableNumbering } = require("../helpers/activeNumbering");
const Fastify = require("fastify");
const multipart = require("@fastify/multipart");
const { AppError } = require("../../../src/utils/AppError");
const workflow = require("../../../src/utils/workflow");
const feeControl = require("../../../src/services/feeControl");
const budget = require("../../../src/services/budgetPool");
const purchaseBudget = require("../../../src/services/purchaseBudget");
const cutover = require("../../../src/services/cutover");
const notifications = require("../../../src/services/notifications");
const email = require("../../../src/services/email");
const dispatcher = require("../../../src/services/notificationDispatcher");
const policy = require("../../../src/services/purchaseAttachmentPolicy");
const mocks = {};
const collaborators = [[workflow, "parseWorkflowSteps"], [workflow, "resolveActionableStep"], [feeControl, "resolveEntityPrecheck"], [feeControl, "createRiskFlagsFromPrecheck"], [budget, "resolveApplicableBudgetPoolByContext"], [purchaseBudget, "reserveBudgetForEntity"], [purchaseBudget, "releaseReservedBudgetIfAny"], [cutover, "ensureCutoverAllowed"], [notifications, "resolveRecipientsForAssignee"], [notifications, "createNotificationsWithRecords"], [email, "sendNotificationEmailSafe"], [dispatcher, "dispatchNotificationBatch"], [policy, "validateAndNormalizePurchaseAttachment"]];
const saved = collaborators.map(([object, key]) => object[key]);
collaborators.forEach(([object, key]) => { object[key] = mocks[key] = vi.fn(); });
const routePath = require.resolve("../../../src/routes/purchaseRequests");
const savedRoute = require.cache[routePath]; delete require.cache[routePath]; const routes = require(routePath);
collaborators.forEach(([object, key], i) => { object[key] = saved[i]; });
afterAll(() => { if (savedRoute) require.cache[routePath] = savedRoute; else delete require.cache[routePath]; });
const ID = "11111111-1111-4111-8111-111111111111";
const ACTOR = "22222222-2222-4222-8222-222222222222";
const COMPANY = "33333333-3333-4333-8333-333333333333";
const CENTER = "44444444-4444-4444-8444-444444444444";
const OTHER = "55555555-5555-4555-8555-555555555555";
const apps = [];
beforeEach(() => {
Object.values(mocks).forEach((fn) => fn.mockReset());
mocks.parseWorkflowSteps.mockReturnValue([{ id: "synthetic-step" }]);
mocks.resolveActionableStep.mockResolvedValue({ actionable: { step: { id: "synthetic-step", name: "合成审批" }, assigneeId: OTHER, assigneeRole: null }, skipped: [] });
mocks.resolveEntityPrecheck.mockResolvedValue({ decision: "pass", blockingReasons: [], riskFlags: [] });
mocks.createRiskFlagsFromPrecheck.mockResolvedValue([]); mocks.resolveApplicableBudgetPoolByContext.mockResolvedValue({ id: OTHER });
mocks.reserveBudgetForEntity.mockResolvedValue({ applied: true, reservedAmount: "12.50" }); mocks.releaseReservedBudgetIfAny.mockResolvedValue({ applied: true, items: [] });
mocks.ensureCutoverAllowed.mockResolvedValue({}); mocks.resolveRecipientsForAssignee.mockResolvedValue([OTHER]); mocks.createNotificationsWithRecords.mockResolvedValue([]);
mocks.sendNotificationEmailSafe.mockResolvedValue({}); mocks.dispatchNotificationBatch.mockResolvedValue({});
});
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
async function build({ allowed = true, actorId = ACTOR, budgetGate = true, projectGate = true, budgetMode = "warn" } = {}) {
const app = Fastify(); apps.push(app);
const current = { numbering_version: 1, id: ID, request_no: "SYNTHETIC", applicant_id: ACTOR, submitter_id: null, department_id: CENTER, legal_entity_id: COMPANY, cost_center_id: CENTER, supplier_id: OTHER, document_category_id: OTHER, budget_pool_id: null, project_id: null, title: "合成采购", currency_code: "CNY", total_amount: "12.50", status: "draft", items: [{ id: OTHER, description: "合成明细", quantity: "1.00", unit_price: "12.50", line_amount: "12.50" }], attachments: [], workflow_instances: [] };
const document = { id: OTHER, code: "PR_STANDARD", entity_type: "purchase_request", enabled: true, archived_at: null };
const model = (row) => ({ findUnique: vi.fn(async () => row), findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn(async () => [row]), count: vi.fn().mockResolvedValue(1), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => Object.assign(row, Object.fromEntries(Object.entries(data).filter(([,v]) => v !== undefined)))), updateMany: vi.fn().mockResolvedValue({ count: 1 }) });
const prisma = {
purchaseRequest: model(current), legalEntity: model({ id: COMPANY, enabled: true }), costCenter: model({ id: CENTER, enabled: true, legal_entity_id: COMPANY }),
supplier: model({ id: OTHER, enabled: true, legal_entity_id: COMPANY }), documentCategory: model(document), budgetPool: model({ id: OTHER, status: "active" }),
project: model({ id: OTHER, status: "active", owner_employee_id: ACTOR, members: [] }),
purchaseRequestItem: { deleteMany: vi.fn().mockResolvedValue({}), createMany: vi.fn().mockResolvedValue({}), aggregate: vi.fn().mockResolvedValue({ _sum: { line_amount: "12.50" } }) },
workflowDefinition: { findFirst: vi.fn().mockResolvedValue({ id: OTHER, definition_json: {} }) },
workflowInstance: model({ id: OTHER }), workflowTask: model({ id: OTHER, assignee_id: OTHER, assignee_role: null }), workflowHistory: model({ id: OTHER }),
auditLog: model({ id: OTHER }), entityRiskFlag: { findMany: vi.fn().mockResolvedValue([]) },
businessNumberPolicy: { findUnique: vi.fn(async () => null) },
$executeRaw: vi.fn(async () => 0),
documentSequence: { findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn().mockResolvedValue({ current_number: 1 }) },
purchaseOrderArchive: { findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn(async ({ create }) => ({ id: OTHER, ...create })) },
purchaseAttachment: model({ id: OTHER }),
featureFlag: { findUnique: vi.fn(async ({ where }) => ({ flag_key: where.flag_key, override_mode: (where.flag_key === "project_linking_v1" ? projectGate : where.flag_key === "budget_precheck_v1" ? budgetGate : false) ? "force_on" : "force_off", rollout_type: "global", department_ids: [], version: 1 })) },
$queryRaw: vi.fn(async () => [{ ...current }]),
};
prisma.$transaction = vi.fn(async (cb) => cb(prisma));
const storage = { writeBuffer: vi.fn().mockResolvedValue("synthetic-object"), openReadStream: vi.fn() };
app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", budgetControlMode: budgetMode, projectLinkingEnabled: true, budgetPrecheckEnabled: true, maxUploadSizeBytes: 1024 });
app.decorate("authenticate", async (request) => { request.employee = { id: actorId, department_id: CENTER, permissionCodes: allowed ? ["*"] : [] }; });
app.decorate("hasPermission", () => allowed);
app.decorate("requirePermission", () => async (request) => { await app.authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); });
app.setErrorHandler((error, _, reply) => reply.status(error instanceof AppError ? error.statusCode : 500).send({ error: { code: error.code, details: error.details ?? {} } }));
await app.register(multipart, { limits: { files: 1, fileSize: 1024 } });
await app.register(routes, { prefix: "/api", attachmentStorage: storage });
return { app, prisma, current, storage };
}
const req = (method, suffix = "", payload) => ({ method, url: `/api/purchase-requests${suffix}`, payload });
function rejected(r, reason, status, details = {}) { expect(r.statusCode).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason, ...details } }); }
function noWrites(prisma) {
for (const [name, model] of Object.entries(prisma)) for (const op of ["create", "update", "updateMany", "createMany", "deleteMany", "upsert"]) if (model[op]) expect(model[op], `${name}.${op}`).not.toHaveBeenCalled();
expect(mocks.reserveBudgetForEntity).not.toHaveBeenCalled(); expect(mocks.releaseReservedBudgetIfAny).not.toHaveBeenCalled();
expect(mocks.createNotificationsWithRecords).not.toHaveBeenCalled(); expect(mocks.sendNotificationEmailSafe).not.toHaveBeenCalled(); expect(mocks.dispatchNotificationBatch).not.toHaveBeenCalled();
}
describe("purchase input, references and state reasons", () => {
it.each([["GET", "?page=0", undefined, "query_invalid"], ["PATCH", `/${ID}`, { expectedDeliveryDate: "2026-02-30" }, "update_input_invalid"], ["POST", `/${ID}/archive-order`, {}, "archive_input_invalid"]])("explains %s %s input", async (method, suffix, payload, reason) => { const { app, prisma } = await build(); rejected(await app.inject(req(method, suffix, payload)), `purchase_request.${reason}`, 400, { fieldErrors: expect.any(Object) }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); });
it.each([["GET", ""], ["PATCH", ""], ["POST", "/precheck"], ["POST", "/submit"], ["POST", "/withdraw"], ["POST", "/archive-order"], ["POST", "/attachments"]])("explains %s invalid request id %s", async (method, suffix) => { const { app, prisma } = await build(); rejected(await app.inject(req(method, `/bad${suffix}`, method === "GET" ? undefined : {})), "purchase_request.id_invalid", 400, { id: "invalid_uuid" }); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.purchaseRequest.findUnique).not.toHaveBeenCalled(); noWrites(prisma); });
it.each(["DELETE", "POST"])("explains %s invalid attachment id", async (method) => { const { app, prisma } = await build(); rejected(await app.inject(req(method, `/${ID}/attachments/bad${method === "POST" ? "/download" : ""}`, {})), "purchase_attachment.id_invalid", 400, { attachmentId: "invalid_uuid" }); expect(prisma.purchaseRequest.findUnique).not.toHaveBeenCalled(); noWrites(prisma); });
it.each([
["legalEntity", "legalEntityId", null, "company_not_found", "not_found"], ["legalEntity", "legalEntityId", { enabled: false }, "company_inactive", "disabled_or_archived"],
["costCenter", "costCenterId", null, "cost_center_not_found", "not_found"], ["costCenter", "costCenterId", { enabled: true, archived_at: new Date("2026-01-01") }, "cost_center_inactive", "disabled_or_archived"], ["costCenter", "costCenterId", { enabled: true, legal_entity_id: OTHER }, "cost_center_company_mismatch", "legalEntityId_mismatch"],
["supplier", "supplierId", null, "supplier_not_found", "not_found"], ["supplier", "supplierId", { enabled: false }, "supplier_inactive", "disabled_or_archived"], ["supplier", "supplierId", { enabled: true, legal_entity_id: OTHER }, "supplier_company_mismatch", "legalEntityId_mismatch"],
["documentCategory", "documentCategoryId", null, "document_category_not_found", "not_found"], ["documentCategory", "documentCategoryId", { enabled: true, entity_type: "expense" }, "document_category_type_invalid", "entity_type_mismatch"], ["documentCategory", "documentCategoryId", { enabled: false, entity_type: "purchase_request" }, "document_category_inactive", "disabled_or_archived"],
["budgetPool", "budgetPoolId", null, "budget_pool_not_found", "not_found"], ["budgetPool", "budgetPoolId", { status: "inactive" }, "budget_pool_inactive", "disabled_or_archived"],
])("explains %s reference %s", async (model, field, row, reason, detail) => { const { app, prisma } = await build(); prisma[model].findUnique.mockResolvedValue(row); rejected(await app.inject(req("PATCH", `/${ID}`, { [field]: OTHER })), `purchase_request.${reason}`, 409, { [field]: detail }); noWrites(prisma); });
it.each([[false, "active", "project_link_disabled"], [true, "archived", "project_archived"]])("explains project gate %s and status %s", async (projectGate, status, reason) => { const { app, prisma } = await build({ projectGate }); prisma.project.findUnique.mockResolvedValue({ id: OTHER, status, owner_employee_id: ACTOR }); rejected(await app.inject(req("PATCH", `/${ID}`, { projectId: OTHER })), `purchase_request.${reason}`, 409); noWrites(prisma); if (!projectGate) expect(prisma.project.findUnique).not.toHaveBeenCalled(); });
it.each([["PATCH", "", "reviewing", {}, "not_editable", "only_draft_editable"], ["POST", "/submit", "approved", undefined, "not_submittable", "only_draft_submittable"], ["POST", "/withdraw", "draft", undefined, "not_withdrawable", "only_reviewing_withdrawable"], ["POST", "/archive-order", "draft", { poNo: "SYNTHETIC" }, "not_archivable", "only_approved_archivable"]])("explains %s %s state %s", async (method, suffix, status, payload, reason, detail) => { const { app, prisma, current } = await build(); current.status = status; rejected(await app.inject(req(method, `/${ID}${suffix}`, payload)), `purchase_request.${reason}`, 409, { status: detail }); noWrites(prisma); });
it("preserves permissions and applicant ownership before writes", async () => {
const denied = await build({ allowed: false }); expect((await denied.app.inject(req("PATCH", "/bad", {}))).statusCode).toBe(403); noWrites(denied.prisma);
const other = await build({ actorId: OTHER }); expect((await other.app.inject(req("PATCH", `/${ID}`, {}))).statusCode).toBe(403); noWrites(other.prisma);
});
it("retains empty PATCH, supplier without company binding and explicit null clearing", async () => {
const { app, prisma, current } = await build();
expect((await app.inject(req("PATCH", `/${ID}`, {}))).statusCode).toBe(200);
prisma.supplier.findUnique.mockResolvedValue({ id: OTHER, enabled: true, legal_entity_id: null });
expect((await app.inject(req("PATCH", `/${ID}`, { supplierId: OTHER, projectId: null, expectedDeliveryDate: null, items: [] }))).statusCode).toBe(200);
expect(current).toMatchObject({ project_id: null, expected_delivery_date: null, total_amount: "0.00" }); expect(prisma.purchaseRequestItem.deleteMany).toHaveBeenCalledOnce(); expect(prisma.purchaseRequestItem.createMany).not.toHaveBeenCalled();
});
it.each([["quantity", "9".repeat(400), "quantity_invalid", "invalid_decimal"], ["unitPrice", "9".repeat(400), "unit_price_invalid", "invalid_decimal"], ["quantity", "0", "quantity_too_small", "must_be_positive"], ["quantity", 0.001, "quantity_too_small", "must_be_positive"]])("preserves prior update/delete before numeric rejection %s", async (field, value, reason, detail) => {
const { app, prisma } = await build(); const r = await app.inject(req("PATCH", `/${ID}`, { items: [{ description: "合成明细", quantity: "1", unitPrice: "1", [field]: value }] }));
rejected(r, `purchase_request.${reason}`, 400, { [field]: detail }); expect(prisma.purchaseRequest.update).toHaveBeenCalledOnce(); expect(prisma.purchaseRequestItem.deleteMany).toHaveBeenCalledOnce(); expect(prisma.purchaseRequestItem.createMany).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("keeps negative unit price at schema and valid decimal boundaries", async () => {
const { app, prisma } = await build(); rejected(await app.inject(req("PATCH", `/${ID}`, { items: [{ description: "合成", quantity: "1", unitPrice: -1 }] })), "purchase_request.update_input_invalid", 400); noWrites(prisma);
expect((await app.inject(req("PATCH", `/${ID}`, { items: [{ description: " 合成 ", quantity: "0.01", unitPrice: "0" }] }))).statusCode).toBe(200);
expect(prisma.purchaseRequestItem.createMany.mock.calls[0][0].data[0]).toMatchObject({ description: "合成", quantity: "0.01", unit_price: "0.00", line_amount: "0.00" });
});
});
describe("purchase submission error contracts", () => {
it("rechecks the authoritative budget inside the locked submit transaction before workflow writes", async () => {
const { app, prisma } = await build();
mocks.resolveEntityPrecheck.mockResolvedValue({ decision: "block", blockingReasons: ["synthetic_block"] });
rejected(await app.inject(req("POST", `/${ID}/submit`)), "purchase_request.precheck_blocked", 409, { precheck: ["synthetic_block"] });
expect(prisma.$transaction).toHaveBeenCalledOnce();
expect(mocks.resolveApplicableBudgetPoolByContext.mock.invocationCallOrder[0]).toBeLessThan(mocks.resolveEntityPrecheck.mock.invocationCallOrder[0]);
expect(prisma.purchaseRequest.update).toHaveBeenCalledOnce();
expect(prisma.purchaseRequest.update.mock.calls[0][0].data).toMatchObject({ total_amount: "12.50" });
expect(prisma.workflowInstance.create).not.toHaveBeenCalled();
expect(mocks.reserveBudgetForEntity).not.toHaveBeenCalled();
expect(mocks.createRiskFlagsFromPrecheck).not.toHaveBeenCalled();
});
it.each(["workflow_missing", "approver_unavailable"])("explains %s before transaction", async (reason) => { const { app, prisma } = await build(); if (reason === "workflow_missing") prisma.workflowDefinition.findFirst.mockResolvedValue(null); else mocks.resolveActionableStep.mockResolvedValue({ actionable: null, skipped: [] }); rejected(await app.inject(req("POST", `/${ID}/submit`)), `purchase_request.${reason}`, 409, { workflow: reason === "workflow_missing" ? "missing_definition" : "no_actionable_approver" }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); });
it.each([["legal_entity_id", "legalEntityId", "company_required"], ["cost_center_id", "costCenterId", "cost_center_required"], ["supplier_id", "supplierId", "supplier_required"], ["document_category_id", "documentCategoryId", "document_category_required"], ["items", "items", "items_required"]])("requires %s before recomputation", async (field, detail, reason) => { const { app, prisma, current } = await build(); current[field] = field === "items" ? [] : null; rejected(await app.inject(req("POST", `/${ID}/submit`)), `purchase_request.${reason}`, 400, { [detail]: field === "items" ? "at_least_one_required" : "required" }); noWrites(prisma); });
it("retains concurrent applicant recheck", async () => {
const { app, prisma, current } = await build(); prisma.purchaseRequest.findUnique.mockResolvedValueOnce({ ...current, applicant_id: OTHER }).mockResolvedValueOnce(current);
rejected(await app.inject(req("POST", `/${ID}/submit`)), "purchase_request.approver_unavailable", 409, { workflow: "no_actionable_approver" }); noWrites(prisma);
});
it.each(["mismatch", "missing"])("recalculates then validates pool identity before budget %s rejection", async (kind) => {
const { app, prisma, current } = await build({ budgetGate: false, budgetMode: "block" });
if (kind === "mismatch") current.budget_pool_id = OTHER;
mocks.resolveApplicableBudgetPoolByContext.mockResolvedValue(null);
rejected(await app.inject(req("POST", `/${ID}/submit`)), `purchase_request.${kind === "mismatch" ? "budget_context_mismatch" : "budget_required"}`, 409, kind === "mismatch" ? { budgetPoolId: "context_mismatch" } : { budget: "missing_budget_pool" });
expect(prisma.purchaseRequest.update).toHaveBeenCalledOnce(); expect(prisma.purchaseRequest.update.mock.calls[0][0].data).toMatchObject({ total_amount: "12.50" }); expect(mocks.createRiskFlagsFromPrecheck).toHaveBeenCalledTimes(kind === "mismatch" ? 0 : 1);
expect(mocks.reserveBudgetForEntity).not.toHaveBeenCalled(); expect(prisma.workflowInstance.create).not.toHaveBeenCalled(); expect(prisma.documentSequence.upsert).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); expect(mocks.createNotificationsWithRecords).not.toHaveBeenCalled();
});
it("retains synthetic cutover refusal before business mutations", async () => { const { app, prisma } = await build(); mocks.ensureCutoverAllowed.mockRejectedValue(new AppError({ code: "VALID_001", statusCode: 409, message: "合成切换拒绝", details: { cutover: "synthetic_block" } })); const r = await app.inject(req("POST", `/${ID}/submit`)); expect(r.statusCode).toBe(409); expect(r.json().error.details.cutover).toBe("synthetic_block"); noWrites(prisma); });
it("retains submit/withdraw with budget and notification stubs", async () => {
const { app, prisma, current } = await build(); const r = await app.inject(req("POST", `/${ID}/submit`)); expect(r.statusCode).toBe(200); expect(current.status).toBe("reviewing"); expect(mocks.reserveBudgetForEntity).toHaveBeenCalledOnce(); expect(prisma.workflowInstance.create).toHaveBeenCalledOnce(); expect(prisma.workflowTask.create).toHaveBeenCalledOnce(); expect(mocks.createNotificationsWithRecords).toHaveBeenCalledOnce();
prisma.workflowInstance.findFirst.mockResolvedValue({ id: OTHER }); expect((await app.inject(req("POST", `/${ID}/withdraw`))).statusCode).toBe(200); expect(current.status).toBe("draft"); expect(mocks.releaseReservedBudgetIfAny).toHaveBeenCalledOnce(); expect(prisma.workflowTask.updateMany).toHaveBeenCalledOnce();
});
it("retains blank draft creation and approved order archive", async () => {
const { app, prisma, current } = await build(); expect((await app.inject(req("POST", ""))).statusCode).toBe(200); expect(prisma.purchaseRequest.create.mock.calls[0][0].data).toMatchObject({ title: "", status: "draft", total_amount: "0.00" });
current.status = "approved"; const r = await app.inject(req("POST", `/${ID}/archive-order`, { poNo: "SYNTHETIC", contractNo: null, archivedFiles: [] })); expect(r.statusCode).toBe(200); expect(prisma.purchaseOrderArchive.upsert).toHaveBeenCalledOnce(); expect(prisma.auditLog.create.mock.calls.map(([x]) => x.data.action)).toContain("PURCHASE_ORDER_ARCHIVED");
});
});
describe("purchase attachment policy-to-route mapping", () => {
it.each([["dangerous_filename", "dangerous_filename"], ["corrupt_content", "corrupt_content"], ["multiple_pages", "multiple_pages"], ["pixel_limit", "pixel_limit"], ["type_mismatch", "type_mismatch"], ["empty_file", "empty_file"], ["size_limit", "too_large"], ["unsupported_type", "unsupported_type"], ["future_synthetic_policy", "validation_failed"]])("preserves explicit policy %s and safe mapping", async (policyReason, reason) => {
const { app, prisma, storage } = await build(); mocks.validateAndNormalizePurchaseAttachment.mockRejectedValue(new policy.PurchaseAttachmentPolicyError(policyReason));
const r = await app.inject({ method: "POST", url: `/api/purchase-requests/${ID}/attachments`, headers: { "content-type": "multipart/form-data; boundary=synthetic-policy" }, payload: Buffer.from('--synthetic-policy\r\nContent-Disposition: form-data; name="file"; filename="synthetic.pdf"\r\nContent-Type: application/pdf\r\n\r\nsynthetic\r\n--synthetic-policy--\r\n') });
rejected(r, `purchase_attachment.${reason}`, 400, { file: policyReason }); expect(mocks.validateAndNormalizePurchaseAttachment).toHaveBeenCalledOnce(); expect(storage.writeBuffer).not.toHaveBeenCalled(); noWrites(prisma);
});
});
describe("purchase draft write transaction boundary", () => {
it("commits draft and audit before hydrating the unchanged detail response", async () => {
const { app, prisma, current } = await build();
const events = [];
let committed = false;
const tx = {
businessNumberPolicy: { findUnique: vi.fn(async () => null) },
$executeRaw: vi.fn(async () => 0),
purchaseRequest: { create: vi.fn(async ({ data, select }) => {
events.push("draft");
expect(Object.keys(select).sort()).toEqual(["applicant_id", "department_id", "id"]);
Object.assign(current, data, { items: [], applicant: { id: ACTOR, name: "合成申请人", employee_no: "SYNTHETIC" } });
return { id: ID, applicant_id: ACTOR, department_id: CENTER };
}) },
auditLog: { create: vi.fn(async () => { events.push("audit"); return {}; }) },
};
prisma.$transaction.mockImplementation(async cb => {
events.push("begin"); const result = await cb(tx); committed = true; events.push("commit"); return result;
});
prisma.purchaseRequest.findUnique.mockImplementation(async ({ where, select }) => {
expect(committed).toBe(true); expect(where).toEqual({ id: ID }); expect(select.items).toBeTruthy();
events.push("detail"); return current;
});
const response = await app.inject(req("POST"));
expect(response.statusCode).toBe(200);
expect(response.json().data.purchaseRequest).toMatchObject({ id: ID, status: "draft", title: "", totalAmount: "0.00", items: [], applicant: { id: ACTOR, name: "合成申请人" } });
expect(events).toEqual(["begin", "draft", "audit", "commit", "detail"]);
expect(tx.auditLog.create.mock.calls[0][0].data).toMatchObject({ action: "PURCHASE_REQUEST_CREATED", target_id: ID, actor_id: ACTOR });
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it.each(["draft", "audit", "commit"])("does not read or return success when %s fails", async phase => {
const { app, prisma } = await build();
const failure = new Error("synthetic transaction failure");
const create = vi.fn(async () => { if (phase === "draft") throw failure; return { id: ID, applicant_id: ACTOR, department_id: CENTER }; });
const audit = vi.fn(async () => { if (phase === "audit") throw failure; return {}; });
prisma.$transaction.mockImplementation(async cb => {
const result = await cb({ purchaseRequest: { create }, auditLog: { create: audit } });
if (phase === "commit") throw failure; return result;
});
expect((await app.inject(req("POST"))).statusCode).toBe(500);
expect(prisma.purchaseRequest.findUnique).not.toHaveBeenCalled();
if (phase === "draft") expect(audit).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("purchase draft PATCH transaction boundary", () => {
async function boundary(failure) {
const { app, prisma, current } = await build();
const events = [];
let active = false;
let committed = false;
const operation = (name, run) => vi.fn(async (...args) => {
expect(active, `${name} must remain transactional`).toBe(true);
events.push(name);
if (failure === name) throw new Error(`synthetic ${name} failure`);
return run(...args);
});
const tx = {
...prisma,
$queryRaw: operation("lock", async parts => {
expect(parts.join("?")).toContain("FOR UPDATE");
return [{ ...current }];
}),
purchaseRequest: {
update: operation("update", async ({ data }) => Object.assign(current,
Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined)))),
findUnique: operation("transactional-detail", async () => {
throw new Error("response hydration must not consume the write transaction");
}),
},
purchaseRequestItem: {
deleteMany: operation("items-delete", async () => { current.items = []; return { count: 1 }; }),
createMany: operation("items-create", async ({ data }) => {
current.items = data.map(item => ({ id: OTHER, ...item }));
return { count: data.length };
}),
},
auditLog: { create: operation("audit", async () => ({})) },
};
prisma.$transaction.mockImplementation(async cb => {
events.push("begin"); active = true;
try {
const result = await cb(tx);
if (failure === "commit") throw new Error("synthetic commit failure");
committed = true; events.push("commit"); return result;
} finally { active = false; }
});
prisma.purchaseRequest.findUnique.mockImplementation(async ({ where, select }) => {
expect(committed).toBe(true); expect(active).toBe(false);
expect(where).toEqual({ id: ID });
expect(select).toMatchObject({ items: expect.any(Object), attachments: expect.any(Object) });
events.push("detail");
if (failure === "detail") throw new Error("synthetic detail failure");
return current;
});
return { app, prisma, tx, events, committed: () => committed };
}
const item = { description: "合成替换明细", quantity: "2", unitPrice: "7.50" };
it.each([undefined, [], [item]])("hydrates saved draft only after writes and audit commit: items=%j", async items => {
const { app, prisma, tx, events } = await boundary();
const response = await app.inject(req("PATCH", `/${ID}`, {
title: "合成已保存采购", legalEntityId: COMPANY, costCenterId: CENTER,
supplierId: OTHER, documentCategoryId: OTHER, ...(items === undefined ? {} : { items }),
}));
expect(response.statusCode).toBe(200);
const saved = response.json().data.purchaseRequest;
expect(saved).toMatchObject({ id: ID, title: "合成已保存采购", status: "draft",
totalAmount: items === undefined ? "12.50" : items.length ? "15.00" : "0.00" });
expect(saved.items).toHaveLength(items === undefined ? 1 : items.length);
if (items?.length) expect(saved.items[0].description).toBe(item.description);
expect(events).toEqual(["begin", "lock", "update",
...(items === undefined ? [] : ["items-delete", ...(items.length ? ["items-create"] : [])]),
"update", "audit", "commit", "detail"]);
expect(tx.auditLog.create.mock.calls[0][0].data).toMatchObject({ action: "PURCHASE_REQUEST_UPDATED", target_id: ID, actor_id: ACTOR });
expect(tx.purchaseRequest.findUnique).not.toHaveBeenCalled();
expect(prisma.purchaseRequest.update).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it.each(["update", "items-delete", "items-create", "audit", "commit", "detail"])("never returns success or retries on %s failure", async phase => {
const { app, prisma, tx, events, committed } = await boundary(phase);
const response = await app.inject(req("PATCH", `/${ID}`, { title: "合成修改", items: [item] }));
expect(response.statusCode).toBe(500);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.purchaseRequest.findUnique).toHaveBeenCalledTimes(phase === "detail" ? 1 : 0);
expect(committed()).toBe(phase === "detail");
if (["update", "items-delete", "items-create"].includes(phase)) expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(events.includes("commit")).toBe(phase === "detail");
expect(prisma.purchaseRequest.update).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
it("pins active policy on draft creation without allocating a purchase number", async () => {
const { app, prisma } = await build(); enableNumbering(prisma);
const response = await app.inject(req("POST"));
expect(response.statusCode).toBe(200);
expect(prisma.purchaseRequest.create.mock.calls[0][0].data.numbering_version).toBe(2);
expect(prisma.businessNumberCounter.upsert).not.toHaveBeenCalled();
});