ledgerone_backend/test/exports.service.spec.ts

375 lines
15 KiB
TypeScript

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