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