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

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

80 行
11 KiB
JavaScript

const Fastify = require("fastify");
const routes = require("../../../src/routes/delegations");
const { AppError } = require("../../../src/utils/AppError");
const id = (n) => `11111111-1111-4111-8111-${n.toString(16).padStart(12, "0")}`;
const ACTOR = id(1), DELEGATE = id(2), OTHER = id(3), THIRD = id(4), RECORD = id(10);
const NOW = new Date("2026-01-15T12:00:00Z");
function row(overrides = {}) { return { id: RECORD, principal_employee_id: ACTOR, delegate_employee_id: DELEGATE, scope: "all", valid_from: new Date("2026-01-01T00:00:00Z"), valid_to: null, revoked_at: null, created_at: new Date("2026-01-01T00:00:00Z"), ...overrides }; }
// Minimal in-memory interpretation of the real route's where object, not a Prisma/PG emulator.
function matches(record, where) {
return Object.entries(where ?? {}).every(([key, value]) => {
if (key === "AND") return value.every((item) => matches(record, item));
if (key === "OR") return value.some((item) => matches(record, item));
if (value && typeof value === "object" && !(value instanceof Date)) {
return Object.entries(value).every(([operator, bound]) => operator === "lte" ? record[key] <= bound : operator === "gt" ? record[key] > bound : false);
}
return record[key] === value;
});
}
const apps = [];
beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(NOW); });
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); vi.useRealTimers(); });
async function build({ permissions = ["expense.proxy_manage"], records = [row()] } = {}) {
const app = Fastify(); apps.push(app);
const state = { records };
const prisma = {
employee: { findUnique: vi.fn(async ({ where }) => ({ id: where.id, status: "active", roles: [{ role_code: "finance" }] })) },
delegationGrant: {
findUnique: vi.fn(async ({ where }) => state.records.find((item) => item.id === where.id) ?? null),
findMany: vi.fn(async ({ where }) => state.records.filter((item) => matches(item, where))),
create: vi.fn(async ({ data }) => { const created = row({ ...data, id: id(100 + state.records.length) }); state.records.push(created); return created; }),
update: vi.fn(async ({ where, data }) => Object.assign(state.records.find((item) => item.id === where.id), data)),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
prisma.$transaction = vi.fn(async (cb) => cb(prisma));
app.decorate("prisma", prisma);
app.decorate("authenticate", async (request) => { request.employee = { id: ACTOR, 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, state };
}
const request = (app, method, path, payload) => app.inject({ method, url: `/api${path}`, payload });
function noWrites(prisma) { expect(prisma.delegationGrant.create).not.toHaveBeenCalled(); expect(prisma.delegationGrant.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }
function rejected(r, reason, status, details = {}) { expect(r.statusCode).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `delegation.${reason}`, ...details } }); }
describe("delegation list scope regression", () => {
function data() { return [row({ id: id(11) }), row({ id: id(12), principal_employee_id: OTHER, delegate_employee_id: ACTOR }), row({ id: id(13), principal_employee_id: OTHER, delegate_employee_id: THIRD }), row({ id: id(14), valid_from: new Date("2026-01-16T00:00:00Z") }), row({ id: id(15), valid_to: NOW }), row({ id: id(16), revoked_at: NOW }), row({ id: id(17), valid_from: NOW, valid_to: new Date("2026-01-16T00:00:00Z") })]; }
it.each(["", "?includeInactive=false", "?includeInactive=0"])("keeps both actor and active predicates %s", async (query) => {
const { app, prisma } = await build({ records: data() }); const r = await request(app, "GET", `/delegation-grants${query}`);
expect(r.statusCode).toBe(200); expect(r.json().data.items.map((item) => item.id)).toEqual([id(11), id(12), id(17)]); noWrites(prisma);
});
it.each(["true", "1"])("includes inactive own grants while retaining actor scope %s", async (value) => { const { app } = await build({ records: data() }); const r = await request(app, "GET", `/delegation-grants?includeInactive=${value}&principalEmployeeId=${OTHER}`); expect(r.statusCode).toBe(200); expect(r.json().data.items.map((item) => item.id)).toEqual([id(11), id(12), id(14), id(15), id(16), id(17)]); });
it.each(["false", "true"])("keeps administrator explicit filters %s", async (includeInactive) => { const { app } = await build({ records: data(), permissions: ["system.users"] }); const r = await request(app, "GET", `/delegation-grants?includeInactive=${includeInactive}&principalEmployeeId=${OTHER}&delegateEmployeeId=${THIRD}`); expect(r.statusCode).toBe(200); expect(r.json().data.items.map((item) => item.id)).toEqual([id(13)]); });
it("keeps ordinary explicit filters from enlarging actor scope", async () => { const { app } = await build({ records: data() }); const r = await request(app, "GET", `/delegation-grants?principalEmployeeId=${OTHER}&delegateEmployeeId=${THIRD}`); expect(r.json().data.items.map((item) => item.id)).toEqual([id(11), id(12), id(17)]); });
});
describe("delegation rejection contracts", () => {
it.each([["POST", "/delegations", {}, "create_input_invalid"], ["POST", "/delegation-grants", {}, "grant_input_invalid"], ["GET", "/delegations/mine?status=bad", undefined, "status_query_invalid"], ["GET", "/delegation-grants?includeInactive=bad", undefined, "grants_query_invalid"], ["POST", "/delegations/bad/revoke", undefined, "id_invalid"], ["POST", "/delegation-grants/bad/revoke", undefined, "id_invalid"]])("explains %s %s before reads", async (method, path, payload, reason) => { const { app, prisma } = await build(); rejected(await request(app, method, path, payload), reason, 400); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); expect(prisma.delegationGrant.findUnique).not.toHaveBeenCalled(); expect(prisma.delegationGrant.findMany).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); });
it.each(["/delegations", "/delegation-grants"].flatMap((path) => ["2026-01-14T00:00:00Z", NOW.toISOString()].map((validTo) => [path, validTo])))("rejects %s reversed/equal period %s", async (path, validTo) => { const { app, prisma } = await build(); rejected(await request(app, "POST", path, { delegateEmployeeId: DELEGATE, validFrom: NOW.toISOString(), validTo }), "time_range_invalid", 400, { validTo: "must_be_after_validFrom" }); noWrites(prisma); });
it.each(["/delegations", "/delegation-grants"])("rejects self %s", async (path) => { const { app, prisma } = await build(); rejected(await request(app, "POST", path, { delegateEmployeeId: ACTOR }), "self_not_allowed", 409, { delegateEmployeeId: "self_not_allowed" }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); });
it.each([null, { id: ACTOR, status: "inactive" }])("rejects unavailable principal %s", async (principal) => { const { app, prisma } = await build(); prisma.employee.findUnique.mockResolvedValueOnce(principal); rejected(await request(app, "POST", "/delegation-grants", { delegateEmployeeId: DELEGATE }), "principal_unavailable", 409, { principalEmployeeId: "invalid_principal" }); noWrites(prisma); });
it.each(["/delegations", "/delegation-grants"].flatMap((path) => [null, { id: DELEGATE, status: "inactive", roles: [] }].map((delegate) => [path, delegate])))("rejects unavailable delegate %s %s", async (path, delegate) => { const { app, prisma } = await build(); prisma.employee.findUnique.mockImplementation(async ({ where }) => where.id === ACTOR ? { id: ACTOR, status: "active" } : delegate); rejected(await request(app, "POST", path, { delegateEmployeeId: DELEGATE }), "delegate_unavailable", 409, { delegateEmployeeId: "invalid_delegate" }); noWrites(prisma); });
it.each([["reimbursement_submit", "delegate_proxy_permission_required", "delegate_must_be_administration"], ["all", "delegate_approval_permission_required", "delegate_must_be_approver"]])("explains scope eligibility %s", async (scope, reason, detail) => { const { app, prisma } = await build(); prisma.employee.findUnique.mockResolvedValue({ id: DELEGATE, status: "active", roles: [{ role_code: "employee" }] }); rejected(await request(app, "POST", "/delegations", { delegateEmployeeId: DELEGATE, scope }), reason, 409, { delegateEmployeeId: detail }); noWrites(prisma); });
it("preserves ordinary principal-write refusal", async () => { const { app, prisma } = await build(); const r = await request(app, "POST", "/delegation-grants", { principalEmployeeId: OTHER, delegateEmployeeId: DELEGATE }); expect(r.statusCode).toBe(403); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); });
it.each(["/delegations", "/delegation-grants"])("retains create defaults and revoke replay %s", async (path) => {
const { app, prisma } = await build(); const created = await request(app, "POST", path, { delegateEmployeeId: DELEGATE }); expect(created.statusCode).toBe(200); const grant = created.json().data.delegation;
expect(grant).toMatchObject({ principalEmployeeId: ACTOR, delegateEmployeeId: DELEGATE, scope: "all", validFrom: NOW.toISOString(), validTo: null });
expect((await request(app, "POST", `${path}/${grant.id}/revoke`)).json().data.outcome).toBe("revoked"); expect((await request(app, "POST", `${path}/${grant.id}/revoke`)).json().data.outcome).toBe("already_revoked");
expect(prisma.delegationGrant.update).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).toHaveBeenCalledTimes(2);
});
it("preserves proxy-submit eligible role and scheduled validity", async () => { const { app, prisma } = await build(); prisma.employee.findUnique.mockResolvedValue({ id: DELEGATE, status: "active", roles: [{ role_code: "administration" }] }); const r = await request(app, "POST", "/delegations", { delegateEmployeeId: DELEGATE, scope: "reimbursement_submit", validFrom: "2026-01-16T00:00:00Z", validTo: "2026-01-17T00:00:00Z" }); expect(r.statusCode).toBe(200); expect(r.json().data.delegation.status).toBe("scheduled"); });
it("preserves legacy settings revoke permission without extending grant API", async () => {
const { app, prisma } = await build({ permissions: ["expense.proxy_manage", "system.settings"], records: [row({ principal_employee_id: OTHER, delegate_employee_id: THIRD })] });
expect((await request(app, "POST", `/delegation-grants/${RECORD}/revoke`)).statusCode).toBe(403); noWrites(prisma);
expect((await request(app, "POST", `/delegations/${RECORD}/revoke`)).statusCode).toBe(200); expect(prisma.delegationGrant.update).toHaveBeenCalledOnce();
});
it("keeps legacy permission gate before schema", async () => { const { app, prisma } = await build({ permissions: [] }); expect((await request(app, "POST", "/delegations", {})).statusCode).toBe(403); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); });
});