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