fix: 加固日历日期与动态错误合同(09-05-error-explainability-payment-readiness,09-08-reimbursement-payment-closeout)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-16 20:20:11 +08:00
父节点 842c61e696
当前提交 b1d25d3c99
共修改 20 个文件,包含 253 行新增和 21 行删除
@@ -28,4 +28,13 @@ describe('assets route validation', () => {
}),
);
});
it.each(['lastVerifiedAt', 'expiresAt', 'activatedAt', 'retiredAt'])('rejects calendar overflow in %s', (field) => {
expect(() => assetsRoutes.__testables.parseAssetCreateBodyForTest({ ...validPayload, [field]: '2026-02-30' }))
.toThrow();
expect(assetsRoutes.__testables.parseAssetCreateBodyForTest({ ...validPayload, [field]: '2024-02-29' })[field])
.toBe('2024-02-29');
expect(assetsRoutes.__testables.parseAssetCreateBodyForTest({ ...validPayload, [field]: null })[field])
.toBeNull();
});
});
@@ -229,6 +229,12 @@ describe("auth login route", () => {
headers: { "x-device-fingerprint": "synthetic-input-validation" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe(`auth.${reason}`);
if (reason === "password_policy_failed") {
expect(response.json().error.details.password).toEqual(expect.arrayContaining([
"密码必须包含大写字母", "密码必须包含数字",
]));
expect(response.json().error.details.password).not.toContain("密码必须包含小写字母");
}
expect(state.employeeUpdate).not.toHaveBeenCalled();
expect(state.authSessionCreate).not.toHaveBeenCalled();
expect(state.emailLoginCodeCreate).not.toHaveBeenCalled();
@@ -180,6 +180,9 @@ describe("PATCH /api/invoice-pool/:id", () => {
const response = await app.inject({ method, url: `/api/invoice-pool/${INVOICE_ID}${suffix}`, headers: { authorization: "Bearer test" }, payload });
expect(response.statusCode).toBe(statusCode);
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
if (reason === "ocr_fields_missing") {
expect(response.json().error.details.missingFields).toEqual(["invoiceCode", "sellerName"]);
}
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(JSON.stringify(state.invoice)).toBe(before);
@@ -1011,6 +1011,42 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("rejects overflowing payment dates before creation writes", async () => {
const { app, tx } = await buildApp();
try {
const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" },
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], defaultPaymentDate: "2026-02-30" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe("payment_batch.invalid_input");
expect(response.json().error.details.fieldErrors.defaultPaymentDate).toContain("invalid_calendar_date");
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it.each(["2026-02-30", "2026-13-01"])("rejects invalid payment date override %s without writes", async (paymentDateOverride) => {
const { app, tx } = await buildApp();
try {
const response = await app.inject({ method: "PATCH", url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" }, payload: { paymentDateOverride } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.fieldErrors.paymentDateOverride).toContain("invalid_calendar_date");
expect(tx.paymentBatchItem.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it.each(["2024-02-29", null])("preserves valid or cleared payment date override %s", async (paymentDateOverride) => {
const { app, state } = await buildApp();
try {
const response = await app.inject({ method: "PATCH", url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" }, payload: { paymentDateOverride } });
expect(response.statusCode).toBe(200);
expect(state.batch.items[0].payment_date_override).toEqual(paymentDateOverride ? new Date(`${paymentDateOverride}T00:00:00.000Z`) : null);
} finally { await app.close(); }
});
it("projects synthetic 0014 omissions in candidates and preflight without writes, and recomputes after repair", async () => {
const { app, tx, state } = await buildApp();
const completeOwner = { ...state.reimbursement.owner };
@@ -434,6 +434,29 @@ describe("reimbursement create enhancements", () => {
await app.close();
});
it.each(["reimbursementDate", "travelStartDate", "travelEndDate"])("rejects an overflowing %s before draft writes", async (field) => {
const { app, state, prisma } = await buildApp();
try {
const response = await app.inject({ method: "POST", url: "/api/reimbursements",
payload: { [field]: "2026-02-30" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe("reimbursement.invalid_input");
expect(response.json().error.details.fieldErrors[field]).toContain("invalid_calendar_date");
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(state.reimbursements).toEqual([]);
expect(state.auditLogs).toEqual([]);
} finally { await app.close(); }
});
it.each(["2024-02-29", "2026-04-30"])("stores valid reimbursement date %s unchanged", async (reimbursementDate) => {
const { app, state } = await buildApp();
try {
const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { reimbursementDate } });
expect(response.statusCode).toBe(200);
expect(state.reimbursements[0].reimbursement_date.toISOString()).toBe(`${reimbursementDate}T00:00:00.000Z`);
} finally { await app.close(); }
});
it("creates reimbursement items from template", async () => {
const { app, state } = await buildApp();
@@ -128,6 +128,19 @@ async function buildApp({ employee, prisma }) {
}
describe("travelRequests routes", () => {
it.each(["startDate", "endDate"])("rejects overflowing %s before the edit transaction", async (field) => {
const prisma = { $transaction: vi.fn() };
const app = await buildApp({ employee: { id: "employee-1", permissionCodes: ["travel.create"] }, prisma });
try {
const response = await app.inject({ method: "PATCH", url: "/api/travel-requests/11111111-1111-4111-8111-111111111111",
headers: { authorization: "Bearer test" }, payload: { [field]: "2026-04-31" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("VALID_001");
expect(response.json().error.details.fieldErrors[field]).toContain("invalid_calendar_date");
expect(prisma.$transaction).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it("lists only own records when actor lacks view_all", async () => {
const prisma = buildPrisma({
items: [buildTravelRequestRecord()],
@@ -196,6 +196,37 @@ async function buildApp(options = {}) {
}
describe("workflow batch routes", () => {
it("returns the precise reminder cooldown time without creating another reminder", async () => {
const taskId = "11111111-1111-4111-8111-111111111111";
const { app, tx, state } = await buildApp({
actor: { id: OWNER_ID, roleCodes: ["employee"], permissionCodes: [] },
});
tx.workflowHistory.findFirst = vi.fn().mockResolvedValue({
id: "synthetic-reminder", created_at: new Date("2026-09-16T08:30:00.000Z"),
});
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-09-16T09:30:00.000Z"));
try {
const response = await app.inject({ method: "POST", url: `/api/workflow/tasks/${taskId}/remind`, payload: {} });
expect(response.statusCode).toBe(409);
expect(response.json().error).toMatchObject({ code: "VALID_001", details: {
reasonCode: "workflow.remind_cooldown", workflow: "remind_too_frequent",
nextAllowedAt: "2026-09-17T08:30:00.000Z",
} });
expect(tx.workflowHistory.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: {
task_id: taskId, actor_id: OWNER_ID, action: "remind",
created_at: { gte: new Date("2026-09-15T09:30:00.000Z") },
} }));
expect(tx.workflowHistory.create).not.toHaveBeenCalled();
expect(tx.notification.create).not.toHaveBeenCalled();
expect(tx.workflowTask.update).not.toHaveBeenCalled();
expect(state.history).toEqual([]);
} finally {
vi.useRealTimers();
await app.close();
}
});
it.each([
["POST", "/workflow/tasks/invalid/approve", {}, "workflow.task_id_invalid"],
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/reject", { comment: " " }, "workflow.comment_invalid"],