import fs from "fs/promises"; import path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { formatMailLastError } from "@/constants/ui-copy"; import { nextPollDelayMs, readImapRuntimeStatus, recordImapPollFailure, recordImapPollSuccess, } from "@/services/imap/runtime-status"; import { getEmittedAlerts, resetAlertState } from "@/services/alert/webhook"; import { getMockMailServer } from "@/services/imap/mock-mail-server"; import { getMockTargetAPI } from "@/services/cc/mock-target-api"; import { saveContainer, buildSaveContainerEntity } from "@/services/cc/save-container"; import { enableTestModeEnv, disableTestModeEnv } from "@/services/test/test-mode-env"; import type { ShipmentRow } from "@/types/mail"; const STATUS_PATH = path.join(process.cwd(), "data", "imap-runtime.json"); const ENTITY = buildSaveContainerEntity( { F_ContainerNo: "WHSU8127240", container_no_valid: true }, { F_TransMode: 0, F_OperationType: 0, F_ContainerNo: "WHSU8127240" }, ); const ROW: ShipmentRow = { row_index: 0, row_status: "VALID", F_FBACode: "LAS1", F_Transporter: "TRUCK", F_CTNS: 10, }; describe("fault matrix (current production behavior)", () => { beforeEach(() => { enableTestModeEnv(); }); afterEach(() => { disableTestModeEnv(); }); it("pull auth_fail: Mock IMAP throws AUTHENTICATIONFAILED", async () => { const server = getMockMailServer(); server.setFault("auth_fail"); await expect(server.createClient({ host: "mock.local", port: 993, user: "t", pass: "t", connectTimeoutMs: 1000, readTimeoutMs: 1000, }).connect()).rejects.toThrow(/AUTHENTICATIONFAILED/); }); it("pull timeout: Mock IMAP throws ETIMEOUT", async () => { const server = getMockMailServer(); server.setFault("timeout"); await expect( server.createClient({ host: "mock.local", port: 993, user: "t", pass: "t", connectTimeoutMs: 1000, readTimeoutMs: 1000, }).connect(), ).rejects.toMatchObject({ code: "ETIMEOUT" }); }); it("pull timeout backoff: consecutiveFail>=5 exponential, cap 5min", () => { expect(nextPollDelayMs(4, 60_000)).toBe(60_000); expect(nextPollDelayMs(5, 60_000)).toBe(120_000); expect(nextPollDelayMs(6, 60_000)).toBe(240_000); expect(nextPollDelayMs(10, 60_000)).toBe(5 * 60_000); }); it("pull failure is recorded with lastError (runtime-status file)", async () => { let backup: string | null = null; try { backup = await fs.readFile(STATUS_PATH, "utf8").catch(() => null); await recordImapPollSuccess(0); await recordImapPollFailure(new Error("AUTHENTICATIONFAILED: Invalid credentials")); const status = await readImapRuntimeStatus(); expect(status.consecutiveFail).toBe(1); expect(status.lastError).toMatch(/AUTHENTICATIONFAILED/); } finally { if (backup != null) await fs.writeFile(STATUS_PATH, backup); else await fs.rm(STATUS_PATH, { force: true }); } }); it("3 consecutive IMAP fails emit alert", async () => { let backup: string | null = null; try { backup = await fs.readFile(STATUS_PATH, "utf8").catch(() => null); resetAlertState(); await recordImapPollSuccess(0); await recordImapPollFailure(new Error("fail-1")); await recordImapPollFailure(new Error("fail-2")); expect(getEmittedAlerts()).toHaveLength(0); await recordImapPollFailure(new Error("fail-3")); expect(getEmittedAlerts().some((a) => a.key === "imap.consecutive_fail")).toBe( true, ); } finally { resetAlertState(); if (backup != null) await fs.writeFile(STATUS_PATH, backup); else await fs.rm(STATUS_PATH, { force: true }); } }); it("UI maps last_error codes to visible copy", () => { expect(formatMailLastError("NO_SHIPMENT_ROWS")).toMatch(/未解析到货件/); expect(formatMailLastError("NO_SUPPORTED_SPREADSHEET")).toMatch(/表格附件/); expect(formatMailLastError("missing_columns:仓库ID")).toMatch(/缺列/); expect(formatMailLastError("Unauthorized")).toMatch(/CarrierCentral|权限|登录/); }); it("submit 401: SaveContainer not ok, status FAILED, no timeout compensation", async () => { getMockTargetAPI().setFault("401", "/Container/SaveContainer"); const res = await saveContainer({ entity: ENTITY, shipments: [ROW], }); expect(res.ok).toBe(false); if (res.ok) return; expect(res.status).toBe("FAILED"); expect(res.error).toMatch(/登录|权限|CarrierCentral/); }); it("submit 500: SaveContainer FAILED (compensation only on TIMEOUT_UNKNOWN)", async () => { getMockTargetAPI().setFault("500", "/Container/SaveContainer"); const res = await saveContainer({ entity: ENTITY, shipments: [ROW], }); expect(res.ok).toBe(false); if (res.ok) return; expect(res.status).toBe("FAILED"); expect(res.error).toMatch(/服务器错误|CarrierCentral/); }); it("submit timeout: SaveContainer TIMEOUT_UNKNOWN", async () => { getMockTargetAPI().setFault("timeout", "/Container/SaveContainer"); const res = await saveContainer({ entity: ENTITY, shipments: [ROW], }); expect(res.ok).toBe(false); if (res.ok) return; expect(res.status).toBe("TIMEOUT_UNKNOWN"); expect(res.error).toMatch(/超时/); }); it("submit partial: SaveContainer treats object data as missing external id → TIMEOUT_UNKNOWN", async () => { getMockTargetAPI().setFault("partial", "/Container/SaveContainer"); const res = await saveContainer({ entity: ENTITY, shipments: [ROW], }); expect(res.ok).toBe(false); if (res.ok) return; expect(res.status).toBe("TIMEOUT_UNKNOWN"); }); });