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.

247 lines
9.9 KiB

import { randomUUID } from "crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { prisma } from "@/services/db";
import { ConfirmImport } from "@/services/import/confirm";
import { drainFetchedMails } from "@/services/parse/drain-fetched";
import { getMockTargetAPI } from "@/services/cc/mock-target-api";
import { ImapPoller } from "@/services/imap/poller";
import { getMockMailServer } from "@/services/imap/mock-mail-server";
import { loadEmailFixture, loadAllEmailFixtures } from "@/services/test/email-fixture-library";
import { enableTestModeEnv, disableTestModeEnv } from "@/services/test/test-mode-env";
import {
deleteTestMailsByRunId,
ingestFixtureAndParse,
ingestFixtureFetched,
loadParsedMail,
pingTestDatabase,
} from "@/services/test/db-harness";
import type { ShipmentRow } from "@/types/mail";
function uniqueContainer(): string {
return `TPZU${String(Date.now()).slice(-7)}`;
}
async function confirmForecast(
mailId: bigint,
version: number,
indexes: number[],
containerNo?: string,
) {
return ConfirmImport.execute({
mailId,
actor: "p3-test",
version,
idempotencyKey: randomUUID(),
F_TransMode: 0,
F_OperationType: 0,
containerHeader: containerNo ? { F_ContainerNo: containerNo } : {},
selectedRowIndexes: indexes,
ackUnmappedChannels: true,
forceSkipConflict: true,
});
}
const RUN_ID = `r${Date.now().toString(36)}`;
describe("pipeline integration (DB + Mock IMAP/CC)", () => {
let dbOk = false;
beforeAll(async () => {
enableTestModeEnv();
dbOk = await pingTestDatabase();
});
afterAll(async () => {
if (dbOk) await deleteTestMailsByRunId(RUN_ID);
disableTestModeEnv();
});
it("ParsePipeline vs expected.json for pullable fixtures", async () => {
if (!dbOk) return;
const all = await loadAllEmailFixtures();
for (const f of all) {
if (f.expected.pull === false) continue;
if (f.expected.idempotent_with) continue;
const { mailId } = await ingestFixtureAndParse(f, RUN_ID);
const mail = await loadParsedMail(mailId);
const expectType = f.expected.pipeline_mail_type || f.expected.mail_type;
expect(mail.mailType, f.id).toBe(expectType);
expect(mail.status, f.id).toBe(f.expected.status);
if (f.expected.last_error === "missing_columns") {
expect(mail.lastError, f.id).toMatch(/^missing_columns/);
} else {
expect(mail.lastError, f.id).toBe(f.expected.last_error);
}
if (f.expected.status === "PARSE_FAILED" || f.expected.status === "REJECTED_VALIDATION") {
const raw = mail.snapshotPath;
expect(raw, `${f.id} keeps snapshot`).toBeTruthy();
}
expect(mail.traceId, `${f.id} has traceId`).toBeTruthy();
const shipments = (mail.parseResult?.shipments as unknown as ShipmentRow[]) || [];
if (f.expected.shipment_count != null && mail.parseResult) {
expect(shipments.length, f.id).toBe(f.expected.shipment_count);
}
}
}, 30_000);
it("parse fail on one mail does not block drain of another", async () => {
if (!dbOk) return;
const bad = await loadEmailFixture("12-missing-required");
const good = await loadEmailFixture("01-normal-forecast");
const a = await ingestFixtureFetched(bad, RUN_ID, "drain-bad");
const b = await ingestFixtureFetched(good, RUN_ID, "drain-good");
await drainFetchedMails(20);
const mailA = await loadParsedMail(a.mailId);
const mailB = await loadParsedMail(b.mailId);
expect(mailA.status).toBe("PARSE_FAILED");
expect(mailA.lastError).toMatch(/missing_columns/);
expect(mailB.status).toBe("PENDING_CONFIRM");
});
it("unknown format keyword miss stays IGNORED (not pulled into ops queue)", async () => {
if (!dbOk) return;
const f = await loadEmailFixture("16-unknown-format");
expect(f.expected.status).toBe("IGNORED");
expect(f.expected.last_error).toBe("SKIP_FILTER");
});
it("MockMailServer → parse → stage → MockTargetAPI SaveContainer payload matches source", async () => {
if (!dbOk) return;
const f = await loadEmailFixture("01-normal-forecast");
const { mailId } = await ingestFixtureAndParse(f, `${RUN_ID}.cc`);
const mail = await loadParsedMail(mailId);
expect(mail.status).toBe("PENDING_CONFIRM");
const shipments = (mail.parseResult?.shipments as unknown as ShipmentRow[]) || [];
const indexes = shipments.filter((s) => s.row_status === "VALID").map((s) => s.row_index);
const cn = uniqueContainer();
getMockTargetAPI().reset();
const result = await confirmForecast(mailId, mail.version, indexes, cn);
expect(result.mailStatus).toBe("SUCCESS");
const posts = getMockTargetAPI()
.getRequests()
.filter((r) => r.path === "/Container/SaveContainer");
expect(posts.length).toBeGreaterThanOrEqual(1);
const body = JSON.stringify(posts[0].body);
expect(body).toContain(cn);
expect(body).toContain("LAS1");
});
it("duplicate submit is idempotent (mailId+container+hash), no second CC write", async () => {
if (!dbOk) return;
const f = await loadEmailFixture("01-normal-forecast");
const { mailId } = await ingestFixtureAndParse(f, `${RUN_ID}.dup`);
const mail = await loadParsedMail(mailId);
const shipments = (mail.parseResult?.shipments as unknown as ShipmentRow[]) || [];
const indexes = shipments.filter((s) => s.row_status === "VALID").map((s) => s.row_index);
const cn = uniqueContainer();
getMockTargetAPI().reset();
const first = await confirmForecast(mailId, mail.version, indexes, cn);
expect(first.mailStatus).toBe("SUCCESS");
const after = await prisma.mailMessage.findUniqueOrThrow({
where: { id: mailId },
select: { version: true, status: true },
});
getMockTargetAPI().reset();
const second = await confirmForecast(mailId, after.version, indexes, cn);
expect(second.mailStatus).toBe("SUCCESS");
expect(
getMockTargetAPI().getRequests().filter((r) => r.path === "/Container/SaveContainer"),
).toHaveLength(0);
});
it("submit 401 leaves mail FAILED, not SUCCESS", async () => {
if (!dbOk) return;
const f = await loadEmailFixture("01-normal-forecast");
const { mailId } = await ingestFixtureAndParse(f, `${RUN_ID}.401`);
const mail = await loadParsedMail(mailId);
const shipments = (mail.parseResult?.shipments as unknown as ShipmentRow[]) || [];
const indexes = shipments.filter((s) => s.row_status === "VALID").map((s) => s.row_index);
getMockTargetAPI().reset();
getMockTargetAPI().setFault("401", "/Container/SaveContainer");
const result = await confirmForecast(
mailId,
mail.version,
indexes,
uniqueContainer(),
);
expect(result.mailStatus).toBe("FAILED");
expect(result.imports[0].status).toBe("FAILED");
expect(result.imports[0].lastError).toMatch(/登录|权限|CarrierCentral/);
const comps = await prisma.importCompensation.findMany({
where: { importId: result.imports[0].id },
});
expect(comps).toHaveLength(0);
});
it("submit timeout creates compensation for one-click retry", async () => {
if (!dbOk) return;
const f = await loadEmailFixture("01-normal-forecast");
const { mailId } = await ingestFixtureAndParse(f, `${RUN_ID}.to`);
const mail = await loadParsedMail(mailId);
const shipments = (mail.parseResult?.shipments as unknown as ShipmentRow[]) || [];
const indexes = shipments.filter((s) => s.row_status === "VALID").map((s) => s.row_index);
getMockTargetAPI().reset();
getMockTargetAPI().setFault("timeout", "/Container/SaveContainer");
const result = await confirmForecast(
mailId,
mail.version,
indexes,
uniqueContainer(),
);
expect(result.mailStatus).toBe("FAILED");
expect(result.imports[0].status).toBe("TIMEOUT_UNKNOWN");
const comps = await prisma.importCompensation.findMany({
where: { importId: result.imports[0].id, reason: "TIMEOUT_UNKNOWN" },
});
expect(comps.length).toBe(1);
expect(comps[0].status).toBe("OPEN");
});
it("submit 500 creates CC_5XX compensation, mail not SUCCESS", async () => {
if (!dbOk) return;
const f = await loadEmailFixture("01-normal-forecast");
const { mailId } = await ingestFixtureAndParse(f, `${RUN_ID}.500`);
const mail = await loadParsedMail(mailId);
const shipments = (mail.parseResult?.shipments as unknown as ShipmentRow[]) || [];
const indexes = shipments.filter((s) => s.row_status === "VALID").map((s) => s.row_index);
getMockTargetAPI().reset();
getMockTargetAPI().setFault("500", "/Container/SaveContainer");
const result = await confirmForecast(
mailId,
mail.version,
indexes,
uniqueContainer(),
);
expect(result.mailStatus).toBe("FAILED");
expect(result.imports[0].status).toBe("FAILED");
const comps = await prisma.importCompensation.findMany({
where: { importId: result.imports[0].id, reason: "CC_5XX" },
});
expect(comps.length).toBe(1);
expect(comps[0].status).toBe("OPEN");
getMockTargetAPI().setFault("ok");
});
it("IMAP auth_fail: tick does not throw and records consecutiveFail", async () => {
if (!dbOk) return;
const statusPath = `${process.cwd().replace(/\\/g, "/")}/data/imap-runtime.json`;
const fs = await import("fs/promises");
const backup = await fs.readFile(statusPath, "utf8").catch(() => null);
try {
getMockMailServer().setFault("auth_fail");
const poller = ImapPoller.create();
await expect(poller.tick(1)).resolves.toMatchObject({ skipped: false });
const { readImapRuntimeStatus } = await import(
"@/services/imap/runtime-status"
);
const runtime = await readImapRuntimeStatus();
expect(runtime.consecutiveFail).toBeGreaterThanOrEqual(1);
expect(runtime.lastError).toMatch(/AUTH|鉴权|同步失败|authentication/i);
} finally {
getMockMailServer().setFault("none");
if (backup != null) await fs.writeFile(statusPath, backup);
else await fs.rm(statusPath, { force: true });
}
});
});