/** * 韧性设计 A-* 场景矩阵(docx/异常场景与系统韧性设计 §1–§10 / §12) * 不连真实 IMAP/CC;覆盖纯逻辑 + 解析/门禁/限流/鉴权规则。 */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import ExcelJS from "exceljs"; import AdmZip from "adm-zip"; import { classifyMail } from "@/services/parse/classify"; import { parsePackingListBuffer } from "@/services/parse/packing-list"; import { extractSpreadsheetFromZip } from "@/services/parse/zip-extract"; import { resolveNoSpreadsheetOutcome } from "@/services/parse/pipeline"; import { mapChannel } from "@/services/parse/channel-map"; import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate"; import { assertTransition, canTransition, resolveAfterParse, } from "@/services/state-machine"; import { isValidContainerNo, normalizeContainerNo } from "@/utils/iso6346"; import { parseFlexibleDate, round4, toShanghaiDateString } from "@/utils/dates"; import { extractMailMeta, sanitizeFilename, truncateMessageId, } from "@/services/imap/snapshot"; import { extractLoginToken } from "@/services/cc/auth"; import { normalizeCcCode } from "@/services/cc/code"; import { isRecentlyCreated } from "@/services/import/compensation"; import { isImapSyncAlert, nextPollDelayMs, } from "@/services/imap/runtime-status"; import { checkSlidingWindow, resetRateLimitBuckets, } from "@/lib/rate-limit"; import { verifyCredentials } from "@/lib/session"; import { isAdmin, parseBigIntId } from "@/lib/api"; import { ConfirmImportError } from "@/services/import/confirm"; import { resetEnvCache } from "@/lib/env"; import { computeRawHash, computeShipmentsHash, sha256 } from "@/utils/hash"; async function xlsx(rows: { headers: string[]; data: unknown[][]; sheet?: string; }): Promise { const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet(rows.sheet ?? "卡派资料"); ws.addRow(rows.headers); for (const r of rows.data) ws.addRow(r); return Buffer.from(await wb.xlsx.writeBuffer()); } describe("A-1 输入层", () => { it("A-1.1 空主题 → (无主题) + 仍可分类", () => { const meta = extractMailMeta({ subject: " " } as never); expect(meta.subject).toBe("(无主题)"); expect(meta.subjectMissing).toBe(true); const r = classifyMail({ subject: "", body: "请查收卡派", filenames: ["a.xlsx"] }); expect(r.mail_type).toBeTruthy(); }); it("A-1.2 / A-1.3 无正文无附件 → REJECTED;NEW 无清单 → NO_SHIPMENT_ROWS", () => { expect( resolveNoSpreadsheetOutcome({ mailType: "NEW_CONTAINER", hasAttachments: false, }), ).toEqual({ status: "REJECTED_VALIDATION", lastError: "NO_SHIPMENT_ROWS", }); expect( resolveAfterParse({ mailType: "NEW_CONTAINER", validCount: 0, errorCode: "NO_SHIPMENT_ROWS", }), ).toBe("REJECTED_VALIDATION"); }); it("A-1.4 有附件无表格 → PARSE_FAILED / NO_SUPPORTED_SPREADSHEET", () => { expect( resolveNoSpreadsheetOutcome({ mailType: "NEW_CONTAINER", hasAttachments: true, }), ).toEqual({ status: "PARSE_FAILED", lastError: "NO_SUPPORTED_SPREADSHEET", }); }); it("A-1.5 缺关键列 → PARSE_FAILED + missing_columns", async () => { const buf = await xlsx({ headers: ["备注", "随便"], data: [["x", "y"]], }); const parsed = await parsePackingListBuffer(buf); expect(parsed.errorCode).toBe("PARSE_FAILED"); expect(parsed.lineage.missing_columns).toEqual( expect.arrayContaining(["F_FBACode", "F_Transporter", "F_CTNS"]), ); }); it("A-1.6 非法柜号 ISO 失败", () => { expect(isValidContainerNo("BAD")).toBe(false); expect(isValidContainerNo("ABCD1234567", true)).toBe(false); expect(normalizeContainerNo(" matu2745683 ")).toBe("MATU2745683"); }); it("A-1.7 半空行 INVALID,其它 VALID 不阻断", async () => { const buf = await xlsx({ headers: ["仓库ID", "渠道", "件数", "总体积", "毛重", "柜号"], data: [ ["ABQ2", "卡派", 1, 0.5, 10, "MATU2745683"], ["", "", 0, "", "", ""], ["FTW1", "卡派", 2, 1.2, 20, "MATU2745683"], ], }); const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false }); expect(parsed.errorCode).toBeUndefined(); const valid = parsed.shipments.filter((s) => s.row_status === "VALID"); const invalid = parsed.shipments.filter((s) => s.row_status === "INVALID"); expect(valid.length).toBeGreaterThanOrEqual(2); expect(invalid.length).toBeGreaterThanOrEqual(1); const ratio = invalid.length / parsed.shipments.length; expect(ratio).toBeLessThanOrEqual(1); }); it("A-1.9 未映射渠道仍可为 VALID + CHANNEL_UNMAPPED", async () => { const mapped = mapChannel("神秘渠道XYZ"); expect(mapped.unmapped).toBe(true); expect(mapped.transporter.length).toBeLessThanOrEqual(30); const buf = await xlsx({ headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"], data: [["ABQ2", "神秘渠道XYZ", 3, 0.8, 12]], }); const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false }); expect(parsed.shipments[0].warnings).toContain("CHANNEL_UNMAPPED"); expect(parsed.shipments[0].row_status).toBe("VALID"); }); it("A-1.10 体积/重量带单位可解析;乱码 → null 行仍 VALID", async () => { const buf = await xlsx({ headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"], data: [["ABQ2", "卡派", 1, "1.25 cbm", "12.5kg"]], }); const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false }); expect(parsed.shipments[0].row_status).toBe("VALID"); expect(parsed.shipments[0].F_CBM).toBe(1.25); expect(parsed.shipments[0].F_Weight).toBe(12.5); const bad = await xlsx({ headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"], data: [["ABQ2", "卡派", 1, "约莫", "N/A"]], }); const parsedBad = await parsePackingListBuffer(bad, { enableIsoCheck: false }); // 实现:体积/重量解析失败 → null;packing-fill 现把缺失当 INVALID // (韧性 §1.10 写「仍 VALID」,与填写规范「体积重量必填」不一致,见审查结论) expect(parsedBad.shipments[0].F_CBM).toBeNull(); expect(parsedBad.shipments[0].F_Weight).toBeNull(); expect(parsedBad.shipments[0].row_status).toBe("INVALID"); expect(parsedBad.shipments[0].invalid_reasons).toEqual( expect.arrayContaining(["总体积缺失", "毛重缺失"]), ); }); it("A-1.11 坏日期 → null;地址截断 500", async () => { expect(parseFlexibleDate("not-a-date")).toBeNull(); expect(toShanghaiDateString("2026.05.15")).toBe("2026-05-15"); const longAddr = "A".repeat(600); const buf = await xlsx({ headers: ["仓库ID", "渠道", "件数", "总体积", "毛重", "派送地址", "最早送仓"], data: [["ABQ2", "卡派", 1, 0.4, 8, longAddr, "坏日期"]], }); const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false }); expect(parsed.shipments[0].F_Address?.length).toBe(500); expect(parsed.shipments[0].F_Expected_DeliveryDateB).toBeNull(); }); }); describe("A-2 / A-9 确认门禁与幂等", () => { it("A-2.1 / A-2.2 非 PENDING 不可导入;IMPORTING/SUCCESS 不可重解析", () => { expect( canEnterForecastConfirm({ mailType: "NEW_CONTAINER", status: "PENDING_CONFIRM", validShipmentCount: 1, }), ).toBe(true); expect( canEnterForecastConfirm({ mailType: "NEW_CONTAINER", status: "IMPORTING", validShipmentCount: 1, }), ).toBe(false); expect(canTransition("PENDING_CONFIRM", "IMPORTING")).toBe(true); expect(canTransition("IMPORTING", "PARSED")).toBe(false); expect(canTransition("SUCCESS", "PARSING")).toBe(false); expect(canTransition("PARSE_FAILED", "PARSING")).toBe(true); }); it("A-2.4 / A-2.6 WORK_ORDER / TRANSFER 不可走预报确认", () => { expect( canEnterForecastConfirm({ mailType: "WORK_ORDER", status: "PENDING_CONFIRM", validShipmentCount: 2, }), ).toBe(false); expect( canEnterForecastConfirm({ mailType: "TRANSFER", status: "PENDING_CONFIRM", validShipmentCount: 2, }), ).toBe(false); }); it("A-9.1 未映射未 ack → ACK_UNMAPPED_REQUIRED", () => { const err = new ConfirmImportError( "ACK_UNMAPPED_REQUIRED", "存在未映射渠道,请确认后提交", ); expect(err.status).toBe(400); expect(err.code).toBe("ACK_UNMAPPED_REQUIRED"); }); it("A-9.5 accepted_count / shipments_hash 稳定(勾选顺序无关)", () => { expect(computeShipmentsHash("MATU2745683", [3, 1, 2])).toBe( computeShipmentsHash("MATU2745683", [1, 2, 3]), ); }); it("A-2.5 VERSION_CONFLICT 409", () => { const err = new ConfirmImportError("VERSION_CONFLICT", "版本冲突"); expect(err.status).toBe(409); }); it("A-2.1 ALREADY_IMPORTING 409", () => { const err = new ConfirmImportError("ALREADY_IMPORTING", "正在导入"); expect(err.status).toBe(409); }); }); describe("A-3 IMAP / A-4 CC 容错规则", () => { it("A-3.1 连续失败≥3 告警;≥5 指数退避上限 5min", () => { expect( isImapSyncAlert({ consecutiveFail: 2, lastError: null, lastSuccessAt: null, lastAttemptAt: null, lastProcessed: 0, updatedAt: "", }), ).toBe(false); expect( isImapSyncAlert({ consecutiveFail: 3, lastError: "timeout", lastSuccessAt: null, lastAttemptAt: null, lastProcessed: 0, updatedAt: "", }), ).toBe(true); expect(nextPollDelayMs(4, 60_000)).toBe(60_000); expect(nextPollDelayMs(20, 60_000)).toBe(5 * 60_000); }); it("A-3.2 / A-3.3 近 10 分钟新建视为模糊成功可收敛", () => { const now = Date.now(); expect(isRecentlyCreated(new Date(now - 60_000).toISOString(), now)).toBe( true, ); expect( isRecentlyCreated(new Date(now - 11 * 60_000).toISOString(), now), ).toBe(false); }); it("A-4.1 / A-4.5 登录 token 宽松解析;无法解析 → 空", () => { expect(extractLoginToken("abc-token")).toBe("abc-token"); expect(extractLoginToken({ token: "t1" })).toBe("t1"); expect(extractLoginToken({ baseinfo: { token: "t2" } })).toBe("t2"); expect(extractLoginToken({ foo: 1 })).toBe(""); expect(extractLoginToken(null)).toBe(""); }); it("A-4.x CC code 字符串可归一", () => { expect(normalizeCcCode("200")).toBe(200); expect(normalizeCcCode(410)).toBe(410); expect(normalizeCcCode(undefined)).toBe(0); }); }); describe("A-5 解析容错", () => { it("A-5.2 zip 一层可解;嵌套 zip 忽略", () => { const ok = new AdmZip(); ok.addFile("卡派.xlsx", Buffer.from("x")); const okRes = extractSpreadsheetFromZip(ok.toBuffer()); expect(okRes.files[0]?.filename).toBe("卡派.xlsx"); const inner = new AdmZip(); inner.addFile("a.xlsx", Buffer.from("x")); const outer = new AdmZip(); outer.addFile("nested.zip", inner.toBuffer()); const nested = extractSpreadsheetFromZip(outer.toBuffer()); expect(nested.warnings).toContain("zip.nested_ignored"); expect(nested.files).toHaveLength(0); }); it("A-5.2 路径穿越条目跳过或只留 basename", () => { const zip = new AdmZip(); zip.addFile("folder/../../evil.xlsx", Buffer.from("x")); const res = extractSpreadsheetFromZip(zip.toBuffer()); const skipped = res.warnings.includes("path_traversal_skipped"); const onlyBase = res.files.every((f) => !f.filename.includes("..")); expect(skipped || onlyBase).toBe(true); }); it("A-5.3 无「卡派」sheet → fallback 第一 sheet", async () => { const buf = await xlsx({ sheet: "Sheet1", headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"], data: [["ABQ2", "卡派", 1, 0.3, 5]], }); const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false }); expect(parsed.lineage.sheetFallback).toBe(true); expect(parsed.shipments[0].row_status).toBe("VALID"); }); it("A-5.5 硬限制 >hard → REJECTED_VALIDATION;软限仅标记", async () => { const buf = await xlsx({ headers: ["仓库ID", "渠道", "件数"], data: Array.from({ length: 8 }, (_, i) => [`W${i}`, "卡派", 1]), }); const hard = await parsePackingListBuffer(buf, { enableIsoCheck: false, hardLimit: 5, }); expect(hard.errorCode).toBe("REJECTED_VALIDATION"); expect(hard.shipments).toHaveLength(0); const soft = await parsePackingListBuffer(buf, { enableIsoCheck: false, softLimit: 3, hardLimit: 100, }); expect(soft.errorCode).toBeUndefined(); expect(soft.lineage.soft_limit_exceeded).toBe(true); expect(soft.shipments.length).toBe(8); }); it("A-5.6 正文柜号 ≠ 附件柜号 → 冲突规则", () => { const body = normalizeContainerNo("TCLU1234567"); const att = normalizeContainerNo("MATU2745683"); expect(body).not.toBe(att); }); it("A-5.8 重新解析允许 PARSE_FAILED/REJECTED/PARSED;禁止 SUCCESS/IMPORTING", () => { expect(canTransition("PARSE_FAILED", "PARSING")).toBe(true); expect(canTransition("REJECTED_VALIDATION", "PARSING")).toBe(true); expect(canTransition("PARSED", "PARSING")).toBe(true); expect(canTransition("FAILED", "PARSING")).toBe(true); expect(canTransition("SUCCESS", "PARSING")).toBe(false); expect(canTransition("PARTIAL_SUCCESS", "PARSING")).toBe(false); expect(canTransition("IMPORTING", "PARSING")).toBe(false); }); it("A-1.8 / A-5.x 附件名 sanitize 防穿越", () => { expect(sanitizeFilename("../../etc/passwd")).toBe("passwd"); expect(sanitizeFilename("a<>b.xlsx")).toBe("a__b.xlsx"); expect(sanitizeFilename("")).toBe("attachment"); }); }); describe("A-6 状态机 / A-7 幂等", () => { it("A-6.2 PARSING → FETCHED(stale reaper)", () => { expect(canTransition("PARSING", "FETCHED")).toBe(true); }); it("A-6.3 IMPORTING → FAILED(超时回收)", () => { expect(canTransition("IMPORTING", "FAILED")).toBe(true); expect(() => assertTransition("IMPORTING", "PENDING_CONFIRM")).toThrow(); }); it("A-6.6 非法迁移拒绝", () => { expect(() => assertTransition("SUCCESS", "PENDING_CONFIRM")).toThrow(); expect(canTransition("IGNORED", "PARSING")).toBe(false); }); it("A-7.3 hash 碰撞分叉:加盐后不等于 base", () => { const base = computeRawHash({ date: "2026-01-01T00:00:00.000Z", from: "a@b.com", subject: "x", filenames: ["a.xlsx"], }); expect(sha256(`${base}|acc|INBOX|99`)).not.toBe(base); }); it("A-7.4 Message-ID 超长截断 191(UNIQUE 列)", () => { const long = `<${"x".repeat(300)}@ex.com>`; expect(truncateMessageId(long)).toHaveLength(191); }); }); describe("A-9 计算 / A-10 权限", () => { beforeEach(() => { process.env.DATABASE_URL = process.env.DATABASE_URL || "mysql://app:app@localhost:7023/email_forecast"; process.env.SESSION_SECRET = process.env.SESSION_SECRET || "unit-test-session-secret-at-least-32-chars!!"; resetEnvCache(); resetRateLimitBuckets(); }); afterEach(() => { resetRateLimitBuckets(); }); it("A-9.2 浮点 round4", () => { expect(round4(0.12345)).toBe(0.1235); }); it("A-9.3 上海时区跨日", () => { expect(toShanghaiDateString(new Date("2026-07-12T16:30:00.000Z"))).toBe( "2026-07-13", ); }); it("A-10.1 错误口令拒绝;空 session 非 admin", () => { expect(verifyCredentials("admin", "wrong-password")).toBeNull(); expect(isAdmin({})).toBe(false); expect(isAdmin({ username: "ops", role: "ops" })).toBe(false); expect(isAdmin({ username: "admin", role: "admin" })).toBe(true); }); it("A-10.5 导入限流 10/分钟 → 第 11 次拒绝", () => { const key = "import:ops"; for (let i = 0; i < 10; i++) { expect(checkSlidingWindow(key, 10, 60_000)).toBe(true); } expect(checkSlidingWindow(key, 10, 60_000)).toBe(false); }); it("A-10.x 伪造 mail_id 非法 → parseBigIntId null", () => { expect(parseBigIntId("abc")).toBeNull(); expect(parseBigIntId("-1")).toBeNull(); expect(parseBigIntId("12")).toBe(12n); }); });