375 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|