Files
dingMCP/test/invoice-dedup.test.ts
T
gitea-admin 504145aec6
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
feat: initial public release
2026-08-17 03:04:09 +00:00

252 lines
14 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdtempSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import test from "node:test";
import type { AppConfig } from "../src/config.js";
import type { ApprovalService } from "../src/approval-service.js";
import {
extractInvoiceEvidence,
InvoiceDeduplicationService,
InvoiceDedupStore,
} from "../src/invoice-dedup.js";
import type { XftApprovalProvider } from "../src/xft.js";
import { UnifiedApprovalService } from "../src/unified-approval.js";
const secret = "invoice-dedup-test-secret-with-at-least-32-bytes";
function config(): AppConfig {
return {
clientId: "client", clientSecret: "secret", apiBaseUrl: "https://api.test", oapiBaseUrl: "https://oapi.test",
publicBaseUrl: "http://localhost:3000", trustedLocalUser: { userId: "user-1" }, port: 3000,
requestTimeoutMs: 1000, officialLookbackDays: 120, officialScanConcurrency: 2,
officialClientOnlyProcessCodes: [], batchLimit: 20, approvalRefSecret: secret,
};
}
test("invoice evidence recognizes exact aliases and avoids unrelated sensitive numbers", () => {
const evidence = extractInvoiceEvidence({
invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321", invoiceDate: "2026-08-01" }],
certificate: { invoiceCloudCertificates: [{ digitalInvoiceNumber: "25312000000012345678" }] },
formFields: [
{ label: "发票代码", value: "044031900222" },
{ label: "发票号码", value: "11223344" },
{ label: "银行卡号", value: "6222021234567890" },
{ label: "身份证号", value: "440301199001011234" },
],
expenseList: [{ amount: 88.5, invoiceDate: "2026-08-01", merchantName: "深圳测试商户" }],
});
assert.ok(evidence.some((item) => item.kind === "invoice_code_number" && item.normalized.includes("87654321")));
assert.ok(evidence.some((item) => item.kind === "invoice_code_number" && item.normalized.includes("11223344")));
assert.ok(evidence.some((item) => item.kind === "digital_invoice_number"));
assert.ok(evidence.some((item) => item.kind === "expense_signature"));
assert.equal(evidence.some((item) => item.normalized.includes("6222021234567890")), false);
assert.equal(evidence.some((item) => item.normalized.includes("440301199001011234")), false);
assert.ok(evidence.every((item) => !item.maskedDisplay.includes("87654321")));
});
test("invoice store persists only HMAC fingerprints and isolates users across restart", () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-dedup-"));
const path = join(root, "dedup.sqlite");
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
const first = new InvoiceDedupStore(path, secret);
const userOne = first.userHash("private-user-one");
first.indexDetail(userOne, "xft", "business-1", "pending", "2026-08-01T01:00:00.000Z", evidence);
assert.ok(first.findMatches(userOne, evidence).length >= 1);
assert.equal(first.findMatches(first.userHash("private-user-two"), evidence).length, 0);
const restarted = new InvoiceDedupStore(path, secret);
assert.ok(restarted.findMatches(restarted.userHash("private-user-one"), evidence).length >= 1);
const databaseBytes = readFileSync(path).toString("latin1");
assert.doesNotMatch(databaseBytes, /044031900111|87654321|private-user-one/);
});
test("invoice fingerprints expire automatically after the bounded retention window", () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-retention-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
const userHash = store.userHash("user-1");
const now = Date.now();
store.indexDetail(userHash, "xft", "expired-business", "approved", new Date(now - 36 * 86_400_000).toISOString(), evidence);
store.indexDetail(userHash, "xft", "retained-business", "approved", new Date(now - 34 * 86_400_000).toISOString(), evidence);
assert.equal(store.findMatches(userHash, evidence).length, 4);
store.cleanupExpired(now);
const retained = store.findMatches(userHash, evidence);
assert.equal(retained.length, 2);
assert.ok(retained.every((item) => item.business_no === "retained-business"));
const expiredReservation = "expired-reservation";
assert.equal(store.reserve(userHash, evidence, expiredReservation, now - 1), true);
store.cleanupExpired(now);
assert.equal(store.reserve(userHash, evidence, "replacement-reservation", now + 60_000), true);
});
test("dedup preparation blocks historical duplicates and concurrent confirmations", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-prepare-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const detailCalls: string[] = [];
let observedRange: { startTime: number; endTime: number } | undefined;
const ding = {
async listMyHistory(_userId: string, _scope: string, _limit: number, range: { startTime: number; endTime: number }) {
observedRange = range;
return { items: [{
source: "official_oa", historyScope: "submitted", processInstanceId: "oa-existing",
status: "COMPLETED", result: "agree", submittedAt: "2026-08-01T01:00:00.000Z",
}], diagnostics: {} };
},
async getHistoryDetail() {
detailCalls.push("dingtalk");
return { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
},
} as unknown as ApprovalService;
const xft = {
async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; },
} as unknown as XftApprovalProvider;
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
const user = { userId: "user-1" };
const values = { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
const first = await service.prepare(user, values, false, Date.now() + 600_000);
assert.equal(first.duplicateCheck.status, "confirmation_required");
assert.equal(first.duplicateCheck.matches[0]?.source, "dingtalk");
assert.equal(first.duplicateCheck.matches[0]?.disposition, "blocking");
assert.equal(first.reservationId, undefined);
assert.ok(observedRange);
assert.ok(Math.abs((observedRange!.endTime - observedRange!.startTime) - 30 * 86_400_000) < 1_000);
const confirmed = await service.prepare(user, values, true, Date.now() + 600_000);
assert.equal(confirmed.duplicateCheck.status, "proceeding_after_confirmation");
assert.ok(confirmed.reservationId);
service.assertReservation(user, confirmed.reservationId);
const concurrent = await service.prepare(user, values, true, Date.now() + 600_000);
assert.equal(concurrent.reservationId, undefined);
assert.equal(concurrent.duplicateCheck.requiresUserConfirmation, true);
assert.match(concurrent.duplicateCheck.message, /另一个尚未过期的提单确认/);
assert.equal(detailCalls.length, 1, "already indexed history details are not downloaded again");
});
test("incomplete cross-source coverage is explicit and requires an override", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-partial-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const ding = { async listMyHistory() { return { items: [], diagnostics: {} }; } } as unknown as ApprovalService;
const xft = { async listMyHistory() { throw new Error("temporary XFT outage"); } } as unknown as XftApprovalProvider;
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
const values = { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
const blocked = await service.prepare({ userId: "user-1" }, values, false, Date.now() + 600_000);
assert.equal(blocked.duplicateCheck.status, "check_unavailable");
assert.equal(blocked.duplicateCheck.coverageComplete, false);
assert.equal(blocked.reservationId, undefined);
const confirmed = await service.prepare({ userId: "user-1" }, values, true, Date.now() + 600_000);
assert.equal(confirmed.duplicateCheck.status, "proceeding_after_confirmation");
assert.ok(confirmed.reservationId);
});
test("a reimbursement with no usable invoice evidence never reports a clean check", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-missing-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const service = new InvoiceDeduplicationService(config(), store, {} as ApprovalService);
const blocked = await service.prepare(
{ userId: "user-1" }, { reimAmount: 100 }, false, Date.now() + 600_000, true,
);
assert.equal(blocked.duplicateCheck.status, "check_unavailable");
assert.equal(blocked.duplicateCheck.requiresUserConfirmation, true);
const confirmed = await service.prepare(
{ userId: "user-1" }, { reimAmount: 100 }, true, Date.now() + 600_000, true,
);
assert.equal(confirmed.duplicateCheck.status, "proceeding_after_confirmation");
});
test("rejected prior bills warn instead of masquerading as active approvals", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-rejected-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
store.indexDetail(store.userHash("user-1"), "xft", "xft-rejected", "rejected", "2026-08-01T00:00:00.000Z", evidence);
const ding = { async listMyHistory() { return { items: [], diagnostics: {} }; } } as unknown as ApprovalService;
const xft = { async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; } } as unknown as XftApprovalProvider;
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
const result = await service.prepare(
{ userId: "user-1" }, { invoiceCode: "044031900111", invoiceNumber: "87654321" }, false, Date.now() + 600_000,
);
assert.equal(result.duplicateCheck.status, "confirmation_required");
assert.equal(result.duplicateCheck.matches[0]?.status, "rejected");
assert.equal(result.duplicateCheck.matches[0]?.disposition, "warning");
});
test("truncated history and detail failures make duplicate coverage incomplete", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-truncated-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const ding = {
async listMyHistory() {
return { items: [{
source: "official_oa", historyScope: "submitted", processInstanceId: "detail-fails", status: "RUNNING",
}], diagnostics: {} };
},
async getHistoryDetail() { throw new Error("detail unavailable"); },
} as unknown as ApprovalService;
const xft = {
async listMyHistory() { return { items: [], totalCount: 0, diagnostics: { truncated: true } }; },
} as unknown as XftApprovalProvider;
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
const result = await service.prepare(
{ userId: "user-1" }, { invoiceCode: "044031900111", invoiceNumber: "87654321" }, false, Date.now() + 600_000,
);
assert.equal(result.duplicateCheck.status, "check_unavailable");
assert.equal(result.duplicateCheck.sourceStatus.dingtalk?.status, "partial");
assert.equal(result.duplicateCheck.sourceStatus.xft?.status, "partial");
});
test("unified submission requires a duplicate override and commits the successful business number", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-unified-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const ding = {
async listMyHistory() {
return { items: [{
source: "official_oa", historyScope: "submitted", processInstanceId: "oa-prior",
status: "RUNNING", submittedAt: "2026-08-01T01:00:00.000Z",
}], diagnostics: {} };
},
async getHistoryDetail() {
return { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
},
} as unknown as ApprovalService;
const template = {
eventSeqId: "event-1", eventNumber: "XFT-1", eventName: "费用申请",
eventType: "EXPENSE_APPLY", kind: "expense_apply" as const,
};
let writes = 0;
const xft = {
async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; },
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
async submitApproval() { writes += 1; return { billId: "2026081500000001", status: "submitted", diagnostics: {} }; },
async getMySubmissionStatus() {
return { billId: "2026081500000001", status: "1", statusName: "待审批", revoked: false, revokeAllowed: true, diagnostics: {} };
},
} as unknown as XftApprovalProvider;
const dedup = new InvoiceDeduplicationService(config(), store, ding, xft);
const unified = new UnifiedApprovalService(config(), ding, xft, undefined, undefined, dedup);
const user = { userId: "user-1" };
const templateId = String((await unified.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const values = {
applyExpenses: [{ expenseNumber: "MEAL", amount: 88.5 }],
invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }],
};
const blocked = await unified.prepareSubmission(user, { templateId, values });
assert.equal(blocked.status, "duplicate_confirmation_required");
assert.equal(blocked.confirmationId, undefined);
assert.equal(writes, 0);
const prepared = await unified.prepareSubmission(user, { templateId, values, allowDuplicateInvoices: true });
assert.equal(prepared.status, "confirmation_required");
assert.ok(prepared.confirmationId);
const submitted = await unified.submitApproval(
user, prepared.confirmationId!, { templateId, values, allowDuplicateInvoices: true },
);
assert.equal(submitted.status, "submitted");
assert.equal(writes, 1);
const matches = store.findMatches(store.userHash(user.userId), extractInvoiceEvidence(values));
assert.ok(matches.some((item) => item.business_no === "2026081500000001"));
});