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(); });