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