汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
154 行
15 KiB
JavaScript
154 行
15 KiB
JavaScript
const Fastify = require("fastify");
|
|
const masterDataRoutes = require("../../../src/routes/masterData");
|
|
const supplierRoutes = require("../../../src/routes/masterDataSuppliers");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
|
|
const ID = "11111111-1111-4111-8111-111111111111";
|
|
const COMPANY = "22222222-2222-4222-8222-222222222222";
|
|
const PARENT = "33333333-3333-4333-8333-333333333333";
|
|
const EMPLOYEE = "44444444-4444-4444-8444-444444444444";
|
|
const archivedAt = new Date("2024-01-01T00:00:00Z");
|
|
const entities = [
|
|
{ route: "legal-entities", model: "legalEntity", domain: "legal_entity", payload: { code: " lc ", name: " 合成公司 ", currencyCode: " cny " }, code: "LC", action: "LEGAL_ENTITY" },
|
|
{ route: "cost-centers", model: "costCenter", domain: "cost_center", payload: { code: " cc ", name: " 合成中心 ", legalEntityId: COMPANY }, code: "CC", action: "COST_CENTER" },
|
|
{ route: "document-categories", model: "documentCategory", domain: "document_category", payload: { code: " DOC ", name: " 合成分类 ", entityType: "general", sequencePrefix: " xx ", resetPeriod: "none" }, code: "doc", action: "DOCUMENT_CATEGORY" },
|
|
{ route: "suppliers", model: "supplier", domain: "supplier", payload: { code: " sp ", name: " 合成供应商 " }, code: "SP", action: "SUPPLIER" },
|
|
];
|
|
const apps = [];
|
|
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
|
|
async function buildApp(permissions = ["masterdata.manage"]) {
|
|
const app = Fastify(); apps.push(app);
|
|
const records = Object.fromEntries(entities.map(({ model }) => [model, { id: ID, code: "TEST", name: "合成资料", enabled: true, archived_at: null, legal_entity_id: COMPANY, parent_id: null, owner_employee_id: null }]));
|
|
const parent = { id: PARENT, legal_entity_id: COMPANY, parent_id: null, archived_at: null };
|
|
const owner = { id: EMPLOYEE, status: "active" };
|
|
const prisma = { auditLog: { create: vi.fn().mockResolvedValue({}) }, employee: { findUnique: vi.fn(async () => owner) } };
|
|
for (const { model } of entities) {
|
|
prisma[model] = {
|
|
findUnique: vi.fn(async ({ where }) => model === "costCenter" && where.id === PARENT ? parent : records[model]),
|
|
findMany: vi.fn(async () => [records[model]]), count: vi.fn().mockResolvedValue(0),
|
|
create: vi.fn(async ({ data }) => ({ id: ID, ...data })), update: vi.fn(async ({ data }) => ({ ...records[model], ...data })),
|
|
};
|
|
}
|
|
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
|
|
app.decorate("prisma", prisma);
|
|
app.decorate("authenticate", async (request) => { request.employee = { id: EMPLOYEE }; });
|
|
app.decorate("hasPermission", (_employee, permission) => permissions.includes(permission));
|
|
app.decorate("requirePermission", (permission) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permission)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); });
|
|
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
|
|
await app.register(masterDataRoutes, { prefix: "/api" }); await app.register(supplierRoutes, { prefix: "/api" });
|
|
return { app, prisma, records, parent, owner };
|
|
}
|
|
function noWrites(prisma) {
|
|
for (const { model } of entities) { expect(prisma[model].create).not.toHaveBeenCalled(); expect(prisma[model].update).not.toHaveBeenCalled(); }
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
}
|
|
function noReads(prisma) {
|
|
for (const { model } of entities) { expect(prisma[model].findUnique).not.toHaveBeenCalled(); expect(prisma[model].findMany).not.toHaveBeenCalled(); expect(prisma[model].count).not.toHaveBeenCalled(); }
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
}
|
|
function expectError(response, status, reasonCode, details) {
|
|
expect(response.statusCode).toBe(status);
|
|
expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } });
|
|
}
|
|
function url(entity, suffix = "") { return `/api/master-data/${entity.route}${suffix}`; }
|
|
|
|
for (const entity of entities) {
|
|
describe(entity.route, () => {
|
|
it.each([
|
|
["GET", "?includeArchived=invalid", undefined, "query_invalid"],
|
|
["POST", "", {}, "create_input_invalid"],
|
|
["PATCH", `/${ID}`, { name: "" }, "update_input_invalid"],
|
|
])("explains %s schema rejection before database access", async (method, suffix, payload, reason) => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method, url: url(entity, suffix), ...(payload ? { payload } : {}) });
|
|
expect(response.statusCode).toBe(400); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `${entity.domain}.${reason}`, formErrors: [], fieldErrors: expect.any(Object) } });
|
|
noReads(prisma); noWrites(prisma);
|
|
});
|
|
it.each(["PATCH", "POST"])("explains invalid id on %s before database access", async (method) => {
|
|
const { app, prisma } = await buildApp();
|
|
expectError(await app.inject({ method, url: url(entity, `/invalid${method === "POST" ? "/archive" : ""}`), ...(method === "PATCH" ? { payload: { name: "合成" } } : {}) }), 400, `${entity.domain}.id_invalid`, { id: "invalid_uuid" }); noReads(prisma); noWrites(prisma);
|
|
});
|
|
it("explains an empty patch", async () => {
|
|
const { app, prisma } = await buildApp(); expectError(await app.inject({ method: "PATCH", url: url(entity, `/${ID}`), payload: {} }), 400, `${entity.domain}.update_empty`, { body: "empty" }); noReads(prisma); noWrites(prisma);
|
|
});
|
|
it("keeps archived records locked", async () => {
|
|
const { app, prisma, records } = await buildApp(); records[entity.model].archived_at = archivedAt;
|
|
expectError(await app.inject({ method: "PATCH", url: url(entity, `/${ID}`), payload: { name: "合成" } }), 409, `${entity.domain}.archived_locked`, { status: "archived" }); noWrites(prisma);
|
|
});
|
|
it.each(["POST", "PATCH"])("explains %s code conflicts without success audit", async (method) => {
|
|
const { app, prisma } = await buildApp();
|
|
prisma[entity.model][method === "POST" ? "create" : "update"].mockRejectedValue({ code: "P2002", meta: { target: ["code"] } });
|
|
expectError(await app.inject({ method, url: url(entity, method === "PATCH" ? `/${ID}` : ""), payload: entity.payload }), 409, `${entity.domain}.code_duplicate`, { code: "duplicate" }); expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
it.each(["PATCH", "POST"])("preserves missing current record 404 for %s", async (method) => {
|
|
const { app, prisma } = await buildApp(); prisma[entity.model].findUnique.mockResolvedValue(null);
|
|
const response = await app.inject({ method, url: url(entity, `/${ID}${method === "POST" ? "/archive" : ""}`), ...(method === "PATCH" ? { payload: { name: "合成" } } : {}) }); expect(response.statusCode).toBe(404); expect(response.json().error.code).toBe("SYS_404"); noWrites(prisma);
|
|
});
|
|
it("preserves archive idempotency with no second write or audit", async () => {
|
|
const { app, prisma, records } = await buildApp(); records[entity.model].archived_at = archivedAt;
|
|
const response = await app.inject({ method: "POST", url: url(entity, `/${ID}/archive`) }); expect(response.statusCode).toBe(200); expect(response.json().data.outcome).toBe("already_archived"); noWrites(prisma);
|
|
});
|
|
it("preserves successful archive and audit", async () => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method: "POST", url: url(entity, `/${ID}/archive`) }); expect(response.statusCode).toBe(200); expect(response.json().data.outcome).toBe("archived");
|
|
expect(prisma[entity.model].update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: ID }, data: { enabled: false, archived_at: expect.any(Date), updated_by: EMPLOYEE } }));
|
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: `${entity.action}_ARCHIVE` }) }));
|
|
});
|
|
it.each(["POST", "PATCH"])("preserves successful %s normalization and audit", async (method) => {
|
|
const { app, prisma } = await buildApp(); const response = await app.inject({ method, url: url(entity, method === "PATCH" ? `/${ID}` : ""), payload: entity.payload }); expect(response.statusCode).toBe(200);
|
|
expect(prisma[entity.model][method === "POST" ? "create" : "update"]).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ code: entity.code, name: entity.payload.name.trim(), updated_by: EMPLOYEE }) }));
|
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: `${entity.action}_${method === "POST" ? "CREATE" : "UPDATE"}` }) }));
|
|
});
|
|
it("keeps list filters and procurement read access", async () => {
|
|
const { app, prisma } = await buildApp(["procurement.create"]); const response = await app.inject({ method: "GET", url: url(entity, "?includeArchived=true&enabled=false") }); expect(response.statusCode).toBe(200); expect(prisma[entity.model].findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { enabled: false } })); noWrites(prisma);
|
|
});
|
|
it.each(["GET", "POST", "PATCH"])("keeps permission refusal before %s parsing", async (method) => {
|
|
const { app, prisma } = await buildApp([]); const response = await app.inject({ method, url: url(entity, method === "PATCH" ? "/invalid" : ""), ...(method !== "GET" ? { payload: {} } : {}) }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); noReads(prisma); noWrites(prisma);
|
|
});
|
|
});
|
|
}
|
|
|
|
it.each([entities[1], entities[3]].flatMap((entity) => ["POST", "PATCH"].flatMap((method) => ["not_found", "archived"].map((state) => [entity, method, state]))))("preserves company reference for $0.route %s %s", async (entity, method, state) => {
|
|
const { app, prisma, records } = await buildApp();
|
|
if (state === "not_found") prisma.legalEntity.findUnique.mockResolvedValue(null); else records.legalEntity.archived_at = archivedAt;
|
|
expectError(await app.inject({ method, url: url(entity, method === "PATCH" ? `/${ID}` : ""), payload: { ...entity.payload, legalEntityId: COMPANY } }), state === "not_found" ? 400 : 409, `legal_entity.reference_${state}`, { legalEntityId: state }); noWrites(prisma);
|
|
});
|
|
it.each(["POST", "PATCH"].flatMap((method) => ["missing", "inactive"].map((state) => [method, state])))("preserves %s owner %s rejection", async (method, state) => {
|
|
const { app, prisma, owner } = await buildApp(); if (state === "missing") prisma.employee.findUnique.mockResolvedValue(null); else owner.status = "inactive";
|
|
expectError(await app.inject({ method, url: url(entities[1], method === "PATCH" ? `/${ID}` : ""), payload: { ...entities[1].payload, ownerEmployeeId: EMPLOYEE } }), 400, "cost_center.owner_unavailable", { ownerEmployeeId: "not_found" }); noWrites(prisma);
|
|
});
|
|
it.each(["POST", "PATCH"].flatMap((method) => ["missing", "archived", "cross-company"].map((state) => [method, state])))("preserves %s parent %s rejection", async (method, state) => {
|
|
const { app, prisma, records, parent } = await buildApp();
|
|
if (state === "missing") prisma.costCenter.findUnique.mockImplementation(async ({ where }) => where.id === ID ? records.costCenter : null);
|
|
if (state === "archived") parent.archived_at = archivedAt;
|
|
if (state === "cross-company") parent.legal_entity_id = EMPLOYEE;
|
|
const [reason, detail] = { missing: ["parent_not_found", "not_found"], archived: ["parent_archived", "archived"], "cross-company": ["parent_company_mismatch", "cross_legal_entity_not_allowed"] }[state];
|
|
expectError(await app.inject({ method, url: url(entities[1], method === "PATCH" ? `/${ID}` : ""), payload: { ...entities[1].payload, parentId: PARENT } }), state === "missing" ? 400 : 409, `cost_center.${reason}`, { parentId: detail }); noWrites(prisma);
|
|
});
|
|
it.each(["self", "cycle"])("preserves %s hierarchy rejection", async (state) => {
|
|
const { app, prisma, records, parent } = await buildApp(); parent.parent_id = ID; prisma.costCenter.findMany.mockResolvedValue([records.costCenter, parent]);
|
|
expectError(await app.inject({ method: "PATCH", url: url(entities[1], `/${ID}`), payload: { parentId: state === "self" ? ID : PARENT } }), 409, `cost_center.parent_${state}_forbidden`, { parentId: `${state}_not_allowed` }); noWrites(prisma);
|
|
});
|
|
it.each([
|
|
[entities[0], "legal_entity.has_cost_centers", { legalEntity: "has_cost_centers" }, { legal_entity_id: ID, archived_at: null }],
|
|
[entities[1], "cost_center.has_children", { costCenter: "has_children" }, { parent_id: ID, archived_at: null }],
|
|
])("preserves dependent record archive guard for $0.route", async (entity, reason, details, where) => {
|
|
const { app, prisma } = await buildApp(); prisma.costCenter.count.mockResolvedValue(1);
|
|
expectError(await app.inject({ method: "POST", url: url(entity, `/${ID}/archive`) }), 409, reason, details); expect(prisma.costCenter.count).toHaveBeenCalledWith({ where }); noWrites(prisma);
|
|
});
|
|
it("preserves explicit null clearing and omitted references on supplier patch", async () => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method: "PATCH", url: url(entities[3], `/${ID}`), payload: { legalEntityId: null, contactName: " " } }); expect(response.statusCode).toBe(200);
|
|
expect(prisma.supplier.update).toHaveBeenCalledWith(expect.objectContaining({ data: { legal_entity_id: null, contact_name: null, updated_by: EMPLOYEE } })); expect(prisma.legalEntity.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
it("validates retained cost-center references on patch and preserves explicit null clearing", async () => {
|
|
const { app, prisma, records } = await buildApp(); records.costCenter.owner_employee_id = EMPLOYEE; records.costCenter.parent_id = PARENT;
|
|
const response = await app.inject({ method: "PATCH", url: url(entities[1], `/${ID}`), payload: { ownerEmployeeId: null, parentId: null } }); expect(response.statusCode).toBe(200);
|
|
expect(prisma.legalEntity.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: COMPANY } })); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); expect(prisma.costCenter.findUnique).toHaveBeenCalledTimes(1);
|
|
expect(prisma.costCenter.update).toHaveBeenCalledWith(expect.objectContaining({ data: { owner_employee_id: null, parent_id: null, updated_by: EMPLOYEE } }));
|
|
});
|
|
it("preserves non-code unique failure handling for a company", async () => {
|
|
const { app, prisma } = await buildApp(); prisma.legalEntity.create.mockRejectedValue({ code: "P2002", meta: { target: ["unrelated"] } });
|
|
const response = await app.inject({ method: "POST", url: url(entities[0]), payload: entities[0].payload }); expect(response.statusCode).toBe(500); expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
});
|