189 lines
9.3 KiB
TypeScript
189 lines
9.3 KiB
TypeScript
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<Map<string, Buffer>> {
|
|
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<string, Buffer>();
|
|
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<typeof loadConfig>;
|
|
let database: ReturnType<typeof openDatabase>;
|
|
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");
|
|
});
|
|
});
|