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
252 lines
14 KiB
TypeScript
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"));
|
|
});
|