diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 89cc82b9..73e64d82 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -18,6 +18,16 @@ QA script patterns over new abstractions. ## Common Checks +### Calendar Date Inputs + +- For reimbursement, payment-batch, travel-request and asset date-only input schemas, + combine the YYYY-MM-DD shape with isValidDateOnlyString. Reject calendar overflow + before persistence; a regex alone accepts February 30 and JavaScript normalizes it. +- Keep optional and nullable wrappers outside the string validator. Validate leap-day, + month-end, invalid month and empty/null boundaries without changing stored historical dates. +- parseDateOnlyString retains its legacy conversion contract for unaudited consumers; + it is not validation. Do not claim the shared parser or other domains are all hardened. + ### Payment Account And Invoice Read Errors - Payment-account maintenance and invoice-pool read validation use registered diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 87f3e948..6d169cfb 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -23,6 +23,12 @@ ## Source Of Truth +- Reminder cooldown hints render server UTC timestamps in Asia/Shanghai with an + explicit Beijing-time label and second precision. Reuse formatDisplayDateTime's + opt-in includeSeconds; existing callers retain minute precision. Both registered + reason and legacy details paths reject malformed/non-finite timestamps without + reflecting arbitrary values. Cover a UTC-to-Beijing cross-year case. + Frontend visual and interaction design uses the installed `frontend-design` skill as the primary design authority. `docs/产品设计规则.md` remains a product experience and business-boundary fact source, not a visual, brand or aesthetic diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md b/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md index 3a153c2e..b8ce4807 100644 --- a/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md @@ -243,3 +243,18 @@ R1包及其manifest不变;本轮代码已偏离R1,必须重冻R2后交增量 - 下一项统一待办需求仅以该真实流程的三个交接观察为输入:交给财务组批、交给经理/CEO 复核、交回财务登记付款;每处记录谁交给谁、接收人通过什么现有入口或提醒知道轮到自己、是否需要口头催办。尚未发生的交接不预填结论,不记录真实银行资料。 - 独立待办仍保留:财务测试账号 403 需要单独解决并补财务身份真实验收(管理员代验不等于该角色验收);合同只读超时需单独复现归因,不计入通过;“三张单的答复”仅作为用户提醒保留,未明确具体单据和答复内容,不自行推断或代答。 - Trellis 升级另行审查:逐条核对 workflow.md 与 project-governance.md 删除或弱化的规则及理由,独立检查 hooks 行为变化;不以模型升级为整批删除保障的理由。本次仅记录后续事项,不启动新产品任务、不修改配置或真实资料、不自动提交、部署或归档。 +# 2026-09-16 上线后契约补强 + +后续日期批次方案:共享date工具新增纯isValidDateOnlyString谓词(严格YYYY-MM-DD、有限UTC日期、回读同日),保持旧parseDateOnlyString行为以免无关服务突然抛500;先接报销/付款/差旅申请/资产四路由的既有Zod日期schema,optional/nullable外层语义不变,失败沿原输入错误合同且写入前拒绝。催办复用前端displayFormat的Asia/Shanghai格式并明确“北京时间”,旧details分支也校验UTC形状/有限性,不回显任意字符串。验证单测正反边界、各域实际入口/解析器、无副作用及前端typecheck/build;采购、合同、考勤等剩余消费者继续单列,不冒称所有日期解析债务清零。不迁移或改生产历史日期,不提交/部署本轮未审代码。 + +日期批次已本地实施:9文件450/450定向回归通过(日期工具/客户端映射/四域验证/报销统计/金额/草稿分类)。付款导出两项首跑因系统Python缺BIFF8依赖返回409,改用已有隔离Python(xlrd2.0.2/xlwt1.3.0)后整轮通过,没有修改生产依赖或产品导出门禁。命令:`cd server && PAYMENT_ORDER_PYTHON_BIN=<已有隔离Python> npm test -- tests/unit/utils/date.test.js tests/unit/scripts/error-reason-client.test.js tests/unit/routes/assets.validation.test.js tests/unit/routes/travelRequests.test.js tests/unit/routes/reimbursements.create.test.js tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/routes/reimbursements.stats.test.js tests/unit/routes/reimbursements.amount-guard.test.js tests/unit/routes/reimbursements.draft-classification.test.js`。资产覆盖实际create解析器的4字段无效/闰日/null,不冒充真实DB写入;差旅覆盖PATCH两日期输入前拒绝;报销/付款另有正常日期/清空/无写入反例。催办保留秒且显示北京时间,默认格式消费者仍分钟精度。 + +消费者盘点:已收口4路由输入schema;invoicePool局部日期函数已有独立严格校验,未重写。contractsRegistry/purchaseRequests/travelOrders/travelReconciliation/weeklyReports/presence/portal、contractRegistryFilters/attendance/employeeStats/feeControl/presenceHistory及export-small-team-observation仍调用旧解析器,需按外部输入与内部派生分类后逐项迁移,不因共享谓词新增就算已修。无生产历史日期扫描/修订;原到期日未改。当前主会话自检不计独立复核,整批新变更待只读内容复核与单独提交/发布批准。 + +最终增量合并验证:上述9文件再加auth.login、invoicePool.patch、workflow.batch共12文件526/526通过。`cd client && npm run typecheck && npm run build`退出0;Node语法检查、原因码注册229/遗留612/零finding、process baseline、Trellis治理及git diff --check通过。构建有既有Browserslist数据过期与大chunk提示,未升级依赖或修改拆包。当前未执行浏览器/生产登录业务链,不把旧22项记录当新输入重跑。准备calendar-contract-review-20260916-r1独立只读交审,旧R1/R2交付包保持原字节;本轮不提交或部署,生产仍842c61e6。 + +842c61e6已部署,公网只读验收通过,发布回执见error-explainability-r2-release。用户批准继续后续工作;本轮先处理独立复核F-1/F-2/F-3的本地测试缺口,不改已上线业务逻辑、不重冻旧包、不自动提交或部署。验收:服务端当前VAT必填集合逐字段投影、密码校验实际输出逐规则投影;路由断言missingFields/password兼容键与具体值;严格UTC形状但非有限日期安全回落。票种扩展、时区展示仍需分别核验,测试覆盖不扩称全集已完成。 + +最终同输入验证:`cd server && npm test -- tests/unit/scripts/error-reason-client.test.js tests/unit/routes/auth.login.test.js tests/unit/routes/invoicePool.patch.test.js tests/unit/routes/workflow.batch.test.js`,4文件323/323通过。新增4项(VAT集合、密码策略、非有限日期、催办精确24小时冷却负载);两项已有路由用例加强动态键断言。催办409同时核nextAllowedAt、查询时间窗、无历史/通知/任务写入;只用合成actor/存储替身,不是生产登录链。首轮1项失败是新测试把已有“总金额”误写为“价税合计”,已校正测试预期而未修改产品文案;最终整轮通过。存在Node既有punycode弃用警告,无测试失败。 + +流程baseline、Trellis治理及git diff --check通过。本轮仅测试/文档,无运行时代码或schema变化,未重跑前端build/浏览器,未独立复核、提交、推送或发布。扩展票种buyerName、密码新规则的输入覆盖、时区与共享日历校验仍不是这组测试能全量保证的事项。 diff --git a/.trellis/tasks/09-08-reimbursement-payment-closeout/implement.md b/.trellis/tasks/09-08-reimbursement-payment-closeout/implement.md index 301b318d..f36a3d41 100644 --- a/.trellis/tasks/09-08-reimbursement-payment-closeout/implement.md +++ b/.trellis/tasks/09-08-reimbursement-payment-closeout/implement.md @@ -756,3 +756,12 @@ T0 是新增代码工作的前置;T1 数据库路径获成功证据后推进 T - [ ] 功能交付后回到统一待办投影;同期维护原错误豁免截止日队列,不批量归档或延期。 首个实施动作明确为“用空报销合成数据复现发票顺序问题,并核查退回删除事务/材料关系”,不是继续生产清理或让 majianyong 再尝试一次。主工作树在旧基线且有治理差异,执行副本须从最新 origin/main 核验建立,按任务登记分批文件清单与测试命令。 +# 2026-09-16 发布后后续执行 + +最新已上线候选842c61e69645b2c753b41593686701854e25ea3a,树831557fd8f5c26445bd66eef574332eb8314fe59。R2独立内容审查通过后已按批准提交、推送、重新预检和部署;公网只读/运行身份验收通过,数据库未重启、.env未覆盖。证据归artifacts/qa-layout/error-explainability-r2-release,不能再将下方09-15历史“未提交/未部署”当当前事实。 + +用户要求继续后续工作,本轮先在09-05补复核低级发现对应的动态错误负载/白名单契约/无效时间反例,只有测试和执行记录修改,线上版本不变,未自动提交或部署。原09-04两文档及统一审批中心三处旧差异保持隔离。 + +接续批次已增加四域日期输入严格校验及明确北京时间的催办提示,当前变更不再仅测试/记录。最终12文件526/526及前端typecheck/build、原因码和流程治理通过;详细范围与未覆盖消费者见09-05本日记录。交审包calendar-contract-review-20260916-r1待独立内容复核,不沿用上一轮R2通过结论;未提交、未部署,线上仍842c61e6。其余需要真实对象/专项授权的C/E/F不通过修改测试造完成记录。 + +仍待项:A历史task全量验收映射不以祖先关系替代;B真实通知送达与E自然回单不制造业务;C同源DB+附件+应用恢复仍未实测完成,08-23中固定历史源方案待专项批准,不能将本次部署批准当恢复授权;F密钥来源UNKNOWN保留且不再向用户索取密钥。D共享日期/时区、其他域遗留原因与扩展票种仍按既有期限逐项治理,不统一延期。镜像归档完整性不是恢复演练,当前部署通过不自动关闭上述事项。 diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index c2df0fd8..e9056751 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -1,4 +1,5 @@ import { formatInvoiceOcrFieldLabels } from "../lib/invoiceOcr"; +import { formatDisplayDateTime } from "../lib/displayFormat"; import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "./errorReasons"; function readStringList(value: unknown): string[] { @@ -307,8 +308,9 @@ export function mapApiErrorMessage( if (details?.workflow === "remind_too_frequent") { const nextAllowedAt = typeof details?.nextAllowedAt === "string" ? details.nextAllowedAt : null; - return nextAllowedAt - ? `24 小时内已催办过,请在 ${nextAllowedAt.slice(0, 19).replace("T", " ")} 后再试。` + return nextAllowedAt && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(nextAllowedAt) + && Number.isFinite(new Date(nextAllowedAt).getTime()) + ? `24 小时内已催办过,请在北京时间 ${formatDisplayDateTime(nextAllowedAt, { includeSeconds: true })} 后再试。` : "24 小时内已催办过。"; } diff --git a/client/src/lib/displayFormat.ts b/client/src/lib/displayFormat.ts index 62ce8a36..6ad59e47 100644 --- a/client/src/lib/displayFormat.ts +++ b/client/src/lib/displayFormat.ts @@ -17,7 +17,7 @@ export function formatDisplayDate(value: string | null | undefined) { }).format(timestamp); } -export function formatDisplayDateTime(value: string | null | undefined) { +export function formatDisplayDateTime(value: string | null | undefined, options: { includeSeconds?: boolean } = {}) { if (!value) return "-"; const timestamp = new Date(value).getTime(); @@ -30,6 +30,7 @@ export function formatDisplayDateTime(value: string | null | undefined) { day: "numeric", hour: "2-digit", minute: "2-digit", + ...(options.includeSeconds ? { second: "2-digit" as const } : {}), }).format(timestamp); } diff --git a/server/src/routes/assets.js b/server/src/routes/assets.js index 82c441c2..12ef0ce1 100644 --- a/server/src/routes/assets.js +++ b/server/src/routes/assets.js @@ -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"]; diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index 1ec70763..812cfb3f 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -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({ diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 4f5275a7..a6194064 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -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(), diff --git a/server/src/routes/travelRequests.js b/server/src/routes/travelRequests.js index 271b8299..32bb5cb6 100644 --- a/server/src/routes/travelRequests.js +++ b/server/src/routes/travelRequests.js @@ -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"]); diff --git a/server/src/utils/date.js b/server/src/utils/date.js index d247ca1a..0cad163b 100644 --- a/server/src/utils/date.js +++ b/server/src/utils/date.js @@ -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, }; diff --git a/server/tests/unit/routes/assets.validation.test.js b/server/tests/unit/routes/assets.validation.test.js index 73eea60b..834982fd 100644 --- a/server/tests/unit/routes/assets.validation.test.js +++ b/server/tests/unit/routes/assets.validation.test.js @@ -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(); + }); }); diff --git a/server/tests/unit/routes/auth.login.test.js b/server/tests/unit/routes/auth.login.test.js index b6e3f1a8..268fc0a9 100644 --- a/server/tests/unit/routes/auth.login.test.js +++ b/server/tests/unit/routes/auth.login.test.js @@ -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(); diff --git a/server/tests/unit/routes/invoicePool.patch.test.js b/server/tests/unit/routes/invoicePool.patch.test.js index 85eaf4af..3de8932b 100644 --- a/server/tests/unit/routes/invoicePool.patch.test.js +++ b/server/tests/unit/routes/invoicePool.patch.test.js @@ -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); diff --git a/server/tests/unit/routes/paymentBatches.reimbursement.test.js b/server/tests/unit/routes/paymentBatches.reimbursement.test.js index 5e65b8ae..a2b0426f 100644 --- a/server/tests/unit/routes/paymentBatches.reimbursement.test.js +++ b/server/tests/unit/routes/paymentBatches.reimbursement.test.js @@ -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 }; diff --git a/server/tests/unit/routes/reimbursements.create.test.js b/server/tests/unit/routes/reimbursements.create.test.js index 13251f58..c5ed06b4 100644 --- a/server/tests/unit/routes/reimbursements.create.test.js +++ b/server/tests/unit/routes/reimbursements.create.test.js @@ -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(); diff --git a/server/tests/unit/routes/travelRequests.test.js b/server/tests/unit/routes/travelRequests.test.js index 3c403e0f..3d4b5b80 100644 --- a/server/tests/unit/routes/travelRequests.test.js +++ b/server/tests/unit/routes/travelRequests.test.js @@ -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()], diff --git a/server/tests/unit/routes/workflow.batch.test.js b/server/tests/unit/routes/workflow.batch.test.js index c79e2ec7..da4e7055 100644 --- a/server/tests/unit/routes/workflow.batch.test.js +++ b/server/tests/unit/routes/workflow.batch.test.js @@ -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"], diff --git a/server/tests/unit/scripts/error-reason-client.test.js b/server/tests/unit/scripts/error-reason-client.test.js index dd92d807..28bed700 100644 --- a/server/tests/unit/scripts/error-reason-client.test.js +++ b/server/tests/unit/scripts/error-reason-client.test.js @@ -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 }))) diff --git a/server/tests/unit/utils/date.test.js b/server/tests/unit/utils/date.test.js new file mode 100644 index 00000000..207593e9 --- /dev/null +++ b/server/tests/unit/utils/date.test.js @@ -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); + }); +});