You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

165 lines
5.7 KiB

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");
});
});