229 行
6.9 KiB
JavaScript
229 行
6.9 KiB
JavaScript
const path = require("node:path");
|
|
const { pathToFileURL } = require("node:url");
|
|
const { spawnSync } = require("node:child_process");
|
|
|
|
const ROOT_DIR = path.resolve(__dirname, "../../../..");
|
|
const SCRIPT_PATH = path.join(ROOT_DIR, "scripts/qa/check-ocr-provider-samples.mjs");
|
|
const OCR_PDF_MAX_BYTES = 7 * 1024 * 1024;
|
|
|
|
function syntheticPdf(body = "synthetic") {
|
|
return Buffer.from(`%PDF-1.7\n${body}\n%%EOF\n`, "ascii");
|
|
}
|
|
|
|
function providerConfig() {
|
|
return {
|
|
ocrProvider: "tencent",
|
|
tencentSecretId: "synthetic-id",
|
|
tencentSecretKey: "synthetic-key",
|
|
tencentOcrRegion: "ap-beijing",
|
|
ocrTimeoutMs: 5000,
|
|
};
|
|
}
|
|
|
|
function normalizedResult(overrides = {}) {
|
|
return {
|
|
documentSubtype: "VatElectronicCommonInvoice",
|
|
documentCategory: "vat_invoice",
|
|
documentCount: 1,
|
|
invoiceCode: "synthetic-code",
|
|
invoiceNumber: "synthetic-number",
|
|
invoiceDate: "2026-01-01",
|
|
amount: "1.00",
|
|
taxAmount: "0.01",
|
|
totalAmount: "1.01",
|
|
sellerName: "synthetic-seller",
|
|
buyerName: "synthetic-buyer",
|
|
transportMode: "pdf_native",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("check-ocr-provider-samples", () => {
|
|
let liveRunner;
|
|
|
|
beforeAll(async () => {
|
|
liveRunner = await import(pathToFileURL(SCRIPT_PATH).href);
|
|
});
|
|
|
|
it.each([
|
|
["vat_invoice", normalizedResult()],
|
|
[
|
|
"full_vat_invoice",
|
|
normalizedResult({
|
|
documentSubtype: "VatElectronicInvoiceFull",
|
|
documentCategory: "full_vat_invoice",
|
|
invoiceCode: null,
|
|
}),
|
|
],
|
|
[
|
|
"electronic_train_ticket",
|
|
normalizedResult({
|
|
documentSubtype: "ElectronicTrainTicketFull",
|
|
documentCategory: "electronic_train_ticket",
|
|
invoiceCode: null,
|
|
sellerName: null,
|
|
}),
|
|
],
|
|
])("accepts category-aware required fields for %s", async (_category, result) => {
|
|
const runOcr = vi.fn().mockResolvedValue(result);
|
|
|
|
const summary = await liveRunner.runLiveSampleCheck({
|
|
alias: "SAMPLE-PDF-TEST",
|
|
buffer: syntheticPdf(),
|
|
config: providerConfig(),
|
|
runOcr,
|
|
now: vi.fn().mockReturnValueOnce(100).mockReturnValueOnce(125),
|
|
});
|
|
|
|
expect(summary.status).toBe("PASS");
|
|
expect(summary.elapsedMs).toBe(25);
|
|
expect(summary.documentCategory).toBe(result.documentCategory);
|
|
expect(summary.missingRequiredFields).toEqual([]);
|
|
expect(runOcr).toHaveBeenCalledTimes(1);
|
|
expect(runOcr.mock.calls[0][0]).toMatchObject({
|
|
ocrType: "vat",
|
|
sourceMimeType: "application/pdf",
|
|
});
|
|
});
|
|
|
|
it("holds a railway ticket when its buyer field is missing", async () => {
|
|
const summary = await liveRunner.runLiveSampleCheck({
|
|
alias: "SAMPLE-PDF-TRAIN",
|
|
buffer: syntheticPdf(),
|
|
config: providerConfig(),
|
|
runOcr: vi.fn().mockResolvedValue(
|
|
normalizedResult({
|
|
documentSubtype: "ElectronicTrainTicketFull",
|
|
documentCategory: "electronic_train_ticket",
|
|
invoiceCode: null,
|
|
sellerName: null,
|
|
buyerName: null,
|
|
}),
|
|
),
|
|
});
|
|
|
|
expect(summary.status).toBe("HOLD");
|
|
expect(summary.missingRequiredFields).toEqual(["buyerName"]);
|
|
});
|
|
|
|
it("exposes only boolean railway amount diagnostics", async () => {
|
|
const summary = await liveRunner.runLiveSampleCheck({
|
|
alias: "SAMPLE-PDF-TRAIN-DIAGNOSTIC",
|
|
buffer: syntheticPdf(),
|
|
config: providerConfig(),
|
|
runOcr: vi.fn().mockResolvedValue(
|
|
normalizedResult({
|
|
documentSubtype: "ElectronicTrainTicketFull",
|
|
documentCategory: "electronic_train_ticket",
|
|
invoiceCode: null,
|
|
totalAmount: null,
|
|
sellerName: null,
|
|
diagnostics: {
|
|
trainAmountFields: {
|
|
total: {
|
|
present: true,
|
|
isString: true,
|
|
isNumber: false,
|
|
nonEmpty: true,
|
|
parseable: false,
|
|
value: "forbidden-total-marker",
|
|
},
|
|
fare: {
|
|
present: false,
|
|
isString: false,
|
|
isNumber: false,
|
|
nonEmpty: false,
|
|
parseable: false,
|
|
value: "forbidden-fare-marker",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
});
|
|
const serialized = JSON.stringify(summary);
|
|
|
|
expect(summary.status).toBe("HOLD");
|
|
expect(summary.trainAmountDiagnostics).toEqual({
|
|
totalPresent: true,
|
|
totalIsString: true,
|
|
totalIsNumber: false,
|
|
totalNonEmpty: true,
|
|
totalParseable: false,
|
|
farePresent: false,
|
|
fareIsString: false,
|
|
fareIsNumber: false,
|
|
fareNonEmpty: false,
|
|
fareParseable: false,
|
|
});
|
|
expect(serialized).not.toContain("forbidden-total-marker");
|
|
expect(serialized).not.toContain("forbidden-fare-marker");
|
|
});
|
|
|
|
it("never exposes provider raw fields or normalized business values", async () => {
|
|
const summary = await liveRunner.runLiveSampleCheck({
|
|
alias: "SAMPLE-PDF-PRIVACY",
|
|
buffer: syntheticPdf(),
|
|
config: providerConfig(),
|
|
runOcr: vi.fn().mockResolvedValue(
|
|
normalizedResult({
|
|
raw: {
|
|
passengerIdentity: "forbidden-identity-marker",
|
|
response: "forbidden-response-marker",
|
|
},
|
|
}),
|
|
),
|
|
});
|
|
const serialized = JSON.stringify(summary);
|
|
|
|
expect(serialized).not.toContain("forbidden-identity-marker");
|
|
expect(serialized).not.toContain("forbidden-response-marker");
|
|
expect(serialized).not.toContain("synthetic-number");
|
|
expect(serialized).not.toContain("synthetic-seller");
|
|
expect(summary.fieldHits.invoiceNumber).toBe(true);
|
|
expect(summary.fieldHits.sellerName).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
["non-pdf", Buffer.from("not a pdf", "ascii"), "sample_pdf_invalid"],
|
|
["truncated", Buffer.from("%PDF-1.7\nmissing eof", "ascii"), "sample_pdf_invalid"],
|
|
["oversized", Buffer.alloc(OCR_PDF_MAX_BYTES + 1, 0x20), "sample_too_large"],
|
|
])("rejects %s before provider invocation", async (_caseName, buffer, errorCode) => {
|
|
const runOcr = vi.fn();
|
|
|
|
await expect(
|
|
liveRunner.runLiveSampleCheck({
|
|
alias: "SAMPLE-PDF-TEST",
|
|
buffer,
|
|
config: providerConfig(),
|
|
runOcr,
|
|
}),
|
|
).rejects.toMatchObject({ code: errorCode });
|
|
expect(runOcr).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("consumes one stdin PDF and returns only a safe config error without credentials", () => {
|
|
const result = spawnSync(process.execPath, [SCRIPT_PATH, "--alias=SAMPLE-PDF-STDIN"], {
|
|
cwd: ROOT_DIR,
|
|
env: {
|
|
...process.env,
|
|
TENCENT_SECRET_ID: "",
|
|
TENCENT_SECRET_KEY: "",
|
|
},
|
|
input: syntheticPdf(),
|
|
encoding: "utf8",
|
|
});
|
|
const summary = JSON.parse(result.stdout.trim());
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toBe("");
|
|
expect(summary).toEqual({
|
|
alias: "SAMPLE-PDF-STDIN",
|
|
provider: "tencent",
|
|
status: "HOLD",
|
|
transportMode: "pdf_native",
|
|
errorCode: "ocr_credentials_missing",
|
|
});
|
|
});
|
|
});
|