import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { createHash, randomUUID } from "node:crypto"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import ExcelJS from "exceljs"; import yauzl from "yauzl"; import { loadConfig, prepareDataDirectories } from "../server/config.js"; import { openDatabase } from "../server/db/index.js"; import { buildExportJob, insertExportJob, type ExportSnapshot } from "../server/exporter.js"; const proofBytes = Buffer.from("export-proof-bytes"); function zipEntries(buffer: Buffer): Promise> { return new Promise((resolve, reject) => { yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => { if (error || !zip) { reject(error ?? new Error("无法读取导出 ZIP")); return; } const entries = new Map(); let settled = false; const fail = (reason: Error) => { if (settled) return; settled = true; zip.close(); reject(reason); }; zip.on("error", fail); zip.on("end", () => { if (settled) return; settled = true; resolve(entries); }); zip.on("entry", (entry) => { zip.openReadStream(entry, (streamError, stream) => { if (streamError || !stream) { fail(streamError ?? new Error("无法读取 ZIP 条目")); return; } const chunks: Buffer[] = []; stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); stream.on("error", fail); stream.on("end", () => { entries.set(entry.fileName, Buffer.concat(chunks)); if (!settled) zip.readEntry(); }); }); }); zip.readEntry(); }); }); } describe("导出 ZIP 产物", () => { let dataDir: string; let config: ReturnType; let database: ReturnType; let adminId: string; beforeEach(async () => { dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-export-")); process.env.TALLYNOTE_DATA_DIR = dataDir; process.env.TALLYNOTE_COOKIE_SECURE = "false"; process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998"; config = loadConfig(); prepareDataDirectories(config); database = openDatabase(config); adminId = randomUUID(); database.sqlite.prepare(` INSERT INTO admins(id, username, username_norm, display_name, password_hash, status, must_change_password, auth_version, version, created_at) VALUES (?, ?, ?, ?, ?, 'active', 0, 1, 1, ?) `).run(adminId, "export-admin", "export-admin", "导出测试管理员", "not-a-password-hash", Date.now()); }); afterEach(async () => { database.sqlite.close(); await rm(dataDir, { recursive: true, force: true }); }); async function createJob(includeManifest: boolean): Promise<{ jobId: string; expenseId: string; reason: string }> { const expenseId = randomUUID(); const attachmentId = randomUUID(); const paidAt = Date.parse("2026-08-27T04:00:00.000Z"); const reason = "供应商仅提供收据,无法补开发票"; const storagePath = "aa/payment.png"; await mkdir(path.join(config.filesDir, "aa"), { recursive: true }); await writeFile(path.join(config.filesDir, storagePath), proofBytes, { mode: 0o600 }); const sha256 = createHash("sha256").update(proofBytes).digest("hex"); database.sqlite.prepare(` INSERT INTO expenses(id, paid_at, amount_cents, note, invoice_missing_reason, status, version, created_at, created_by, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, 'unreimbursed', 1, ?, ?, ?, ?) `).run(expenseId, paidAt, 1234, "导出无发票测试", reason, Date.now(), adminId, Date.now(), adminId); database.sqlite.prepare(` INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by) VALUES (?, ?, 'payment_proof', ?, ?, 'image/png', ?, ?, ?, ?) `).run(attachmentId, expenseId, storagePath, "付款截图.png", proofBytes.length, sha256, Date.now(), adminId); const snapshot: ExportSnapshot = { includeManifest, expenses: [{ id: expenseId, paidAt, amountCents: 1234, note: "导出无发票测试", invoiceMissingReason: reason, status: "unreimbursed", attachments: [{ id: attachmentId, kind: "payment_proof", originalName: "付款截图.png", mimeType: "image/png", storagePath, sizeBytes: proofBytes.length, sha256, }], }], }; const jobId = insertExportJob(database.sqlite, config, { adminId, sessionHash: "session-hash", selection: { ids: [expenseId], includeManifest }, snapshot, }); await buildExportJob(database.sqlite, config, jobId); return { jobId, expenseId, reason }; } it("Excel 包含无发票原因列和合计,默认不生成 manifest", async () => { const { jobId, reason } = await createJob(false); const job = database.sqlite.prepare("SELECT status, file_path AS filePath FROM export_jobs WHERE id=?").get(jobId) as { status: string; filePath: string }; expect(job.status).toBe("ready"); const archive = await zipEntries(await readFile(path.join(config.exportsDir, job.filePath))); expect([...archive.keys()]).toContain("报销清单.xlsx"); expect(archive.has("manifest.json")).toBe(false); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(archive.get("报销清单.xlsx")!); const sheet = workbook.getWorksheet("报销清单")!; expect(sheet.getCell("I1").value).toBe("无发票原因"); expect(sheet.getCell("I2").value).toBe(reason); expect(sheet.getCell("C2").value).toBe(12.34); expect(sheet.getCell("C3").value).toBe(12.34); expect([...archive.keys()].some((name) => name.endsWith("/付款凭证/付款截图.png"))).toBe(true); }); it("开启 manifest 时包含原因和附件元数据,重复构建不会破坏 ZIP", async () => { const { jobId, expenseId, reason } = await createJob(true); await Promise.all([buildExportJob(database.sqlite, config, jobId), buildExportJob(database.sqlite, config, jobId)]); const job = database.sqlite.prepare("SELECT status, file_path AS filePath FROM export_jobs WHERE id=?").get(jobId) as { status: string; filePath: string }; expect(job.status).toBe("ready"); const archive = await zipEntries(await readFile(path.join(config.exportsDir, job.filePath))); const manifest = JSON.parse(archive.get("manifest.json")!.toString("utf8")) as { records: Array<{ id: string; invoiceMissingReason: string; attachments: Array<{ originalName: string }> }> }; expect(manifest.records).toHaveLength(1); expect(manifest.records[0]).toMatchObject({ id: expenseId, invoiceMissingReason: reason }); expect(manifest.records[0]!.attachments[0]!.originalName).toBe("付款截图.png"); }); it("导出错误不泄露本地路径或内部附件标识", async () => { const expenseId = randomUUID(); const missingId = randomUUID(); const now = Date.now(); database.sqlite.prepare(` INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by, updated_at, updated_by) VALUES (?, ?, 100, '审计下载', 'unreimbursed', 1, ?, ?, ?, ?) `).run(expenseId, now, now, adminId, now, adminId); const storagePath = "bb/proof.png"; await mkdir(path.join(config.filesDir, "bb"), { recursive: true }); await writeFile(path.join(config.filesDir, storagePath), proofBytes, { mode: 0o600 }); const digest = createHash("sha256").update(proofBytes).digest("hex"); database.sqlite.prepare(` INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by) VALUES (?, ?, 'payment_proof', ?, 'proof.png', 'image/png', ?, ?, ?, ?) `).run(randomUUID(), expenseId, storagePath, proofBytes.length, digest, now, adminId); const brokenSnapshot: ExportSnapshot = { includeManifest: false, expenses: [{ id: missingId, paidAt: now, amountCents: 100, note: "broken", invoiceMissingReason: null, status: "unreimbursed", attachments: [{ id: randomUUID(), kind: "payment_proof", originalName: "missing.png", mimeType: "image/png", storagePath: "cc/does-not-exist.png", sizeBytes: 12, sha256: "d".repeat(64) }] }], }; database.sqlite.prepare("INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by, updated_at, updated_by) VALUES (?, ?, 100, 'broken', 'unreimbursed', 1, ?, ?, ?, ?)").run(missingId, now, now, adminId, now, adminId); const brokenJob = insertExportJob(database.sqlite, config, { adminId, sessionHash: "audit-session", selection: { ids: [missingId] }, snapshot: brokenSnapshot }); await buildExportJob(database.sqlite, config, brokenJob); const failed = database.sqlite.prepare("SELECT error_message AS errorMessage FROM export_jobs WHERE id=?").get(brokenJob) as { errorMessage: string }; expect(failed.errorMessage).toBe("导出失败:附件文件缺失或校验不通过"); expect(failed.errorMessage).not.toContain("does-not-exist"); }); });