const { enableNumbering } = require("../helpers/activeNumbering"); const Fastify = require("fastify"); const routes = require("../../../src/routes/assets"); const { AppError } = require("../../../src/utils/AppError"); const ID = "11111111-1111-4111-8111-111111111111"; const COMPANY = "22222222-2222-4222-8222-222222222222"; const CENTER = "33333333-3333-4333-8333-333333333333"; const PERSON = "44444444-4444-4444-8444-444444444444"; const OTHER = "55555555-5555-4555-8555-555555555555"; const BODY = { name: "合成资产", categoryId: ID, legalEntityId: COMPANY, costCenterId: CENTER }; const asset = { id: ID, name: "合成资产", asset_no: "SYNTHETIC-1", asset_type: "fixed_asset", sensitivity_level: "internal", category_id: ID, legal_entity_id: COMPANY, cost_center_id: CENTER, status: "in_stock" }; const doc = { id: ID, code: "ASSET_CARD_DEFAULT", entity_type: "asset_card", enabled: true, archived_at: null, sequence_prefix: "SYNTHETIC", number_length: 6, reset_period: "yearly" }; const category = { id: ID, code: "SYNTHETIC", name: "合成分类", enabled: true, archived_at: null, document_category: doc }; function fixture() { const model = (row) => ({ findUnique: vi.fn().mockResolvedValue(row), findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => ({ ...row, ...data })) }); const prisma = { assetCard: model(asset), assetCategory: model(category), documentCategory: model(doc), legalEntity: model({ id: COMPANY, enabled: true }), costCenter: model({ id: CENTER, legal_entity_id: COMPANY, enabled: true }), employee: model({ id: PERSON, status: "active" }), purchaseRequest: model({ id: OTHER, status: "approved" }), assetAssignment: model({ id: OTHER }), auditLog: model({ id: OTHER }), integrationOutbox: model({ id: OTHER }), businessNumberPolicy: { findUnique: vi.fn(async () => null) }, $executeRaw: vi.fn(async () => 0), documentSequence: { findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn(async ({ create }) => ({ ...create, current_number: 1 })) }, featureFlag: { findUnique: vi.fn().mockResolvedValue({ override_mode: "force_off", rollout_type: "global", department_ids: [], version: 1 }) }, }; prisma.$transaction = vi.fn(async (cb) => cb(prisma)); return prisma; } async function build(prisma = fixture(), permissions = ["asset.manage"]) { const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", {}); app.decorate("authenticate", async (request) => { request.employee = { id: PERSON, permissionCodes: permissions }; }); app.decorate("hasPermission", (_, permission) => permissions.includes(permission)); app.decorate("requirePermission", (permission) => async (request) => { await app.authenticate(request); if (!permissions.includes(permission)) 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(routes, { prefix: "/api" }); return { app, prisma }; } function noWrites(prisma) { for (const [name, model] of Object.entries(prisma)) { if (name === "$transaction") continue; for (const op of ["create", "update", "upsert"]) if (model[op]) expect(model[op], `${name}.${op}`).not.toHaveBeenCalled(); } } function rejected(response, reason, status, details = {}) { expect(response.statusCode).toBe(status); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `asset.${reason}`, ...details } }); } afterEach(() => vi.unstubAllEnvs()); describe("asset operation error contracts", () => { it.each([ ["GET", "/asset-categories?enabled=bad", undefined, "category_query_invalid"], ["GET", "/assets?status=bad", undefined, "query_invalid"], ["GET", `/assets/assignees?q=${"x".repeat(101)}`, undefined, "assignee_query_invalid"], ["POST", "/asset-categories", {}, "category_create_input_invalid"], ["PATCH", `/asset-categories/${ID}`, {}, "category_update_input_invalid"], ["PATCH", `/asset-categories/${ID}`, { name: "" }, "category_update_input_invalid"], ["POST", "/assets", {}, "create_input_invalid"], ["PATCH", `/assets/${ID}`, {}, "update_input_invalid"], ["PATCH", `/assets/${ID}`, { name: "" }, "update_input_invalid"], ["POST", "/assets/import/redacted-doc-index", { items: [] }, "index_import_input_invalid"], ["POST", "/assets/import/redacted-doc-index", { items: Array.from({ length: 21 }, () => ({ name: "合成索引" })) }, "index_import_input_invalid"], ["PATCH", "/asset-categories/bad", { name: "合成分类" }, "category_id_invalid"], ["GET", "/assets/bad", undefined, "id_invalid"], ["PATCH", "/assets/bad", { name: "合成资产" }, "id_invalid"], ])("rejects %s %s before database access", async (method, url, payload, reason) => { const { app, prisma } = await build(); try { const response = await app.inject({ method, url: `/api${url}`, payload }); rejected(response, reason, 400); for (const model of Object.values(prisma)) { if (typeof model === "function") expect(model).not.toHaveBeenCalled(); else for (const fn of Object.values(model)) expect(fn).not.toHaveBeenCalled(); } } finally { await app.close(); } }); it.each([ ["name", "raw body", "raw_payload"], ["sourceHash", "hash with spaces", "unsafe_hash_format"], ["metadata", { text: "x".repeat(2000) }, "too_large"], ["metadata", { text: "raw body" }, "raw_payload"], ])("keeps create and patch unsafe %s rejection", async (field, value, detail) => { const { app, prisma } = await build(); try { for (const method of ["POST", "PATCH"]) { const response = await app.inject({ method, url: `/api/assets${method === "PATCH" ? `/${ID}` : ""}`, payload: { ...(method === "POST" ? BODY : {}), [field]: value } }); rejected(response, "unsafe_content", 400, { [field]: detail }); } noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); } finally { await app.close(); } }); it.each([ ["documentCategory", null, "document_category_not_found", 404, { documentCategoryId: "not_found" }], ["documentCategory", { ...doc, entity_type: "purchase_request" }, "document_category_type_invalid", 409, { documentCategoryId: "unsupported_entity_type" }], ["documentCategory", { ...doc, enabled: false }, "document_category_inactive", 409, { documentCategoryId: "inactive" }], ["documentCategory", { ...doc, archived_at: new Date("2026-01-01") }, "document_category_inactive", 409, { documentCategoryId: "inactive" }], ])("explains category reference %s %s", async (model, row, reason, status, details) => { const prisma = fixture(); prisma[model].findUnique.mockResolvedValue(row); const { app } = await build(prisma); try { const response = await app.inject({ method: "POST", url: "/api/asset-categories", payload: { code: "SYNTHETIC", name: "合成分类", documentCategoryId: ID } }); rejected(response, reason, status, details); noWrites(prisma); } finally { await app.close(); } }); const references = [ ["assetCategory", null, {}, "category_not_found", 404, "categoryId", "not_found"], ["assetCategory", { ...category, enabled: false }, {}, "category_inactive", 409, "categoryId", "inactive"], ["assetCategory", { ...category, document_category: { ...doc, entity_type: "expense" } }, {}, "category_document_invalid", 409, "categoryId", "invalid_document_category"], ["assetCategory", { ...category, document_category: { ...doc, enabled: false } }, {}, "category_document_invalid", 409, "categoryId", "invalid_document_category"], ["legalEntity", null, {}, "company_not_found", 404, "legalEntityId", "not_found"], ["legalEntity", { id: COMPANY, enabled: false }, {}, "company_inactive", 409, "legalEntityId", "inactive"], ["costCenter", null, {}, "cost_center_not_found", 404, "costCenterId", "not_found"], ["costCenter", { id: CENTER, enabled: false }, {}, "cost_center_inactive", 409, "costCenterId", "inactive"], ["costCenter", { id: CENTER, enabled: true, legal_entity_id: OTHER }, {}, "cost_center_company_mismatch", 409, "costCenterId", "legal_entity_mismatch"], ...["assignee", "owner", "keeper"].flatMap((role) => [ ["employee", null, { [`${role}EmployeeId`]: PERSON }, `${role}_not_found`, 404, `${role}EmployeeId`, "not_found"], ["employee", { id: PERSON, status: "inactive" }, { [`${role}EmployeeId`]: PERSON }, `${role}_inactive`, 409, `${role}EmployeeId`, "inactive"], ]), ["purchaseRequest", null, { purchaseRequestId: OTHER }, "purchase_not_found", 404, "purchaseRequestId", "not_found"], ["purchaseRequest", { id: OTHER, status: "pending" }, { purchaseRequestId: OTHER }, "purchase_not_approved", 409, "purchaseRequestId", "not_approved"], ]; it.each(references)("explains asset reference %s %s", async (model, row, fields, reason, status, field, detail) => { const prisma = fixture(); prisma[model].findUnique.mockResolvedValue(row); const { app } = await build(prisma); try { const response = await app.inject({ method: "POST", url: "/api/assets", payload: { ...BODY, ...fields } }); rejected(response, reason, status, { [field]: detail }); noWrites(prisma); } finally { await app.close(); } }); it.each([ ["PATCH", "/asset-categories", "assetCategory", { name: "合成分类" }, "category_not_found"], ["GET", "/assets", "assetCard", undefined, "not_found"], ["PATCH", "/assets", "assetCard", { name: "合成资产" }, "not_found"], ])("retains VALID_001 404 for %s %s", async (method, path, model, payload, reason) => { const prisma = fixture(); prisma[model].findUnique.mockResolvedValue(null); const { app } = await build(prisma); try { rejected(await app.inject({ method, url: `/api${path}/${ID}`, payload }), reason, 404, { id: "not_found" }); noWrites(prisma); } finally { await app.close(); } }); it("preserves permission and own-record read denial", async () => { const { app, prisma } = await build(fixture(), ["asset.view_own"]); try { for (const options of [{ method: "POST", url: "/api/assets", payload: {} }, { method: "GET", url: `/api/assets/${ID}` }]) { const r = await app.inject(options); expect(r.statusCode).toBe(403); expect(r.json().error.code).toBe("PERM_001"); } noWrites(prisma); } finally { await app.close(); } }); it("retains category create/update and asset create/update/detail", async () => { const { app, prisma } = await build(); try { for (const opts of [ { method: "POST", url: "/api/asset-categories", payload: { code: "synthetic", name: "合成分类", documentCategoryId: ID } }, { method: "PATCH", url: `/api/asset-categories/${ID}`, payload: { enabled: false } }, { method: "POST", url: "/api/assets", payload: { ...BODY, assigneeEmployeeId: PERSON, ownerEmployeeId: PERSON, keeperEmployeeId: PERSON, purchaseRequestId: OTHER, activatedAt: "2024-02-29" } }, { method: "PATCH", url: `/api/assets/${ID}`, payload: { name: "合成修改", ownerEmployeeId: null, keeperEmployeeId: null, expiresAt: null } }, { method: "GET", url: `/api/assets/${ID}` }, ]) expect((await app.inject(opts)).statusCode).toBe(200); expect(prisma.assetCategory.create.mock.calls[0][0].data.code).toBe("SYNTHETIC"); expect(prisma.assetCard.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: "in_use", activated_at: new Date("2024-02-29T00:00:00Z"), owner_employee_id: PERSON, keeper_employee_id: PERSON, purchase_request_id: OTHER }), select: expect.any(Object) }); expect(prisma.assetAssignment.create).toHaveBeenCalledOnce(); expect(prisma.assetCard.update.mock.calls[0][0].data).toMatchObject({ owner_employee_id: null, keeper_employee_id: null, expires_at: null }); expect(prisma.auditLog.create.mock.calls.map(([x]) => x.data.action)).toEqual(expect.arrayContaining(["ASSET_CATEGORY_CREATED", "ASSET_CATEGORY_UPDATED", "ASSET_CARD_CREATED", "ASSET_CARD_UPDATED"])); expect(prisma.integrationOutbox.create).not.toHaveBeenCalled(); } finally { await app.close(); } }); }); describe("synthetic redacted index error contracts", () => { const url = "/api/assets/import/redacted-doc-index"; it.each([[{ name: "raw body" }, "items.0.name", "raw_payload"], [{ name: "合成索引", sourceHash: "bad hash" }, "items.0.sourceHash", "unsafe_hash_format"]])("rejects unsafe index before gate/access %s", async (item, key, value) => { vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "0"); const { app, prisma } = await build(); try { rejected(await app.inject({ method: "POST", url, payload: { write: true, items: [item] } }), "index_import_unsafe_content", 400, { [key]: value }); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("keeps closed write gate and existing CONFIG_001", async () => { vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "0"); const { app, prisma } = await build(); try { const r = await app.inject({ method: "POST", url, payload: { write: true, items: [{ name: "合成索引" }] } }); expect(r.statusCode).toBe(409); expect(r.json().error.code).toBe("CONFIG_001"); noWrites(prisma); } finally { await app.close(); } }); it("explains missing dimensions under synthetic write flag", async () => { vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "1"); const { app, prisma } = await build(); try { rejected(await app.inject({ method: "POST", url, payload: { write: true, items: [{ name: "合成索引" }] } }), "index_import_dimensions_required", 400, { categoryId: "required_for_write", legalEntityId: "required_for_write", costCenterId: "required_for_write" }); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("retains 20-item dry run overriding write without database access", async () => { vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "0"); const { app, prisma } = await build(); try { const r = await app.inject({ method: "POST", url, payload: { dryRun: true, write: true, items: Array.from({ length: 20 }, () => ({ name: "合成索引" })) } }); expect(r.statusCode).toBe(200); expect(r.json().data).toMatchObject({ mode: "dry_run", readOnly: true }); expect(r.json().data.items).toHaveLength(20); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("retains index-only writes and hash skips with mock DB", async () => { vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "1"); const { app, prisma } = await build(); prisma.assetCard.findFirst.mockResolvedValueOnce({ id: ID, asset_no: "SYNTHETIC-OLD" }).mockResolvedValueOnce(null); try { const r = await app.inject({ method: "POST", url, payload: { write: true, categoryId: ID, legalEntityId: COMPANY, costCenterId: CENTER, items: [{ name: "合成旧索引", sourceHash: "synthetic_old" }, { name: "合成新索引", sourceHash: "synthetic_new" }] } }); expect(r.statusCode).toBe(200); expect(r.json().data).toMatchObject({ mode: "write", imported: 1, boundaries: { noFileUpload: true, noRawContent: true } }); expect(r.json().data.items.map((x) => x.status)).toEqual(["skipped_existing_hash", "created"]); expect(prisma.assetCard.create).toHaveBeenCalledOnce(); expect(prisma.documentSequence.upsert).toHaveBeenCalledOnce(); expect(prisma.assetCard.create.mock.calls[0][0].data).toMatchObject({ asset_type: "electronic_document", status: "archived", metadata: { rawFileImported: false } }); expect(prisma.auditLog.create).toHaveBeenCalledTimes(2); } finally { await app.close(); } }); }); describe("asset creation transaction boundary", () => { it.each([false, true])("commits required writes before reading full detail (assigned=%s)", async assigned => { const prisma = fixture(); const events = []; let committed = false; const createdAt = new Date("2026-01-02T03:04:05Z"); const row = { ...asset, created_at: createdAt, updated_at: createdAt, category: { ...category, document_category: { ...doc, name: "合成单据类型" } }, legal_entity: { id: COMPANY, code: "SYNTHETIC", name: "合成主体" }, cost_center: { id: CENTER, code: "SYNTHETIC", name: "合成成本中心", legal_entity_id: COMPANY }, }; const tx = fixture(); tx.assetCard.create.mockImplementation(async ({ data, select }) => { events.push("asset"); // Relation hydration must not extend the atomic write transaction. expect(Object.values(select).every(value => value === true)).toBe(true); Object.assign(row, data); return Object.fromEntries(Object.keys(select).map(key => [key, row[key]])); }); tx.assetAssignment.create.mockImplementation(async ({ data }) => { events.push("assignment"); expect(data).toMatchObject({ asset_id: ID, employee_id: PERSON, assigned_at: createdAt }); return { id: OTHER }; }); prisma.$transaction.mockImplementation(async cb => { events.push("begin"); const result = await cb(tx); committed = true; events.push("commit"); return result; }); prisma.auditLog.create.mockImplementation(async () => { expect(committed).toBe(true); events.push("audit"); return {}; }); prisma.assetCard.findUnique.mockImplementation(async ({ where, select }) => { expect(committed).toBe(true); expect(where).toEqual({ id: ID }); for (const relation of ["category", "legal_entity", "cost_center", "purchase_request", "assignee", "owner", "keeper"]) expect(select[relation]).toBeTruthy(); events.push("detail"); return row; }); const { app } = await build(prisma); try { const response = await app.inject({ method: "POST", url: "/api/assets", payload: { ...BODY, ...(assigned ? { assigneeEmployeeId: PERSON, ownerEmployeeId: PERSON, keeperEmployeeId: PERSON } : {}) } }); expect(response.statusCode).toBe(200); expect(response.json().data.asset).toMatchObject({ id: ID, name: BODY.name, status: assigned ? "in_use" : "in_stock", category: { id: ID, name: category.name }, legalEntity: { id: COMPANY, name: "合成主体" }, costCenter: { id: CENTER, legalEntityId: COMPANY }, assigneeEmployeeId: assigned ? PERSON : null, permissions: { canEdit: true } }); expect(events).toEqual(["begin", "asset", ...(assigned ? ["assignment"] : []), "commit", "audit", "detail"]); expect(prisma.auditLog.create.mock.calls[0][0].data).toMatchObject({ action: "ASSET_CARD_CREATED", target_id: ID, actor_id: PERSON, extra: { assetNo: row.asset_no, assetType: "fixed_asset", sensitivityLevel: "internal", purchaseRequestId: null, assigneeEmployeeId: assigned ? PERSON : null, ownerEmployeeId: assigned ? PERSON : null, keeperEmployeeId: assigned ? PERSON : null } }); expect(tx.auditLog.create).not.toHaveBeenCalled(); expect(tx.assetCard.findUnique).not.toHaveBeenCalled(); if (!assigned) expect(tx.assetAssignment.create).not.toHaveBeenCalled(); for (const method of ["assetCategory", "legalEntity", "costCenter"]) expect(tx[method].findUnique).toHaveBeenCalled(); expect(tx.documentSequence.upsert).toHaveBeenCalledOnce(); } finally { await app.close(); } }); it.each(["asset", "assignment", "commit"])("does not hydrate or emit success audit when %s fails", async phase => { const prisma = fixture(); const tx = fixture(); const failure = new Error("synthetic transaction failure"); if (phase === "asset") tx.assetCard.create.mockRejectedValue(failure); if (phase === "assignment") tx.assetAssignment.create.mockRejectedValue(failure); prisma.$transaction.mockImplementation(async cb => { const result = await cb(tx); if (phase === "commit") throw failure; return result; }); const { app } = await build(prisma); try { expect((await app.inject({ method: "POST", url: "/api/assets", payload: { ...BODY, assigneeEmployeeId: PERSON } })).statusCode).toBe(500); expect(prisma.assetCard.findUnique).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); if (phase === "asset") expect(tx.assetAssignment.create).not.toHaveBeenCalled(); expect(prisma.$transaction).toHaveBeenCalledOnce(); } finally { await app.close(); } }); it.each(["unavailable", "missing"])("keeps committed writes and does not retry when response detail is %s", async mode => { const prisma = fixture(); let committed = false; prisma.$transaction.mockImplementation(async cb => { const result = await cb(prisma); committed = true; return result; }); prisma.assetCard.findUnique.mockImplementation(async () => { expect(committed).toBe(true); if (mode === "unavailable") throw new Error("synthetic read failure"); return null; }); const { app } = await build(prisma); try { const r = await app.inject({ method: "POST", url: "/api/assets", payload: BODY }); expect(r.statusCode).toBe(mode === "unavailable" ? 500 : 404); if (mode === "missing") rejected(r, "not_found", 404, { id: "not_found" }); expect(committed).toBe(true); expect(prisma.assetCard.create).toHaveBeenCalledOnce(); expect(prisma.$transaction).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).toHaveBeenCalledOnce(); } finally { await app.close(); } }); it("preserves post-commit nonblocking audit and reports its failure separately", async () => { const prisma = fixture(); let rejectAudit; prisma.auditLog.create.mockReturnValue(new Promise((_, reject) => { rejectAudit = reject; })); const { app } = await build(prisma); const errorLog = vi.spyOn(app.log, "error"); try { const response = await app.inject({ method: "POST", url: "/api/assets", payload: BODY }); expect(response.statusCode).toBe(200); expect(prisma.assetCard.findUnique).toHaveBeenCalledOnce(); const failure = new Error("synthetic audit failure"); rejectAudit(failure); await Promise.resolve(); expect(errorLog).toHaveBeenCalledWith({ err: failure, assetId: ID }, "Failed to write asset create audit log"); } finally { await app.close(); } }); }); it("creates a permanent fixed-asset number under active policy", async () => { const { app, prisma } = await build(); enableNumbering(prisma); try { const response = await app.inject({ method: "POST", url: "/api/assets", payload: BODY }); expect(response.statusCode).toBe(200); expect(prisma.assetCard.create.mock.calls[0][0].data.asset_no).toBe("FA-0001"); expect(prisma.documentSequence.upsert).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("keeps EA prefix and permanent numbering for redacted document imports", async () => { vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "1"); const { app, prisma } = await build(); enableNumbering(prisma); prisma.documentCategory.findUnique.mockResolvedValue({ ...doc, code: "ASSET_EA_DOC", sequence_prefix: "EA-DOC" }); try { const response = await app.inject({ method: "POST", url: "/api/assets/import/redacted-doc-index", payload: { write: true, categoryId: ID, legalEntityId: COMPANY, costCenterId: CENTER, items: [{ name: "合成电子索引", sourceHash: "synthetic_numbering" }], } }); expect(response.statusCode).toBe(200); expect(prisma.assetCard.create.mock.calls[0][0].data.asset_no).toBe("EA-DOC-0001"); expect(prisma.documentSequence.upsert).not.toHaveBeenCalled(); } finally { await app.close(); } });