import { ExportsService } from "../src/exports/exports.service"; import { createPrismaMock } from "./utils/mock-prisma"; import * as XLSX from "xlsx"; describe("ExportsService", () => { const abuse = { recordExportActivity: jest.fn() }; const createObjectStorage = () => ({ store: jest.fn(async (input: { content: string | Buffer }) => ({ provider: "local", key: "exports/user_1/test.csv", sizeBytes: Buffer.isBuffer(input.content) ? input.content.byteLength : Buffer.byteLength(input.content), })), load: jest.fn(async () => ({ content: Buffer.from("id,date,description,amount,category,notes,hidden,source\ntx_1,2025-01-10,Lunch,20.00,Meals,Team lunch,false,manual") })), }); beforeEach(() => { abuse.recordExportActivity.mockReset(); }); it("exports an empty csv when the user has no matching rows", async () => { const prisma = createPrismaMock(); prisma.transactionRaw.findMany.mockResolvedValue([]); prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); const result = await service.exportCsv("user_1"); expect(result.status).toBe("ready"); expect(result.rowCount).toBe(0); expect(result.csv).toContain("id,date,description,amount,category,notes,attribution,splitMode,splitMinePercent,splitYoursPercent,splitMineAmount,splitYoursAmount,hidden,source,watermark"); expect(result.csv).toContain("LedgerOne Export Watermark"); expect(prisma.exportLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: "user_1", rowCount: 0 }) }) ); expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 0, {}, undefined); }); it("exports csv with headers and rows", async () => { const prisma = createPrismaMock(); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_1", date: new Date("2025-01-10"), description: "Lunch", amount: 20, source: "manual", derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } } ]); prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); const result = await service.exportCsv("user_1", { category: "Meals" }, { ipAddress: "203.0.113.10", userAgent: "jest-agent", }); expect(result.status).toBe("ready"); expect(result.rowCount).toBe(1); expect(result.csv).toContain("id,date,description,amount,category,notes,attribution,splitMode,splitMinePercent,splitYoursPercent,splitMineAmount,splitYoursAmount,hidden,source,watermark"); expect(result.csv).toContain("LedgerOne Export Watermark"); expect(prisma.exportLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: "user_1", format: "csv", destination: "download", filters: { category: "Meals" }, rowCount: 1, fileName: expect.stringMatching(/ledgerone-export-\d{4}-\d{2}-\d{2}\.csv/), mimeType: "text/csv", fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), ipAddress: "203.0.113.10", userAgent: "jest-agent", metadata: { watermark: expect.objectContaining({ label: "LedgerOne Export Watermark", userId: "user_1" }) }, }) }) ); expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals" }, { ipAddress: "203.0.113.10", userAgent: "jest-agent", }); }); it("exports json as a downloadable base64 transaction file", async () => { const prisma = createPrismaMock(); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_1", date: new Date("2025-01-10"), description: "Lunch", amount: 20, source: "manual", derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } } ]); prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); const result = await service.exportJson("user_1", { category: "Meals" }); const payload = JSON.parse(Buffer.from(result.base64, "base64").toString("utf8")); expect(result.status).toBe("ready"); expect(result.rowCount).toBe(1); expect(result.fileName).toMatch(/ledgerone-export-\d{4}-\d{2}-\d{2}\.json/); expect(result.mimeType).toBe("application/json"); expect(payload.rowCount).toBe(1); expect(payload.filters).toEqual({ category: "Meals" }); expect(payload.watermark).toEqual(expect.objectContaining({ label: "LedgerOne Export Watermark", userId: "user_1", traceId: expect.any(String), })); expect(payload.transactions[0]).toEqual(expect.objectContaining({ id: "tx_1", description: "Lunch", category: "Meals", })); expect(prisma.exportLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: "user_1", format: "json", destination: "download", rowCount: 1, filters: { category: "Meals" }, fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), }) }) ); expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "json" }, undefined); }); it("exports xlsx as a downloadable base64 workbook", async () => { const prisma = createPrismaMock(); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_1", date: new Date("2025-01-10"), description: "Lunch", amount: 20, source: "manual", derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } } ]); prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); const result = await service.exportXlsx("user_1", { category: "Meals" }); const fileBuffer = Buffer.from(result.base64, "base64"); expect(result.status).toBe("ready"); expect(result.rowCount).toBe(1); expect(result.fileName).toMatch(/ledgerone-export-\d{4}-\d{2}-\d{2}\.xlsx/); expect(result.mimeType).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); expect(fileBuffer.subarray(0, 2).toString()).toBe("PK"); const workbook = XLSX.read(fileBuffer, { type: "buffer" }); expect(workbook.SheetNames).toContain("Watermark"); const watermarkRows = XLSX.utils.sheet_to_json(workbook.Sheets.Watermark); expect(watermarkRows[0]).toEqual(expect.objectContaining({ label: "LedgerOne Export Watermark", userId: "user_1", })); const exportRows = XLSX.utils.sheet_to_json(workbook.Sheets["LedgerOne Export"]); expect(exportRows[0]).toEqual(expect.objectContaining({ watermark: expect.stringContaining("LedgerOne Export Watermark"), })); expect(prisma.exportLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: "user_1", format: "xlsx", destination: "download", rowCount: 1, filters: { category: "Meals" }, fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), }) }) ); expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "xlsx" }, undefined); }); it("exports pdf as a downloadable base64 document", async () => { const prisma = createPrismaMock(); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_1", date: new Date("2025-01-10"), description: "Lunch", amount: 20, source: "manual", derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } } ]); prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); const result = await service.exportPdf("user_1", { category: "Meals" }); const fileBuffer = Buffer.from(result.base64, "base64"); expect(result.status).toBe("ready"); expect(result.rowCount).toBe(1); expect(result.fileName).toMatch(/ledgerone-export-\d{4}-\d{2}-\d{2}\.pdf/); expect(result.mimeType).toBe("application/pdf"); expect(fileBuffer.subarray(0, 4).toString()).toBe("%PDF"); expect(fileBuffer.toString("binary")).toContain("LedgerOne Export Watermark"); expect(prisma.exportLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: "user_1", format: "pdf", destination: "download", rowCount: 1, filters: { category: "Meals" }, fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), }) }) ); expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "pdf" }, undefined); }); it("skips real-time Google Sheets sync when Google is not connected", async () => { const prisma = createPrismaMock(); prisma.googleConnection.findUnique.mockResolvedValue(null); const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); const result = await service.syncGoogleSheets("user_1"); expect(result).toEqual({ status: "skipped", reason: "not_connected" }); expect(prisma.transactionRaw.findMany).not.toHaveBeenCalled(); }); it("creates a short-lived single-use signed download URL", async () => { const prisma = createPrismaMock(); prisma.exportDownloadToken.create.mockResolvedValue({ id: "token_1" }); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_1", date: new Date("2025-01-10"), description: "Lunch", amount: 20, source: "manual", derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } } ]); const objectStorage = createObjectStorage(); const service = new ExportsService(prisma as any, abuse as any, objectStorage as any); const result = await service.createSignedDownloadUrl("user_1", "csv", { category: "Meals" }); expect(result.status).toBe("signed"); expect(result.singleUse).toBe(true); expect(result.expiresInSeconds).toBe(120); expect(result.downloadUrl).toMatch(/^\/api\/exports\/download\/[A-Za-z0-9_-]+$/); expect(prisma.exportDownloadToken.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: "user_1", tokenHash: expect.stringMatching(/^[a-f0-9]{64}$/), format: "csv", filters: { category: "Meals" }, storageProvider: "local", storageKey: "exports/user_1/test.csv", fileName: expect.stringMatching(/ledgerone-export-\d{4}-\d{2}-\d{2}\.csv/), mimeType: "text/csv", rowCount: 1, fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), expiresAt: expect.any(Date), }), }) ); expect(objectStorage.store).toHaveBeenCalledWith(expect.objectContaining({ userId: "user_1", format: "csv", mimeType: "text/csv", content: expect.stringContaining("Lunch"), })); expect(objectStorage.store).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining("LedgerOne Export Watermark"), })); }); it("consumes a signed download URL once and records export audit data", async () => { const prisma = createPrismaMock(); prisma.exportDownloadToken.findUnique.mockResolvedValue({ id: "token_1", userId: "user_1", format: "csv", filters: { category: "Meals" }, storageProvider: "local", storageKey: "exports/user_1/test.csv", fileName: "ledgerone-export-2025-01-10.csv", mimeType: "text/csv", rowCount: 1, fileHash: "hash_1", expiresAt: new Date(Date.now() + 60_000), usedAt: null, }); prisma.exportDownloadToken.updateMany.mockResolvedValue({ count: 1 }); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_1", date: new Date("2025-01-10"), description: "Lunch", amount: 20, source: "manual", derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } } ]); prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); const objectStorage = createObjectStorage(); const service = new ExportsService(prisma as any, abuse as any, objectStorage as any); const file = await service.consumeSignedDownloadUrl("raw_token", { ipAddress: "203.0.113.10", userAgent: "jest-agent", }); expect(file.fileName).toBe("ledgerone-export-2025-01-10.csv"); expect(file.mimeType).toBe("text/csv"); expect(file.rowCount).toBe(1); expect(String(file.content)).toContain("Lunch"); expect(objectStorage.load).toHaveBeenCalledWith("local", "exports/user_1/test.csv"); expect(prisma.exportDownloadToken.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: "token_1", usedAt: null }), data: { usedAt: expect.any(Date) }, }) ); expect(prisma.exportLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: "user_1", format: "csv", destination: "download", rowCount: 1, ipAddress: "203.0.113.10", userAgent: "jest-agent", metadata: expect.objectContaining({ signedUrl: true, tokenId: "token_1", storageProvider: "local", storageKey: "exports/user_1/test.csv", precomputedFileHash: "hash_1", }), }), }) ); expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "csv", signedUrl: "true", }, { ipAddress: "203.0.113.10", userAgent: "jest-agent", }); }); it("rejects a signed download URL that was already used", async () => { const prisma = createPrismaMock(); prisma.exportDownloadToken.findUnique.mockResolvedValue({ id: "token_1", userId: "user_1", format: "csv", filters: {}, expiresAt: new Date(Date.now() + 60_000), usedAt: new Date(), }); const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); await expect(service.consumeSignedDownloadUrl("raw_token")).rejects.toThrow("already been used"); expect(prisma.transactionRaw.findMany).not.toHaveBeenCalled(); }); });