From 8b946977ded24c08bcef8152fad05e24ec90e805 Mon Sep 17 00:00:00 2001 From: dchaudhari7177 <111210939+dchaudhari7177@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:43:26 +0530 Subject: [PATCH 1/2] test(indexer): cover export-csv.mjs export-csv.mjs had no tests. It is a script rather than a module -- it reads process.argv at import time, writes to stdout and calls process.exit -- so these drive it the way an operator does, as a subprocess over a fixture ndjson file, and assert on its stdout. That also covers the argv default and the exit code, which importing could not, and needs no refactor of the script. Eleven tests: the header row on an empty log, one row per record in column order, blank-line skipping, a missing field and an explicit null both becoming empty cells rather than "undefined"/"null", the three cell()-quoting cases (embedded comma, doubled quote per RFC 4180, embedded newline), unlisted record fields staying out of the output, the events.ndjson argv default, and a missing input exiting 1 with its message on stderr. No new dependencies: node:test plus the script itself. Verified: `node --test export-csv.test.mjs` -> 11 pass, 0 fail. Two pre-existing problems in indexer/ that this does not touch, both visible when running `node --test` across the directory: - index.test.mjs does not parse. At line 30 a second file's imports are pasted into the middle of the first test function, so `node --check` fails with "SyntaxError: Unexpected identifier 'test'" and none of that file's tests ever run. It survived because no workflow runs the indexer tests -- grep for "indexer" across .github/workflows/ finds nothing. Left alone here to keep this PR to one concern; happy to send the fix separately. - replay.test.mjs fails only for want of @stellar/stellar-sdk, which is not installed in this environment. Not a bug. The four src/**/*.test.ts files are vitest tests (see vitest.config.ts) and `node --test` is not meant to run them. Closes #335 Co-authored-by: Claude Opus 5 --- indexer/export-csv.test.mjs | 180 ++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 indexer/export-csv.test.mjs diff --git a/indexer/export-csv.test.mjs b/indexer/export-csv.test.mjs new file mode 100644 index 0000000..3a7a8b5 --- /dev/null +++ b/indexer/export-csv.test.mjs @@ -0,0 +1,180 @@ +/** + * CSV export tests. + * + * export-csv.mjs is a script, not a module: it reads process.argv at import time, + * writes to stdout and calls process.exit. So these drive it the way an operator + * does -- as a subprocess over a fixture ndjson file -- and assert on its stdout. + * That also covers the argv and exit-code behaviour, which importing could not. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "export-csv.mjs"); +const HEADER = "at,ledger,type,split,amount,token,creator,txHash"; + +/** Write *lines* to a fresh ndjson file and return its path plus the temp dir. */ +function fixture(lines) { + const dir = mkdtempSync(join(tmpdir(), "tributary-csv-")); + const path = join(dir, "events.ndjson"); + writeFileSync(path, lines.join("\n"), "utf8"); + return { dir, path }; +} + +function record(over = {}) { + return { + at: "2026-08-11T00:00:00Z", + ledger: 12345, + type: "Deposited", + split: "SPLIT1", + amount: "1000", + token: "USDC", + creator: "GCREATOR", + txHash: "abc123", + ...over, + }; +} + +/** Run the export and return its stdout as trimmed lines. */ +function exportCsv(args, options = {}) { + const stdout = execFileSync(process.execPath, [SCRIPT, ...args], { + encoding: "utf8", + ...options, + }); + return stdout.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"); +} + +test("export emits the header row even for an empty log", (t) => { + const { dir, path } = fixture([]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const rows = exportCsv([path]); + + assert.deepEqual(rows, [HEADER]); +}); + +test("export emits one row per record, in column order", (t) => { + const { dir, path } = fixture([ + JSON.stringify(record({ ledger: 1, txHash: "tx1" })), + JSON.stringify(record({ ledger: 2, txHash: "tx2" })), + JSON.stringify(record({ ledger: 3, txHash: "tx3" })), + ]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const rows = exportCsv([path]); + + assert.equal(rows.length, 4, "header plus three records"); + assert.equal(rows[0], HEADER); + assert.equal(rows[1], "2026-08-11T00:00:00Z,1,Deposited,SPLIT1,1000,USDC,GCREATOR,tx1"); + assert.equal(rows[3], "2026-08-11T00:00:00Z,3,Deposited,SPLIT1,1000,USDC,GCREATOR,tx3"); +}); + +test("export skips blank and whitespace-only lines", (t) => { + const { dir, path } = fixture([ + JSON.stringify(record({ txHash: "tx1" })), + "", + " ", + JSON.stringify(record({ txHash: "tx2" })), + "", + ]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const rows = exportCsv([path]); + + assert.equal(rows.length, 3, "blank lines must not become empty CSV rows"); +}); + +test("export leaves a missing field as an empty cell rather than 'undefined'", (t) => { + const partial = { at: "2026-08-11T00:00:00Z", ledger: 7, type: "Distributed" }; + const { dir, path } = fixture([JSON.stringify(partial)]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const rows = exportCsv([path]); + + assert.equal(rows[1], "2026-08-11T00:00:00Z,7,Distributed,,,,,"); + assert.ok(!rows[1].includes("undefined"), "a missing field must not print as 'undefined'"); +}); + +test("export treats an explicit null as an empty cell", (t) => { + const { dir, path } = fixture([JSON.stringify(record({ token: null, creator: null }))]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const rows = exportCsv([path]); + + assert.equal(rows[1], "2026-08-11T00:00:00Z,12345,Deposited,SPLIT1,1000,,,abc123"); + assert.ok(!rows[1].includes("null"), "a null field must not print as 'null'"); +}); + +test("export quotes cells containing a comma", (t) => { + const { dir, path } = fixture([JSON.stringify(record({ type: "Deposited,Routed" }))]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const rows = exportCsv([path]); + + assert.ok(rows[1].includes('"Deposited,Routed"'), rows[1]); + // Still eight fields: the quoted comma must not split the row. + assert.equal(rows[1].match(/,/g).length, 8, "one embedded comma plus seven separators"); +}); + +test("export doubles embedded quotes, per RFC 4180", (t) => { + const { dir, path } = fixture([JSON.stringify(record({ creator: 'G"QUOTED"' }))]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const rows = exportCsv([path]); + + assert.ok(rows[1].includes('"G""QUOTED"""'), rows[1]); +}); + +test("export quotes a cell containing a newline so the row stays one record", (t) => { + const { dir, path } = fixture([JSON.stringify(record({ type: "Two\nLines" }))]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const stdout = execFileSync(process.execPath, [SCRIPT, path], { encoding: "utf8" }); + + assert.ok(stdout.includes('"Two\nLines"'), "the newline must be inside quotes"); +}); + +test("export ignores fields that are not exported columns", (t) => { + const { dir, path } = fixture([JSON.stringify(record({ internalCursor: "leaked" }))]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const rows = exportCsv([path]); + + assert.equal(rows[0], HEADER, "header is fixed by COLUMNS"); + assert.ok(!rows[1].includes("leaked"), "an unlisted field must not reach the CSV"); +}); + +test("export defaults to events.ndjson in the working directory", (t) => { + const { dir, path } = fixture([JSON.stringify(record({ txHash: "default" }))]); + t.after(() => rmSync(dir, { recursive: true, force: true })); + assert.ok(path.endsWith("events.ndjson")); + + const rows = exportCsv([], { cwd: dir }); + + assert.equal(rows.length, 2); + assert.ok(rows[1].endsWith("default")); +}); + +test("export fails loudly when the input file is missing", (t) => { + const dir = mkdtempSync(join(tmpdir(), "tributary-csv-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + let error; + try { + execFileSync(process.execPath, [SCRIPT, join(dir, "nope.ndjson")], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + error = err; + } + + assert.ok(error, "a missing input must not exit 0"); + assert.equal(error.status, 1); + assert.match(error.stderr, /not found\. Run the indexer first\./); +}); From 741c458208a4aff80a17d79f61aa73538b706083 Mon Sep 17 00:00:00 2001 From: dchaudhari7177 <111210939+dchaudhari7177@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:48:30 +0530 Subject: [PATCH 2/2] test(app): cover RecipientEditor's duplicate-recipient warning RecipientEditor.test.ts covers parseCsv/rowsTotal/rowsError but never renders the component, so the duplicate warning had no coverage. Nine rendered tests: the same address twice warns, both offending rows are marked (not just the second), a unique set is left alone, addresses differing only in whitespace count as the same recipient, repeated split ids do not warn, a triplicate marks all three rows under one note, two separate duplicated addresses are marked independently, and neither an empty editor nor two blank address rows warn. Two setup notes: - The jsdom environment is set with a per-file `@vitest-environment` docblock rather than in vite.config.ts, so the existing node-environment tests keep running exactly as before and no shared config changes. - cleanup() is registered explicitly. @testing-library/react only installs its automatic afterEach when vitest globals are enabled, and this project does not enable them, so without it renders pile up in document.body and any unscoped query sees every earlier test's DOM. That is what made the first version of the second test read 4 markers instead of 2. Assertions are on the warning elements (.dupe-note, .dupe-input, the aria-label="Duplicate recipient" marker), not on the rendered copy, because three of the i18n keys the component asks for -- duplicateRecipientNote, duplicateAddressHint and duplicateRecipientError -- are absent from the translations table in src/lib/i18n.tsx. t() falls through to returning the key, so the warning currently shows users the literal string "duplicateRecipientNote". Asserting on the copy would have pinned that in place. Not fixed here: adding the keys is a separate concern. Verified with `npx vitest run`: 105 passed, up from 96. The 5 failures in src/lib/tributary.test.ts (fromStroops formatting) are pre-existing and occur identically with this file removed. `npm run build` -- the stated CI gate for the app -- also fails on an unmodified tree, with 10 TS errors, all in src/components/StreamsCard.tsx (missing Client methods for the stream API, plus one implicit any). None are in the file added here. Closes #328 Co-authored-by: Claude Opus 5 --- .../RecipientEditor.component.test.tsx | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 app/src/components/RecipientEditor.component.test.tsx diff --git a/app/src/components/RecipientEditor.component.test.tsx b/app/src/components/RecipientEditor.component.test.tsx new file mode 100644 index 0000000..d1ecb0e --- /dev/null +++ b/app/src/components/RecipientEditor.component.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +// +// Rendered tests for RecipientEditor's duplicate-recipient warning. +// +// The environment is set per-file rather than in vite.config.ts, so the existing +// node-environment tests (RecipientEditor.test.ts and friends) keep running unchanged. +// +// These assert on the warning *elements* -- .dupe-note, .dupe-input, and the +// aria-label="Duplicate recipient" marker -- rather than on their text. Three of the +// i18n keys the component asks for (duplicateRecipientNote, duplicateAddressHint, +// duplicateRecipientError) are missing from the translations table, so t() currently +// falls through to returning the key itself. Asserting on the rendered copy would pin +// that bug in place; asserting on the elements is right both now and after it is fixed. + +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; + +import RecipientEditor, { type Row } from "./RecipientEditor"; +import { I18nProvider } from "../lib/i18n"; + +const G = "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; +const G2 = "GEFGHIJKLMNOPQRSTUVWXYZ234567EFGHIJKLMNOPQRSTUVWXYZ23456"; + +function renderEditor(rows: Row[]) { + const { container } = render( + + {}} /> + , + ); + return container; +} + +// Cleanup is explicit: @testing-library/react only registers its automatic afterEach +// when vitest globals are enabled, and this project does not enable them. Without this, +// renders pile up in document.body and any unscoped query sees every earlier test's DOM. +afterEach(cleanup); + +function address(value: string, percent: string): Row { + return { kind: "address", value, percent }; +} + +function split(value: string, percent: string): Row { + return { kind: "split", value, percent }; +} + +describe("RecipientEditor duplicate warning", () => { + it("warns when the same recipient address is added twice", () => { + const container = renderEditor([address(G, "50"), address(G, "50")]); + + expect(container.querySelector(".dupe-note")).not.toBeNull(); + }); + + it("marks both offending rows, not just the second", () => { + const container = renderEditor([address(G, "50"), address(G, "50")]); + + expect(container.querySelectorAll(".dupe-input")).toHaveLength(2); + expect(container.querySelectorAll('[aria-label="Duplicate recipient"]')).toHaveLength(2); + }); + + it("leaves a unique set of recipients unwarned", () => { + const container = renderEditor([address(G, "50"), address(G2, "50")]); + + expect(container.querySelector(".dupe-note")).toBeNull(); + expect(container.querySelectorAll(".dupe-input")).toHaveLength(0); + }); + + it("treats addresses differing only in whitespace as the same recipient", () => { + // The component keys on value.trim(), and a pasted address often carries a space. + const container = renderEditor([address(G, "50"), address(` ${G} `, "50")]); + + expect(container.querySelector(".dupe-note")).not.toBeNull(); + expect(container.querySelectorAll(".dupe-input")).toHaveLength(2); + }); + + it("does not warn about repeated split ids", () => { + // duplicateAddresses only considers address-type rows: the same split appearing + // twice is a different question from the same account being paid twice. + const container = renderEditor([split("42", "50"), split("42", "50")]); + + expect(container.querySelector(".dupe-note")).toBeNull(); + }); + + it("marks every row of a triplicated address", () => { + const container = renderEditor([ + address(G, "34"), + address(G, "33"), + address(G, "33"), + ]); + + expect(container.querySelectorAll(".dupe-input")).toHaveLength(3); + // One note for the whole editor, however many rows are involved. + expect(container.querySelectorAll(".dupe-note")).toHaveLength(1); + }); + + it("marks two separate duplicated addresses independently", () => { + const container = renderEditor([ + address(G, "25"), + address(G, "25"), + address(G2, "25"), + address(G2, "25"), + ]); + + expect(container.querySelectorAll(".dupe-input")).toHaveLength(4); + expect(container.querySelectorAll(".dupe-note")).toHaveLength(1); + }); + + it("does not warn on an empty editor", () => { + const container = renderEditor([]); + + expect(container.querySelector(".dupe-note")).toBeNull(); + }); + + it("does not treat two empty address rows as duplicates of each other", () => { + // An empty value is "not filled in yet", not a repeated recipient; the empty-row + // error covers that case instead. + const container = renderEditor([address("", "50"), address("", "50")]); + + expect(container.querySelector(".dupe-note")).toBeNull(); + }); +});