完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -0,0 +1,339 @@
|
||||
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(); }
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户