fix: 加固日历日期与动态错误合同(09-05-error-explainability-payment-readiness,09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -2,7 +2,7 @@ const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { parseDateOnlyString } = require("../utils/date");
|
||||
const { parseDateOnlyString, isValidDateOnlyString } = require("../utils/date");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags');
|
||||
const {
|
||||
@@ -14,7 +14,7 @@ const { sendData } = require("../utils/reply");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional();
|
||||
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" });
|
||||
const ASSET_STATUS_VALUES = ["in_stock", "in_use", "under_repair", "retired", "archived"];
|
||||
const ASSET_TYPE_VALUES = ["fixed_asset", "electronic_document", "electronic_system", "code_repository", "backup_media"];
|
||||
const ASSET_SENSITIVITY_VALUES = ["public_candidate", "internal", "confidential", "strictly_confidential"];
|
||||
|
||||
@@ -12,7 +12,7 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { currentDateString, parseDateOnlyString, isValidDateOnlyString } = require("../utils/date");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { createNotificationsWithRecords } = require("../services/notifications");
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
@@ -70,14 +70,14 @@ const CREATE_SCHEMA = z.object({
|
||||
entityType: ENTITY_TYPE_SCHEMA,
|
||||
name: z.string().trim().min(1).max(120).optional(),
|
||||
entityIds: z.array(UUID_SCHEMA).min(1).max(200),
|
||||
defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(),
|
||||
defaultPaymentChannel: PAYMENT_CHANNEL_SCHEMA.optional(),
|
||||
voucherPrefix: z.string().trim().min(1).max(32).optional(),
|
||||
}).strict();
|
||||
|
||||
const PATCH_ITEM_SCHEMA = z.object({
|
||||
paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(),
|
||||
paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).nullable().optional(),
|
||||
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const REVIEW_SCHEMA = z.object({
|
||||
|
||||
@@ -10,7 +10,7 @@ const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sha256Hex } = require("../utils/crypto");
|
||||
const { buildAttachmentContentDisposition } = require("../utils/contentDisposition");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { compactDateString, currentDateString, parseDateOnlyString, isValidDateOnlyString } = require("../utils/date");
|
||||
const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation");
|
||||
const { hasPermission, resolveEntityPermission } = require("../constants/permissions");
|
||||
const {
|
||||
@@ -76,7 +76,7 @@ const {
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const PAY_BODY_SCHEMA = z.object({
|
||||
paymentVoucherNo: z.string().trim().min(1).max(50).optional(),
|
||||
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(),
|
||||
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
|
||||
paymentNote: z.string().trim().max(200).optional(),
|
||||
});
|
||||
@@ -91,12 +91,12 @@ const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(),
|
||||
description: z.string().trim().max(500).nullable().optional(),
|
||||
travelPurpose: z.string().trim().max(200).nullable().optional(),
|
||||
travelLocation: z.string().trim().max(200).nullable().optional(),
|
||||
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).nullable().optional(),
|
||||
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).nullable().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] });
|
||||
@@ -151,14 +151,14 @@ function throwMaterialPreviewAppError(error) {
|
||||
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
||||
.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
templateId: z.string().uuid().optional(),
|
||||
linkedTravelRequestId: z.string().uuid().optional(),
|
||||
travelPurpose: z.string().trim().max(200).optional(),
|
||||
travelLocation: z.string().trim().max(200).optional(),
|
||||
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(),
|
||||
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -181,13 +181,13 @@ const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
||||
|
||||
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
||||
expenseTypeId: z.string().uuid().nullable(),
|
||||
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }),
|
||||
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
|
||||
description: z.string().trim().max(500),
|
||||
assetPriorApprovalBasis: z.string().trim().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
const DATE_ONLY_QUERY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const DATE_ONLY_QUERY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" });
|
||||
const REIMBURSEMENT_STATS_QUERY_SCHEMA = z.object({
|
||||
dateFrom: DATE_ONLY_QUERY_SCHEMA.optional(),
|
||||
dateTo: DATE_ONLY_QUERY_SCHEMA.optional(),
|
||||
|
||||
@@ -2,7 +2,7 @@ const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { parseDateOnlyString } = require("../utils/date");
|
||||
const { parseDateOnlyString, isValidDateOnlyString } = require("../utils/date");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||
@@ -28,7 +28,7 @@ const { flushNotificationDispatchTasks } = require("../services/notificationDisp
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional();
|
||||
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(?:\.\d{1,2})?$/);
|
||||
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" });
|
||||
const DATE_TIME_SCHEMA = z.string().min(1).max(64);
|
||||
const TRAVEL_STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected"]);
|
||||
const TRIP_TYPE_SCHEMA = z.enum(["domestic", "international"]);
|
||||
|
||||
@@ -17,8 +17,15 @@ function parseDateOnlyString(value) {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function isValidDateOnlyString(value) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const date = parseDateOnlyString(value);
|
||||
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compactDateString,
|
||||
currentDateString,
|
||||
parseDateOnlyString,
|
||||
isValidDateOnlyString,
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -3,6 +3,8 @@ const path = require("node:path");
|
||||
const ts = require("../../../../client/node_modules/typescript");
|
||||
const expectedMessages = require("../fixtures/error-reason-messages.json");
|
||||
const { getErrorReason } = require("../../../src/constants/errorReasons");
|
||||
const { getMissingRequiredOcrFields } = require("../../../src/services/ocr");
|
||||
const { validatePassword } = require("../../../src/utils/passwordPolicy");
|
||||
|
||||
function loadClientModule(relativePath, cache = new Map()) {
|
||||
const filename = path.resolve(__dirname, "../../../../client/src", relativePath);
|
||||
@@ -24,6 +26,12 @@ describe("client business error projection", () => {
|
||||
const { paymentItemFailureMessage, paymentRiskFlagsForDisplay } = loadClientModule("lib/paymentBatchDisplay.ts", cache);
|
||||
const fallback = "操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。";
|
||||
|
||||
it("keeps existing display consumers at minute precision unless seconds are requested", () => {
|
||||
const { formatDisplayDateTime } = loadClientModule("lib/displayFormat.ts", cache);
|
||||
expect(formatDisplayDateTime("2026-12-31T18:30:45.000Z")).toBe("2027年1月1日 02:30");
|
||||
expect(formatDisplayDateTime("2026-12-31T18:30:45.000Z", { includeSeconds: true })).toBe("2027年1月1日 02:30:45");
|
||||
});
|
||||
|
||||
it.each(Object.entries(expectedMessages))("keeps exact registered and displayed content for %s", (reasonCode, expected) => {
|
||||
expect(getErrorReason(reasonCode).defaultMessage).toBe(expected);
|
||||
expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", message: "private payload", details: { reasonCode } }))).toBe(expected);
|
||||
@@ -36,7 +44,10 @@ describe("client business error projection", () => {
|
||||
|
||||
it.each([
|
||||
[{ reasonCode: "invoice_pool.ocr_fields_missing", ocr: "missing_required_fields", missingFields: ["invoiceCode", "sellerName"] }, "票据仍缺少关键字段:发票代码、销售方,补齐后才能标记为可用。"],
|
||||
[{ reasonCode: "workflow.remind_cooldown", workflow: "remind_too_frequent", nextAllowedAt: "2026-09-16T08:30:00.000Z" }, "24 小时内已催办过,请在 2026-09-16 08:30:00 后再试。"],
|
||||
[{ reasonCode: "workflow.remind_cooldown", workflow: "remind_too_frequent", nextAllowedAt: "2026-09-16T08:30:00.000Z" }, "24 小时内已催办过,请在北京时间 2026年9月16日 16:30:00 后再试。"],
|
||||
[{ reasonCode: "workflow.remind_cooldown", nextAllowedAt: "2026-12-31T18:30:45.000Z" }, "24 小时内已催办过,请在北京时间 2027年1月1日 02:30:45 后再试。"],
|
||||
[{ workflow: "remind_too_frequent", nextAllowedAt: "2026-12-31T18:30:45.000Z" }, "24 小时内已催办过,请在北京时间 2027年1月1日 02:30:45 后再试。"],
|
||||
[{ workflow: "remind_too_frequent", nextAllowedAt: "/private/secret" }, "24 小时内已催办过。"],
|
||||
[{ reasonCode: "auth.password_policy_failed", password: ["密码长度必须 ≥ 12", "密码必须包含数字"] }, "新密码不符合要求:密码长度必须 ≥ 12;密码必须包含数字。"],
|
||||
])("preserves scoped dynamic guidance alongside reasonCode: %j", (details, expected) => {
|
||||
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details }))).toBe(expected);
|
||||
@@ -51,6 +62,44 @@ describe("client business error projection", () => {
|
||||
.toBe(expectedMessages[details.reasonCode]);
|
||||
});
|
||||
|
||||
it("projects every required field emitted by the current VAT edit contract", () => {
|
||||
const labels = {
|
||||
invoiceCode: "发票代码", invoiceNumber: "发票号码", invoiceDate: "开票日期",
|
||||
totalAmount: "总金额", sellerName: "销售方",
|
||||
};
|
||||
const fields = getMissingRequiredOcrFields("vat", {});
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
for (const field of fields) {
|
||||
expect(labels[field]).toBeDefined();
|
||||
const details = { reasonCode: "invoice_pool.ocr_fields_missing", missingFields: [field] };
|
||||
expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", details })))
|
||||
.toBe(`票据仍缺少关键字段:${labels[field]},补齐后才能标记为可用。`);
|
||||
}
|
||||
});
|
||||
|
||||
it("projects actual password policy failures without silently dropping a rule", () => {
|
||||
vi.stubEnv("WEAK_PASSWORD_CHECK_ENABLED", "true");
|
||||
vi.stubEnv("WEAK_PASSWORD_MIN_SCORE", "2");
|
||||
try {
|
||||
const rules = new Set(["", "a", "A", "1"].flatMap(validatePassword));
|
||||
expect(rules.size).toBe(5);
|
||||
for (const rule of rules) {
|
||||
const details = { reasonCode: "auth.password_policy_failed", password: [rule] };
|
||||
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", details })))
|
||||
.toBe(`新密码不符合要求:${rule}。`);
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a UTC-shaped timestamp that produces a non-finite date", () => {
|
||||
const details = { reasonCode: "workflow.remind_cooldown", nextAllowedAt: "2026-13-16T08:30:00.000Z" };
|
||||
expect(Number.isFinite(new Date(details.nextAllowedAt).getTime())).toBe(false);
|
||||
expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", details })))
|
||||
.toBe(expectedMessages[details.reasonCode]);
|
||||
});
|
||||
|
||||
it("does not expose unknown fields mixed with known OCR labels", () => {
|
||||
const details = { reasonCode: "invoice_pool.ocr_fields_missing", missingFields: ["invoiceCode", "/private/secret"], password: ["private payload"] };
|
||||
expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", message: "private payload", details })))
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const { isValidDateOnlyString, parseDateOnlyString } = require("../../../src/utils/date");
|
||||
|
||||
describe("strict calendar date validation", () => {
|
||||
it.each(["2024-02-29", "2000-02-29", "2026-04-30", "2026-12-31", "0099-01-01"])("accepts %s without timezone shifts", (value) => {
|
||||
expect(isValidDateOnlyString(value)).toBe(true);
|
||||
expect(parseDateOnlyString(value).toISOString()).toBe(`${value}T00:00:00.000Z`);
|
||||
});
|
||||
|
||||
it.each(["2026-02-30", "2026-02-29", "1900-02-29", "2026-04-31", "2026-13-01", "2026-00-01", "2026-01-00", "2026-01-32", "2026-1-01", " 2026-01-01", "2026-01-01T00:00:00Z", "", null, undefined, 20260101])("rejects invalid input %s", (value) => {
|
||||
expect(isValidDateOnlyString(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户