158 行
8.3 KiB
JavaScript
158 行
8.3 KiB
JavaScript
const { createHash } = require("node:crypto");
|
|
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
|
const { claimReceiptJob, runReceiptOcrOnce, startReceiptOcrJob, startReceiptPgBossJob } = require("../../../src/jobs/paymentReceiptOcrJob");
|
|
|
|
test("cron serializes ticks locally and removes timer on close", async () => {
|
|
const cron = require("node-cron");
|
|
let callback;
|
|
let onClose;
|
|
let release;
|
|
const task = { stop: vi.fn(), destroy: vi.fn() };
|
|
const schedule = vi.spyOn(cron, "schedule").mockImplementation((expression, handler) => { callback = handler; return task; });
|
|
const transaction = vi.fn(() => new Promise((resolve) => { release = resolve; }));
|
|
try {
|
|
startReceiptOcrJob({ config: { ocrPollCron: "* * * * *" }, prisma: { $transaction: transaction },
|
|
log: { warn: vi.fn() }, addHook: (name, handler) => { onClose = handler; } });
|
|
const running = callback();
|
|
await callback();
|
|
expect(transaction).toHaveBeenCalledTimes(1);
|
|
release(null);
|
|
await running;
|
|
await onClose();
|
|
expect(task.stop).toHaveBeenCalledTimes(1);
|
|
expect(task.destroy).toHaveBeenCalledTimes(1);
|
|
} finally { schedule.mockRestore(); }
|
|
});
|
|
|
|
test("pg-boss uses a separate receipt queue with no automatic request retry", async () => {
|
|
const boss = { createQueue: vi.fn(), work: vi.fn(), schedule: vi.fn() };
|
|
await startReceiptPgBossJob({ config: { ocrPollCron: "* * * * *", appTimezone: "Asia/Shanghai" } }, boss);
|
|
expect(boss.createQueue).toHaveBeenCalledWith("payment.receipt.ocr", { retryLimit: 0, expireInSeconds: 900 });
|
|
expect(boss.work).toHaveBeenCalledWith("payment.receipt.ocr", { batchSize: 1 }, expect.any(Function));
|
|
expect(boss.schedule.mock.calls[0][3]).toMatchObject({ retryLimit: 0, singletonKey: "payment.receipt.ocr" });
|
|
});
|
|
|
|
function fixture() {
|
|
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
|
const buffer = Buffer.from("synthetic-pdf");
|
|
const attachment = { id: "attachment", payment_batch_id: "batch", receipt_ocr_version: 1,
|
|
file_size: buffer.length, file_sha256: createHash("sha256").update(buffer).digest("hex"),
|
|
mime_type: "application/pdf", storage_path: "private-synthetic", governance_status: "approved" };
|
|
const tx = {
|
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "batch" }]),
|
|
paymentBatchReceiptAttachment: { update: vi.fn().mockResolvedValue(attachment), updateMany: vi.fn(),
|
|
findUnique: vi.fn().mockResolvedValue(attachment) },
|
|
paymentBatch: { findUnique: vi.fn().mockResolvedValue({ id: "batch", status: "reviewed", entity_type: "reimbursement",
|
|
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, "000011112222") },
|
|
items: [{ id: "item", status: "pending", amount: "10.00", currency_code: "CNY",
|
|
snapshot_json: { payee: { bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, "000033334444") } } }] }) },
|
|
paymentReceiptVerification: { create: vi.fn().mockResolvedValue({ id: "verification", status: "needs_review" }) },
|
|
};
|
|
const prisma = { $transaction: vi.fn((callback) => callback(tx)) };
|
|
const readBuffer = vi.fn().mockResolvedValue(buffer);
|
|
const recognize = vi.fn().mockResolvedValue({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [] });
|
|
return { prisma, config, readBuffer, recognize, tx, attachment };
|
|
}
|
|
|
|
test("idle workers do not read files or call OCR", async () => {
|
|
const args = fixture();
|
|
args.tx.$queryRaw.mockResolvedValue([]);
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "idle" });
|
|
expect(args.readBuffer).not.toHaveBeenCalled();
|
|
expect(args.recognize).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
{ entity_type: "travel_order", default_payment_channel: "bank", items: [{ status: "pending" }] },
|
|
{ entity_type: "reimbursement", default_payment_channel: "cash", items: [{ status: "pending" }] },
|
|
{ entity_type: "reimbursement", items: [{ status: "executed" }] },
|
|
])("out-of-scope queued receipts never read files or call OCR: %j", async (batch) => {
|
|
const args = fixture();
|
|
args.tx.paymentBatch.findUnique.mockResolvedValue({ id: "batch", ...batch });
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "idle" });
|
|
expect(args.readBuffer).not.toHaveBeenCalled();
|
|
expect(args.recognize).not.toHaveBeenCalled();
|
|
expect(args.tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
|
expect(args.tx.paymentBatchReceiptAttachment.updateMany).toHaveBeenCalledWith({
|
|
where: { payment_batch_id: "batch", receipt_ocr_state: "queued" }, data: { receipt_ocr_state: "legacy" },
|
|
});
|
|
});
|
|
|
|
test("claim marks processing and increments version in the transaction", async () => {
|
|
const args = fixture();
|
|
await claimReceiptJob(args.prisma);
|
|
expect(args.tx.paymentBatchReceiptAttachment.update.mock.calls[0][0].data)
|
|
.toMatchObject({ receipt_ocr_state: "processing", receipt_ocr_version: { increment: 1 } });
|
|
});
|
|
|
|
test("successful processing stores a result without raw file values in response", async () => {
|
|
const args = fixture();
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "needs_review" });
|
|
expect(args.recognize).toHaveBeenCalledTimes(1);
|
|
expect(args.tx.paymentReceiptVerification.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("file mismatch prevents external transmission", async () => {
|
|
const args = fixture();
|
|
args.attachment.file_sha256 = "a".repeat(64);
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "ocr_failed" });
|
|
expect(args.recognize).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("provider errors are not leaked or automatically retried", async () => {
|
|
const args = fixture();
|
|
args.recognize.mockRejectedValue(new Error("sensitive-provider-value"));
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "ocr_failed" });
|
|
expect(args.recognize).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("superseded result cannot update job state", async () => {
|
|
const args = fixture();
|
|
args.tx.paymentBatchReceiptAttachment.findUnique.mockResolvedValue({ ...args.attachment, receipt_ocr_version: 2 });
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "discarded" });
|
|
expect(args.tx.paymentReceiptVerification.create).not.toHaveBeenCalled();
|
|
expect(args.tx.paymentBatchReceiptAttachment.update).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test.each([
|
|
["receipt_key_unavailable", "key_failed"],
|
|
["receipt_bank_unsupported", "snapshot_failed"],
|
|
["unknown", "system_failed"],
|
|
])("persistence error %s records safe terminal state %s", async (code, state) => {
|
|
const args = fixture();
|
|
args.tx.paymentReceiptVerification.create.mockRejectedValue(Object.assign(new Error("sensitive"), { code }));
|
|
args.tx.paymentBatchReceiptAttachment.updateMany.mockResolvedValue({ count: 1 });
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: state });
|
|
expect(args.tx.paymentBatchReceiptAttachment.updateMany).toHaveBeenCalledWith({
|
|
where: { id: args.attachment.id, payment_batch_id: args.attachment.payment_batch_id,
|
|
receipt_ocr_version: 1, file_sha256: args.attachment.file_sha256,
|
|
receipt_ocr_state: "processing", archived_at: null, governance_status: "approved" },
|
|
data: { receipt_ocr_state: state },
|
|
});
|
|
expect(args.recognize).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("missing key is persisted before any file read or cloud call", async () => {
|
|
const args = fixture();
|
|
args.config = {};
|
|
args.tx.paymentBatchReceiptAttachment.updateMany.mockResolvedValue({ count: 1 });
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "key_failed" });
|
|
expect(args.readBuffer).not.toHaveBeenCalled();
|
|
expect(args.recognize).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("late failure cannot overwrite a changed attachment", async () => {
|
|
const args = fixture();
|
|
args.tx.paymentReceiptVerification.create.mockRejectedValue(new Error("synthetic"));
|
|
args.tx.paymentBatchReceiptAttachment.updateMany.mockResolvedValue({ count: 0 });
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "discarded" });
|
|
});
|
|
|
|
test("failure recording outage is reported without claiming persistence", async () => {
|
|
const args = fixture();
|
|
args.tx.paymentReceiptVerification.create.mockRejectedValue(new Error("sensitive"));
|
|
args.tx.paymentBatchReceiptAttachment.updateMany.mockRejectedValue(new Error("sensitive-db"));
|
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "failure_recording_failed" });
|
|
expect(args.recognize).toHaveBeenCalledTimes(1);
|
|
});
|