汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
1067 行
41 KiB
JavaScript
1067 行
41 KiB
JavaScript
const Fastify = require("fastify");
|
|
|
|
const contractsRegistryRoutes = require("../../../src/routes/contractsRegistry");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
const { currentDateString, parseDateOnlyString } = require("../../../src/utils/date");
|
|
|
|
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const PROJECT_ID = "11111111-1111-4111-8111-111111111111";
|
|
const OWNER_ID = "22222222-2222-4222-8222-222222222222";
|
|
const MANAGER_ID = "33333333-3333-4333-8333-333333333333";
|
|
const MEMBER_ID = "44444444-4444-4444-8444-444444444444";
|
|
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
|
|
function buildFeatureFlagRecord(flagKey) {
|
|
return {
|
|
id: `flag-${flagKey}`,
|
|
flag_key: flagKey,
|
|
override_mode: "force_on",
|
|
rollout_type: "global",
|
|
department_ids: [],
|
|
note: "unit-test",
|
|
version: 1,
|
|
created_at: new Date("2026-04-04T08:00:00.000Z"),
|
|
created_by: null,
|
|
updated_at: new Date("2026-04-04T08:00:00.000Z"),
|
|
updated_by: null,
|
|
};
|
|
}
|
|
|
|
function buildContractRow(overrides = {}) {
|
|
return {
|
|
id: "55555555-5555-4555-8555-555555555551",
|
|
contract_no: "HT-2026-001",
|
|
project_id: PROJECT_ID,
|
|
contract_type: "service",
|
|
counterparty_name: "测试客户",
|
|
summary: "合同摘要",
|
|
amount: "100000.00",
|
|
currency: "CNY",
|
|
signed_date: new Date("2026-03-01T00:00:00.000Z"),
|
|
effective_date: new Date("2026-03-05T00:00:00.000Z"),
|
|
expiry_date: new Date("2026-03-31T00:00:00.000Z"),
|
|
confidentiality_level: "internal",
|
|
archive_location: "/archive/contracts/001",
|
|
offline_archive_no: "OFF-001",
|
|
owner_employee_id: OWNER_ID,
|
|
status: "active",
|
|
terminated_at: null,
|
|
terminated_reason: null,
|
|
terminated_by: null,
|
|
created_at: new Date("2026-03-01T08:00:00.000Z"),
|
|
updated_at: new Date("2026-04-01T08:00:00.000Z"),
|
|
owner: {
|
|
id: OWNER_ID,
|
|
employee_no: "E001",
|
|
name: "Owner",
|
|
},
|
|
project: {
|
|
id: PROJECT_ID,
|
|
project_no: "PRJ-001",
|
|
name: "试点项目",
|
|
},
|
|
terminator: null,
|
|
document_refs: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mapSelectedRow(row, select) {
|
|
if (!select) {
|
|
return row;
|
|
}
|
|
|
|
return Object.entries(select).reduce((result, [key, enabled]) => {
|
|
if (enabled === true) {
|
|
result[key] = row[key];
|
|
}
|
|
return result;
|
|
}, {});
|
|
}
|
|
|
|
function cloneRow(row) {
|
|
return {
|
|
...row,
|
|
owner: row.owner ? { ...row.owner } : null,
|
|
project: row.project ? { ...row.project } : null,
|
|
terminator: row.terminator ? { ...row.terminator } : null,
|
|
document_refs: (row.document_refs ?? []).map((item) => ({
|
|
...item,
|
|
owner: item.owner ? { ...item.owner } : null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function findIdInWhere(where) {
|
|
if (!where || typeof where !== "object") return null;
|
|
if (typeof where.id === "string") return where.id;
|
|
for (const condition of where.AND ?? []) {
|
|
const id = findIdInWhere(condition);
|
|
if (id) return id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildStatefulPrisma(initialContracts, options = {}) {
|
|
const state = {
|
|
contracts: initialContracts.map((item) => cloneRow(item)),
|
|
audits: [],
|
|
};
|
|
|
|
const auditCreate = vi.fn(async ({ data }) => {
|
|
if (options.failAuditAction === data.action) {
|
|
throw new Error("audit unavailable");
|
|
}
|
|
state.audits.push(data);
|
|
return data;
|
|
});
|
|
|
|
const contractRegistry = {
|
|
findMany: vi.fn(async ({ where, select, include }) => {
|
|
let rows = [...state.contracts];
|
|
if (where?.contract_no?.startsWith) {
|
|
rows = rows.filter((item) => item.contract_no.startsWith(where.contract_no.startsWith));
|
|
}
|
|
|
|
if (select) {
|
|
return rows.map((item) => mapSelectedRow(item, select));
|
|
}
|
|
|
|
if (include) {
|
|
return rows.map((item) => cloneRow(item));
|
|
}
|
|
|
|
return rows.map((item) => cloneRow(item));
|
|
}),
|
|
findUnique: vi.fn(async ({ where, select, include }) => {
|
|
const row = state.contracts.find((item) => item.id === where.id);
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
if (select) {
|
|
return mapSelectedRow(row, select);
|
|
}
|
|
if (include) {
|
|
return cloneRow(row);
|
|
}
|
|
return cloneRow(row);
|
|
}),
|
|
findFirst: vi.fn(async ({ where, select, include }) => {
|
|
const id = findIdInWhere(where);
|
|
const row = id ? state.contracts.find((item) => item.id === id) : state.contracts[0];
|
|
if (!row) return null;
|
|
if (select) return mapSelectedRow(row, select);
|
|
return include ? cloneRow(row) : cloneRow(row);
|
|
}),
|
|
create: vi.fn(async ({ data, include }) => {
|
|
const created = buildContractRow({
|
|
id: `66666666-6666-4666-8666-${String(state.contracts.length + 1).padStart(12, "0")}`,
|
|
contract_no: data.contract_no,
|
|
project_id: data.project_id ?? null,
|
|
contract_type: data.contract_type,
|
|
counterparty_name: data.counterparty_name,
|
|
summary: data.summary,
|
|
amount: data.amount ?? null,
|
|
currency: data.currency,
|
|
signed_date: data.signed_date,
|
|
effective_date: data.effective_date,
|
|
expiry_date: data.expiry_date,
|
|
confidentiality_level: data.confidentiality_level,
|
|
archive_location: data.archive_location,
|
|
offline_archive_no: data.offline_archive_no,
|
|
owner_employee_id: data.owner_employee_id,
|
|
status: data.status,
|
|
terminated_at: data.terminated_at ?? null,
|
|
terminated_reason: data.terminated_reason ?? null,
|
|
terminated_by: data.terminated_by ?? null,
|
|
});
|
|
state.contracts.push(created);
|
|
return include ? cloneRow(created) : created;
|
|
}),
|
|
update: vi.fn(async ({ where, data, include }) => {
|
|
const index = state.contracts.findIndex((item) => item.id === where.id);
|
|
if (index < 0) {
|
|
return null;
|
|
}
|
|
state.contracts[index] = {
|
|
...state.contracts[index],
|
|
...data,
|
|
updated_at: new Date("2026-04-04T09:30:00.000Z"),
|
|
};
|
|
return include ? cloneRow(state.contracts[index]) : state.contracts[index];
|
|
}),
|
|
updateMany: vi.fn(async ({ where, data }) => {
|
|
const sameValue = (current, expected) =>
|
|
current instanceof Date && expected instanceof Date
|
|
? current.getTime() === expected.getTime()
|
|
: current === expected;
|
|
const index = state.contracts.findIndex((item) =>
|
|
Object.entries(where).every(([key, value]) => sameValue(item[key], value)),
|
|
);
|
|
if (index < 0) return { count: 0 };
|
|
state.contracts[index] = {
|
|
...state.contracts[index],
|
|
...data,
|
|
updated_at: new Date("2026-04-04T12:00:00.000Z"),
|
|
terminator: data.terminated_by
|
|
? { id: data.terminated_by, employee_no: "E-ACTOR", name: "Actor" }
|
|
: state.contracts[index].terminator,
|
|
};
|
|
return { count: 1 };
|
|
}),
|
|
};
|
|
|
|
return {
|
|
state,
|
|
prisma: {
|
|
featureFlag: {
|
|
findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)),
|
|
},
|
|
auditLog: {
|
|
create: auditCreate,
|
|
findMany: vi.fn(async () =>
|
|
state.audits.map((item, index) => ({
|
|
id: `audit-${index + 1}`,
|
|
...item,
|
|
created_at: new Date(`2026-04-04T10:0${index}:00.000Z`),
|
|
actor: { id: ACTOR_ID, employee_no: "E-ACTOR", name: "Actor" },
|
|
})),
|
|
),
|
|
},
|
|
contractRegistry,
|
|
$transaction: async (callback) => {
|
|
const contractsBefore = state.contracts.map((item) => cloneRow(item));
|
|
const auditsBefore = [...state.audits];
|
|
try {
|
|
return await callback({
|
|
auditLog: { create: auditCreate },
|
|
contractRegistry,
|
|
});
|
|
} catch (error) {
|
|
state.contracts = contractsBefore;
|
|
state.audits = auditsBefore;
|
|
throw error;
|
|
}
|
|
},
|
|
},
|
|
auditCreate,
|
|
};
|
|
}
|
|
|
|
async function buildApp(options = {}) {
|
|
const app = Fastify();
|
|
const actor = options.actor ?? {
|
|
id: ACTOR_ID,
|
|
department_id: DEPARTMENT_ID,
|
|
permissionCodes: ["project.manage"],
|
|
};
|
|
const prisma =
|
|
options.prisma ??
|
|
(() => {
|
|
const fallback = {
|
|
featureFlag: {
|
|
findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)),
|
|
},
|
|
auditLog: {
|
|
create: vi.fn().mockResolvedValue({}),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
contractRegistry: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
create: vi.fn().mockResolvedValue(buildContractRow()),
|
|
update: vi.fn().mockResolvedValue(buildContractRow()),
|
|
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
};
|
|
fallback.$transaction = async (callback) => callback(fallback);
|
|
return fallback;
|
|
})();
|
|
|
|
app.decorate("authenticate", async (request) => {
|
|
request.employee = actor;
|
|
});
|
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
|
const permissionCodes = employee.permissionCodes ?? [];
|
|
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
|
});
|
|
app.decorate("config", {
|
|
appTimezone: "Asia/Shanghai",
|
|
projectLinkingEnabled: true,
|
|
});
|
|
app.decorate("prisma", prisma);
|
|
app.setErrorHandler((error, request, reply) => {
|
|
if (error instanceof AppError) {
|
|
return reply.status(error.statusCode).send({
|
|
requestId: request.id,
|
|
error: { code: error.code, message: error.message, details: error.details ?? {} },
|
|
});
|
|
}
|
|
return reply.status(500).send({
|
|
requestId: request.id,
|
|
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
|
|
});
|
|
});
|
|
|
|
await app.register(contractsRegistryRoutes, { prefix: "/api" });
|
|
return { app, prisma };
|
|
}
|
|
|
|
describe("contracts registry routes", () => {
|
|
it("lists contracts with expired filtering and computed status", async () => {
|
|
const contractFindMany = vi.fn().mockResolvedValue([buildContractRow()]);
|
|
const { app } = await buildApp({
|
|
prisma: (() => {
|
|
const prisma = {
|
|
featureFlag: {
|
|
findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)),
|
|
},
|
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
|
contractRegistry: {
|
|
findMany: contractFindMany,
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
};
|
|
prisma.$transaction = async (callback) => callback(prisma);
|
|
return prisma;
|
|
})(),
|
|
});
|
|
|
|
const today = parseDateOnlyString(currentDateString("Asia/Shanghai"));
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/api/contracts-registry?status=expired&contractType=service&counterpartyName=%E6%B5%8B%E8%AF%95&expiryDateFrom=2026-03-01&expiryDateTo=2026-03-31`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const payload = response.json();
|
|
expect(payload.data.items[0]).toEqual(
|
|
expect.objectContaining({
|
|
status: "expired",
|
|
persistedStatus: "active",
|
|
contractType: "service",
|
|
amount: "100000.00",
|
|
}),
|
|
);
|
|
expect(payload.data.items[0]).not.toHaveProperty("terminatedAt");
|
|
expect(payload.data.items[0]).not.toHaveProperty("terminatedReason");
|
|
expect(payload.data.items[0]).not.toHaveProperty("terminatedBy");
|
|
expect(payload.data.items[0]).not.toHaveProperty("terminator");
|
|
expect(contractFindMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
expect.objectContaining({ status: "active", expiry_date: { lt: today } }),
|
|
expect.objectContaining({ contract_type: "service" }),
|
|
expect.objectContaining({
|
|
counterparty_name: expect.objectContaining({ contains: "测试", mode: "insensitive" }),
|
|
}),
|
|
expect.objectContaining({
|
|
expiry_date: expect.objectContaining({
|
|
gte: new Date("2026-03-01T00:00:00.000Z"),
|
|
lte: new Date("2026-03-31T00:00:00.000Z"),
|
|
}),
|
|
}),
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("keeps terminated contracts terminal even when their expiry date is in the past", async () => {
|
|
const contractFindMany = vi.fn().mockResolvedValue([
|
|
buildContractRow({
|
|
status: "terminated",
|
|
expiry_date: new Date("2026-03-31T00:00:00.000Z"),
|
|
terminated_at: new Date("2026-03-20T08:00:00.000Z"),
|
|
terminated_reason: "双方确认提前终止合同并保留原编号",
|
|
terminated_by: ACTOR_ID,
|
|
terminator: { id: ACTOR_ID, employee_no: "E-ACTOR", name: "Actor" },
|
|
}),
|
|
]);
|
|
const { app } = await buildApp({
|
|
prisma: {
|
|
featureFlag: {
|
|
findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)),
|
|
},
|
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
|
contractRegistry: {
|
|
findMany: contractFindMany,
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
},
|
|
});
|
|
|
|
const response = await app.inject({ method: "GET", url: "/api/contracts-registry" });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data.items[0]).toEqual(expect.objectContaining({
|
|
status: "terminated",
|
|
persistedStatus: "terminated",
|
|
}));
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects invalid expiry date range", async () => {
|
|
const { app } = await buildApp();
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/contracts-registry?expiryDateFrom=2026-04-10&expiryDateTo=2026-04-01",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error).toEqual(
|
|
expect.objectContaining({
|
|
code: "VALID_001",
|
|
details: expect.objectContaining({
|
|
reasonCode: "contract_registry.expiry_date_range_invalid",
|
|
expiryDateTo: "must_be_after_or_equal_expiryDateFrom",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("returns expiring contracts within the default window", async () => {
|
|
const contractFindMany = vi.fn().mockResolvedValue([
|
|
buildContractRow({
|
|
id: "contract-2",
|
|
contract_no: "HT-2026-002",
|
|
expiry_date: new Date("2026-04-10T00:00:00.000Z"),
|
|
}),
|
|
]);
|
|
const { app } = await buildApp({
|
|
prisma: (() => {
|
|
const prisma = {
|
|
featureFlag: {
|
|
findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)),
|
|
},
|
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
|
contractRegistry: {
|
|
findMany: contractFindMany,
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
};
|
|
prisma.$transaction = async (callback) => callback(prisma);
|
|
return prisma;
|
|
})(),
|
|
});
|
|
|
|
const today = parseDateOnlyString(currentDateString("Asia/Shanghai"));
|
|
const end = new Date(today.getTime());
|
|
end.setUTCDate(end.getUTCDate() + 30);
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/contracts-registry/expiring",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual(
|
|
expect.objectContaining({
|
|
days: 30,
|
|
total: 1,
|
|
}),
|
|
);
|
|
expect(contractFindMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
AND: expect.arrayContaining([
|
|
{ status: "active" },
|
|
expect.objectContaining({
|
|
expiry_date: expect.objectContaining({
|
|
gte: today,
|
|
lte: end,
|
|
}),
|
|
}),
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("returns a scoped contract detail with safe document references and history", async () => {
|
|
const contractId = "55555555-5555-4555-8555-555555555551";
|
|
const stateful = buildStatefulPrisma([
|
|
buildContractRow({
|
|
id: contractId,
|
|
document_refs: [
|
|
{
|
|
id: "77777777-7777-4777-8777-777777777777",
|
|
title: "线下涉密资料索引",
|
|
document_no: "DOC-001",
|
|
confidentiality_level: "restricted",
|
|
storage_type: "offline_archive",
|
|
storage_path: "/sensitive/full/path",
|
|
offline_archive_no: "OFF-DOC-001",
|
|
access_rule_json: { raw: true },
|
|
created_at: new Date("2026-04-01T08:00:00.000Z"),
|
|
updated_at: new Date("2026-04-02T08:00:00.000Z"),
|
|
owner: { id: OWNER_ID, employee_no: "E001", name: "Owner" },
|
|
},
|
|
],
|
|
}),
|
|
]);
|
|
stateful.state.audits.push({
|
|
request_id: "request-1",
|
|
actor_id: ACTOR_ID,
|
|
action: "CONTRACT_REGISTRY_UPDATED",
|
|
target_type: "contract_registry",
|
|
target_id: contractId,
|
|
extra: { fields: ["summary"], rawPayload: "不得返回" },
|
|
});
|
|
const { app } = await buildApp({
|
|
prisma: stateful.prisma,
|
|
actor: {
|
|
id: OWNER_ID,
|
|
department_id: DEPARTMENT_ID,
|
|
permissionCodes: ["project.view_own"],
|
|
},
|
|
});
|
|
|
|
const response = await app.inject({ method: "GET", url: `/api/contracts-registry/${contractId}` });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const item = response.json().data.item;
|
|
expect(item.documentRefs[0]).toEqual(
|
|
expect.objectContaining({
|
|
title: "线下涉密资料索引",
|
|
documentNo: "DOC-001",
|
|
storageType: "offline_archive",
|
|
offlineArchiveNo: "OFF-DOC-001",
|
|
}),
|
|
);
|
|
expect(item.documentRefs[0]).not.toHaveProperty("storagePath");
|
|
expect(item.documentRefs[0]).not.toHaveProperty("accessRuleJson");
|
|
expect(item.history[0]).toEqual(expect.objectContaining({ summary: "更新合同信息" }));
|
|
expect(item.history[0]).not.toHaveProperty("extra");
|
|
expect(stateful.prisma.contractRegistry.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ AND: expect.arrayContaining([{ id: contractId }]) }),
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("terminates an active or expired contract with complete fields and required audit", async () => {
|
|
const contractId = "55555555-5555-4555-8555-555555555551";
|
|
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId, status: "active" })]);
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/contracts-registry/${contractId}/terminate`,
|
|
payload: { reason: "客户项目已取消,双方确认不再继续履行" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data.item).toEqual(
|
|
expect.objectContaining({
|
|
status: "terminated",
|
|
persistedStatus: "terminated",
|
|
terminatedReason: "客户项目已取消,双方确认不再继续履行",
|
|
terminatedBy: ACTOR_ID,
|
|
terminator: expect.objectContaining({ id: ACTOR_ID }),
|
|
}),
|
|
);
|
|
const stored = stateful.state.contracts.find((item) => item.id === contractId);
|
|
expect(stored).toEqual(
|
|
expect.objectContaining({
|
|
status: "terminated",
|
|
terminated_reason: "客户项目已取消,双方确认不再继续履行",
|
|
terminated_by: ACTOR_ID,
|
|
}),
|
|
);
|
|
expect(stored.terminated_at).toBeInstanceOf(Date);
|
|
expect(stateful.state.audits.map((item) => item.action)).toContain("CONTRACT_REGISTRY_TERMINATED");
|
|
const terminationAudit = stateful.state.audits.find((item) => item.action === "CONTRACT_REGISTRY_TERMINATED");
|
|
expect(terminationAudit?.extra).toEqual(expect.objectContaining({
|
|
reasonSummary: "客户项目已取消,双方确认不再继续履行",
|
|
}));
|
|
expect(terminationAudit?.extra).not.toHaveProperty("reason");
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rolls back all termination fields when required audit creation fails", async () => {
|
|
const contractId = "55555555-5555-4555-8555-555555555551";
|
|
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })], {
|
|
failAuditAction: "CONTRACT_REGISTRY_TERMINATED",
|
|
});
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/contracts-registry/${contractId}/terminate`,
|
|
payload: { reason: "客户项目已取消,双方确认不再继续履行" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(stateful.state.contracts[0]).toEqual(
|
|
expect.objectContaining({
|
|
status: "active",
|
|
terminated_at: null,
|
|
terminated_reason: null,
|
|
terminated_by: null,
|
|
}),
|
|
);
|
|
expect(stateful.state.audits).toEqual([]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects terminated through general PATCH and keeps terminated contracts final", async () => {
|
|
const contractId = "55555555-5555-4555-8555-555555555551";
|
|
const terminated = buildContractRow({
|
|
id: contractId,
|
|
status: "terminated",
|
|
terminated_at: new Date("2026-04-04T08:30:00.000Z"),
|
|
terminated_reason: "双方确认提前终止合同并保留原编号",
|
|
terminated_by: ACTOR_ID,
|
|
terminator: { id: ACTOR_ID, employee_no: "E-ACTOR", name: "Actor" },
|
|
});
|
|
const stateful = buildStatefulPrisma([terminated]);
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const bypassResponse = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/contracts-registry/${contractId}`,
|
|
payload: { status: "terminated" },
|
|
});
|
|
expect(bypassResponse.statusCode).toBe(400);
|
|
|
|
const editResponse = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/contracts-registry/${contractId}`,
|
|
payload: { summary: "试图覆盖已终止合同" },
|
|
});
|
|
expect(editResponse.statusCode).toBe(409);
|
|
expect(editResponse.json().error.details).toEqual(expect.objectContaining({ status: "terminated_is_final" }));
|
|
|
|
const renewResponse = await app.inject({
|
|
method: "POST",
|
|
url: `/api/contracts-registry/${contractId}/renew`,
|
|
});
|
|
expect(renewResponse.statusCode).toBe(409);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("locks archived fields and appends corrections without overwriting the contract", async () => {
|
|
const contractId = "55555555-5555-4555-8555-555555555551";
|
|
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId, status: "archived" })]);
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const editResponse = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/contracts-registry/${contractId}`,
|
|
payload: { archiveLocation: "新位置" },
|
|
});
|
|
expect(editResponse.statusCode).toBe(409);
|
|
expect(editResponse.json().error.details).toEqual(expect.objectContaining({ status: "archived_fields_locked" }));
|
|
|
|
const correctionResponse = await app.inject({
|
|
method: "POST",
|
|
url: `/api/contracts-registry/${contractId}/corrections`,
|
|
payload: { summary: "原归档位置登记有误,实际以线下档案号为准" },
|
|
});
|
|
expect(correctionResponse.statusCode).toBe(200);
|
|
expect(stateful.state.contracts[0].archive_location).toBe("/archive/contracts/001");
|
|
expect(stateful.state.audits).toEqual([
|
|
expect.objectContaining({
|
|
action: "CONTRACT_REGISTRY_CORRECTION_APPENDED",
|
|
extra: expect.objectContaining({ summary: "原归档位置登记有误,实际以线下档案号为准" }),
|
|
}),
|
|
]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects a stale archived correction when the contract changed concurrently", async () => {
|
|
const contractId = "55555555-5555-4555-8555-555555555551";
|
|
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId, status: "archived" })]);
|
|
stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/contracts-registry/${contractId}/corrections`,
|
|
payload: { summary: "原归档位置登记有误,实际以线下档案号为准" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error.details).toEqual(expect.objectContaining({ status: "correction_state_conflict" }));
|
|
expect(stateful.state.audits).toEqual([]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rolls back an archive status change when required audit creation fails", async () => {
|
|
const contractId = "55555555-5555-4555-8555-555555555551";
|
|
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })], {
|
|
failAuditAction: "CONTRACT_REGISTRY_STATUS_CHANGED",
|
|
});
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/contracts-registry/${contractId}`,
|
|
payload: { status: "archived" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(stateful.state.contracts[0].status).toBe("active");
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rolls back an archive status change when its history audit fails", async () => {
|
|
const contractId = "55555555-5555-4555-8555-555555555551";
|
|
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })], {
|
|
failAuditAction: "CONTRACT_REGISTRY_UPDATED",
|
|
});
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/contracts-registry/${contractId}`,
|
|
payload: { status: "archived" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(stateful.state.contracts[0].status).toBe("active");
|
|
expect(stateful.state.audits).toEqual([]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("fails closed when a contract row violates the termination field invariant", async () => {
|
|
const stateful = buildStatefulPrisma([
|
|
buildContractRow({
|
|
terminated_reason: "存在终止原因但状态尚未切换",
|
|
}),
|
|
]);
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({ method: "GET", url: "/api/contracts-registry" });
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(response.json().error).toEqual(expect.objectContaining({ code: "SYS_001" }));
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects a stale general update instead of overwriting a concurrent terminal transition", async () => {
|
|
const contractId = "55555555-5555-4555-8555-555555555551";
|
|
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })]);
|
|
stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/contracts-registry/${contractId}`,
|
|
payload: { summary: "并发状态变化后不得覆盖合同" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error.details).toEqual(
|
|
expect.objectContaining({ status: "contract_state_conflict" }),
|
|
);
|
|
expect(stateful.state.contracts[0].summary).toBe("合同摘要");
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("uses the same non-enumerating 404 for an unavailable contract detail", async () => {
|
|
const { app } = await buildApp({
|
|
actor: { id: ACTOR_ID, department_id: DEPARTMENT_ID, permissionCodes: ["project.view_own"] },
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/contracts-registry/55555555-5555-4555-8555-555555555551",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
expect(response.json().error).toEqual(expect.objectContaining({ code: "SYS_404", message: "Not Found" }));
|
|
expect(response.body).not.toContain("合同摘要");
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("renews an active contract into the next draft revision and archives the source", async () => {
|
|
const stateful = buildStatefulPrisma([
|
|
buildContractRow({
|
|
id: "55555555-5555-4555-8555-555555555551",
|
|
contract_no: "HT-2026-001",
|
|
status: "active",
|
|
}),
|
|
buildContractRow({
|
|
id: "55555555-5555-4555-8555-555555555552",
|
|
contract_no: "HT-2026-001-R1",
|
|
status: "archived",
|
|
}),
|
|
]);
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/contracts-registry/55555555-5555-4555-8555-555555555551/renew",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual(
|
|
expect.objectContaining({
|
|
item: expect.objectContaining({
|
|
contractNo: "HT-2026-001-R2",
|
|
status: "draft",
|
|
persistedStatus: "draft",
|
|
}),
|
|
sourceItem: expect.objectContaining({
|
|
contractNo: "HT-2026-001",
|
|
status: "archived",
|
|
persistedStatus: "archived",
|
|
}),
|
|
}),
|
|
);
|
|
expect(stateful.state.contracts.find((item) => item.id === "55555555-5555-4555-8555-555555555551")?.status).toBe("archived");
|
|
expect(
|
|
stateful.state.contracts.some((item) => item.contract_no === "HT-2026-001-R2" && item.status === "draft"),
|
|
).toBe(true);
|
|
expect(stateful.state.audits.map((item) => item.action)).toEqual(
|
|
expect.arrayContaining(["CONTRACT_REGISTRY_RENEWED", "CONTRACT_REGISTRY_STATUS_CHANGED"]),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rolls back the renewal draft and source archive when renewal audit fails", async () => {
|
|
const sourceId = "55555555-5555-4555-8555-555555555551";
|
|
const stateful = buildStatefulPrisma([
|
|
buildContractRow({ id: sourceId, contract_no: "HT-2026-001", status: "active" }),
|
|
], {
|
|
failAuditAction: "CONTRACT_REGISTRY_RENEWED",
|
|
});
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/api/contracts-registry/${sourceId}/renew`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(stateful.state.contracts).toHaveLength(1);
|
|
expect(stateful.state.contracts[0]).toEqual(expect.objectContaining({ id: sourceId, status: "active" }));
|
|
expect(stateful.state.audits).toEqual([]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects renewing a draft contract", async () => {
|
|
const stateful = buildStatefulPrisma([
|
|
buildContractRow({
|
|
id: "55555555-5555-4555-8555-555555555553",
|
|
contract_no: "HT-2026-003",
|
|
status: "draft",
|
|
}),
|
|
]);
|
|
const { app } = await buildApp({ prisma: stateful.prisma });
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/contracts-registry/55555555-5555-4555-8555-555555555553/renew",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error).toEqual(
|
|
expect.objectContaining({
|
|
code: "VALID_001",
|
|
details: expect.objectContaining({ status: "renew_not_allowed" }),
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
});
|
|
|
|
|
|
describe("contract calendar input", () => {
|
|
const body = { contractNo: "SYNTHETIC-CALENDAR", contractType: "service", counterpartyName: "合成对方", summary: "合成日期验证", archiveLocation: "synthetic-archive" };
|
|
const cases = ["POST", "PATCH"].flatMap((method) => ["signedDate", "effectiveDate", "expiryDate"].flatMap((field) => ["2026-02-29", "2026-04-31", "2026-13-01", ""].map((date) => [method, field, date])));
|
|
it.each(cases)("rejects %s %s=%s before writes", async (method, field, date) => {
|
|
const { app, prisma } = await buildApp();
|
|
const transaction = vi.spyOn(prisma, "$transaction");
|
|
try {
|
|
const response = await app.inject({ method, url: `/api/contracts-registry${method === "PATCH" ? "/55555555-5555-4555-8555-555555555551" : ""}`, payload: { ...(method === "POST" ? body : {}), [field]: date } });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.details.fieldErrors[field].length).toBeGreaterThan(0);
|
|
expect(prisma.contractRegistry.create).not.toHaveBeenCalled();
|
|
expect(prisma.contractRegistry.updateMany).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
expect(transaction).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
it.each(["2024-02-29", "2026-04-30", null, undefined])("preserves create and patch nullable/optional dates: %s", async (date) => {
|
|
const { prisma, state } = buildStatefulPrisma([buildContractRow({ status: "draft" })]);
|
|
const { app } = await buildApp({ prisma });
|
|
try {
|
|
const dates = date === undefined ? {} : { signedDate: date, effectiveDate: date, expiryDate: date };
|
|
const created = await app.inject({ method: "POST", url: "/api/contracts-registry", payload: { ...body, ...dates } });
|
|
expect(created.statusCode).toBe(201);
|
|
const expected = date ? new Date(`${date}T00:00:00.000Z`) : null;
|
|
expect(prisma.contractRegistry.create.mock.calls[0][0].data).toMatchObject({ signed_date: expected, effective_date: expected, expiry_date: expected });
|
|
const patched = await app.inject({ method: "PATCH", url: `/api/contracts-registry/${state.contracts[0].id}`, payload: { summary: "合成修改", ...dates } });
|
|
expect(patched.statusCode).toBe(200);
|
|
expect(state.contracts[0].signed_date).toEqual(date === undefined ? new Date("2026-03-01T00:00:00.000Z") : expected);
|
|
} finally { await app.close(); }
|
|
});
|
|
it.each(["expiryDateFrom", "expiryDateTo"])("rejects invalid filter %s before contract reads", async (field) => {
|
|
const { app, prisma } = await buildApp();
|
|
try {
|
|
const response = await app.inject({ method: "GET", url: `/api/contracts-registry?${field}=2026-02-30` });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "contract_registry.query_invalid", formErrors: [], fieldErrors: { [field]: expect.arrayContaining(["invalid_calendar_date"]) } } });
|
|
expect(prisma.contractRegistry.findMany).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
});
|
|
|
|
|
|
describe("contract expiring query reasons", () => {
|
|
it.each(["days=0", "days=366", "days=1.5", "days=invalid", "unexpected=1"])("rejects %s before contract queries", async (query) => {
|
|
const { app, prisma } = await buildApp();
|
|
try {
|
|
const response = await app.inject({ method: "GET", url: `/api/contracts-registry/expiring?${query}` });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "contract_registry.expiring_query_invalid" } });
|
|
const details = response.json().error.details;
|
|
if (query.startsWith("days=")) expect(details.fieldErrors.days).toHaveLength(1);
|
|
else expect(details.formErrors).toHaveLength(1);
|
|
expect(prisma.contractRegistry.findMany).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
it.each([1, 365])("retains the legal %s day boundary", async (days) => {
|
|
const { app, prisma } = await buildApp();
|
|
try {
|
|
const response = await app.inject({ method: "GET", url: `/api/contracts-registry/expiring?days=${days}` });
|
|
expect(response.statusCode).toBe(200);
|
|
expect(prisma.contractRegistry.findMany).toHaveBeenCalledOnce();
|
|
} finally { await app.close(); }
|
|
});
|
|
});
|
|
|
|
|
|
describe("contract write error reasons", () => {
|
|
const id = "55555555-5555-4555-8555-555555555551";
|
|
it.each([
|
|
["POST", "", {}, "create"],
|
|
["PATCH", `/${id}`, {}, "update"],
|
|
["POST", `/${id}/terminate`, { reason: "short" }, "terminate"],
|
|
["POST", `/${id}/corrections`, { summary: "short" }, "correction"],
|
|
])("explains %s %s input before business access", async (method, path, payload, operation) => {
|
|
const { app, prisma } = await buildApp();
|
|
const transaction = vi.spyOn(prisma, "$transaction");
|
|
try {
|
|
const response = await app.inject({ method, url: `/api/contracts-registry${path}`, payload });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `contract_registry.${operation}_input_invalid`, fieldErrors: expect.any(Object), formErrors: expect.any(Array) } });
|
|
for (const fn of Object.values(prisma.contractRegistry)) expect(fn).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
expect(transaction).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
const terminated = { status: "terminated", terminated_at: new Date("2026-04-04T08:30:00.000Z"), terminated_reason: "合成终止原因用于测试", terminated_by: ACTOR_ID };
|
|
it.each([
|
|
["PATCH", "", terminated, { summary: "合成修改" }, "terminated_is_final"],
|
|
["PATCH", "", { status: "archived" }, { summary: "合成修改" }, "archived_fields_locked"],
|
|
["POST", "/terminate", { status: "draft" }, { reason: "合成终止原因满足原长度要求" }, "terminate_not_allowed"],
|
|
["POST", "/corrections", { status: "active" }, { summary: "合成纠错说明满足原长度要求" }, "correction_requires_archived"],
|
|
["POST", "/renew", { status: "draft" }, undefined, "renew_not_allowed"],
|
|
])("explains %s %s state guard %s", async (method, suffix, row, payload, reason) => {
|
|
const stateful = buildStatefulPrisma([buildContractRow(row)]);
|
|
const { app, prisma } = await buildApp({ prisma: stateful.prisma });
|
|
try {
|
|
const response = await app.inject({ method, url: `/api/contracts-registry/${id}${suffix}`, payload });
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error).toMatchObject({ code: "VALID_001", details: { status: reason, reasonCode: `contract_registry.${reason}` } });
|
|
expect(prisma.contractRegistry.create).not.toHaveBeenCalled();
|
|
expect(prisma.contractRegistry.updateMany).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
expect(stateful.state.contracts).toHaveLength(1);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each([
|
|
["PATCH", "", "active", { summary: "合成修改" }, "contract_state_conflict", "state_conflict"],
|
|
["POST", "/terminate", "active", { reason: "合成终止原因满足原长度要求" }, "terminate_conflict", "terminate_conflict"],
|
|
["POST", "/corrections", "archived", { summary: "合成纠错说明满足原长度要求" }, "correction_state_conflict", "correction_state_conflict"],
|
|
])("explains optimistic conflict for %s %s without success audit", async (method, suffix, status, payload, detail, reason) => {
|
|
const stateful = buildStatefulPrisma([buildContractRow({ status })]);
|
|
stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
const { app, prisma } = await buildApp({ prisma: stateful.prisma });
|
|
try {
|
|
const response = await app.inject({ method, url: `/api/contracts-registry/${id}${suffix}`, payload });
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error).toMatchObject({ code: "VALID_001", details: { status: detail, reasonCode: `contract_registry.${reason}` } });
|
|
expect(prisma.contractRegistry.updateMany).toHaveBeenCalledOnce();
|
|
expect(prisma.contractRegistry.create).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
expect(stateful.state.contracts[0].status).toBe(status);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it("retains renewal's attempted draft before source-version conflict", async () => {
|
|
const stateful = buildStatefulPrisma([buildContractRow()]);
|
|
stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
const { app, prisma } = await buildApp({ prisma: stateful.prisma });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: `/api/contracts-registry/${id}/renew` });
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error.details).toEqual({ status: "contract_state_conflict", reasonCode: "contract_registry.state_conflict" });
|
|
expect(prisma.contractRegistry.create).toHaveBeenCalledOnce();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
// This fixture restores its own memory; it does not prove a PostgreSQL rollback.
|
|
expect(stateful.state.contracts).toHaveLength(1);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it("keeps feature denial before business access", async () => {
|
|
const { app, prisma } = await buildApp();
|
|
prisma.featureFlag.findUnique.mockImplementation(async ({ where }) => ({ ...buildFeatureFlagRecord(where.flag_key), override_mode: "force_off" }));
|
|
try {
|
|
const response = await app.inject({ method: "GET", url: "/api/contracts-registry" });
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "contract_registry.feature_disabled", featureFlag: "project_linking_v1_disabled", reason: expect.any(String) } });
|
|
for (const fn of Object.values(prisma.contractRegistry)) expect(fn).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it("preserves management permission ahead of malformed input", async () => {
|
|
const { app, prisma } = await buildApp({ actor: { id: ACTOR_ID, permissionCodes: [] } });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: "/api/contracts-registry", payload: {} });
|
|
expect(response.statusCode).toBe(403);
|
|
expect(response.json().error.code).toBe("PERM_001");
|
|
expect(prisma.contractRegistry.create).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
});
|