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

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

146 行
9.4 KiB
JavaScript

const fs = require("node:fs");
const path = require("node:path");
const ts = require("../../../../client/node_modules/typescript");
const expectedMessages = require("../fixtures/error-reason-messages.json");
const { ERROR_REASONS, 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);
if (cache.has(filename)) return cache.get(filename).exports;
const module = { exports: {} };
cache.set(filename, module);
const output = ts.transpileModule(fs.readFileSync(filename, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
const localRequire = (specifier) => loadClientModule(path.relative(path.resolve(__dirname, "../../../../client/src"), path.resolve(path.dirname(filename), `${specifier}.ts`)), cache);
new Function("require", "module", "exports", output)(localRequire, module, module.exports);
return module.exports;
}
describe("client business error projection", () => {
const cache = new Map();
const { resolveErrorMessage } = loadClientModule("lib/resolveErrorMessage.ts", cache);
const { ApiError } = loadClientModule("api/types.ts", cache);
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("requires an independent message fixture for every user-facing registered reason", () => {
const missing = ERROR_REASONS
.filter((reason) => reason.audience === "user" && !Object.hasOwn(expectedMessages, reason.code))
.map((reason) => reason.code);
expect(missing).toEqual([]);
});
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);
});
it("shows exact actionable payment reason rather than arbitrary server text", () => {
expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", message: "private payload", details: { reasonCode: "payment_batch.legal_entity_required" } })))
.toBe("报销人尚未维护所属公司,请联系管理员补充后再安排付款。");
});
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年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);
});
it.each([
{ reasonCode: "invoice_pool.ocr_fields_missing", missingFields: ["/private/secret", "__proto__"], password: ["密码必须包含数字"] },
{ reasonCode: "workflow.remind_cooldown", nextAllowedAt: "/private/secret", password: ["密码必须包含数字"] },
{ reasonCode: "auth.password_policy_failed", password: ["/private/secret"], ocr: "missing_required_fields", missingFields: ["invoiceCode"] },
])("falls back safely for malformed or cross-domain details: %j", (details) => {
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details })))
.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 })))
.toBe("票据仍缺少关键字段:发票代码,补齐后才能标记为可用。");
});
it.each([undefined, "unknown.reason", "__proto__", "constructor"])("fails safely for unknown reason %s", (reasonCode) => {
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details: { reasonCode, internal: "/private/secret" } }))).toBe(fallback);
});
it("adds only a valid diagnostic identifier, never arbitrary request text", () => {
const error = new ApiError({ status: 409, code: "VALID_001", message: "参数校验失败", requestId: "req-test-1" });
expect(resolveErrorMessage(error)).toBe(`${fallback} 排障编号:req-test-1`);
error.requestId = "/private/secret";
expect(resolveErrorMessage(error)).toBe(fallback);
});
it("does not surface generic validation or internal identifiers from plain errors", () => {
expect(resolveErrorMessage(new Error("VALID_001: 参数校验失败"))).toBe(fallback);
expect(resolveErrorMessage(new Error("记录失败 a1111111-1111-4111-8111-111111111111"), "请刷新后重试。")).toBe("请刷新后重试。");
});
it("separates persisted failure text from item and risk-card UI projection", () => {
const item = { id: "item", status: "failed", failureCode: "VALID_001", failureMessage: "参数校验失败 /private/hidden", openRiskFlags: [] };
expect(paymentItemFailureMessage(item, "batch")).toBe(fallback);
const risk = { riskCode: "payment_batch_execute_failed", detail: "参数校验失败 /private/hidden", payloadJson: { batchId: "batch", itemId: "item", reasonCode: "payment_batch.source_status_invalid" } };
item.openRiskFlags = [risk];
const expected = "来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。";
expect(paymentItemFailureMessage(item, "batch")).toBe(expected);
expect(paymentItemFailureMessage(item, "different-batch")).toBe(fallback);
expect(paymentRiskFlagsForDisplay([risk])[0].detail).toBe(expected);
expect(risk.detail).toBe("参数校验失败 /private/hidden");
risk.payloadJson.reasonCode = "unknown.reason";
expect(paymentRiskFlagsForDisplay([risk])[0].detail).toBe(fallback);
});
});