From 2f65dd9d14754cb438646c5ddb6eb5d074ed8f33 Mon Sep 17 00:00:00 2001 From: DatboiCaleb Date: Wed, 29 Jul 2026 11:52:21 +0100 Subject: [PATCH] feat(#229): add invoice bulk import validator with row-level errors - New src/bulkImportValidator.ts with validateBulkImport() function - Validates positive amounts, non-empty recipients, and future deadline - Continues validating all rows (never fail-fast) and returns all errors - Mirrors contract _create_invoice_inner validation rules - Exported via public API (index.ts) - Tests: all-valid, all-invalid, mixed set with multiple distinct error rows --- src/bulkImportValidator.ts | 91 ++++++++++++++ src/index.ts | 7 ++ test/bulkImportValidator.test.ts | 201 +++++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 src/bulkImportValidator.ts create mode 100644 test/bulkImportValidator.test.ts diff --git a/src/bulkImportValidator.ts b/src/bulkImportValidator.ts new file mode 100644 index 0000000..aba1e94 --- /dev/null +++ b/src/bulkImportValidator.ts @@ -0,0 +1,91 @@ +import type { CreateInvoiceParams } from "./types.js"; + +/** + * Describes a single validation error on a specific row and field. + */ +export interface BulkImportRowError { + /** Zero-based row index in the input array. */ + row: number; + /** The field that failed validation. */ + field: string; + /** Human-readable description of the problem. */ + message: string; +} + +/** + * Result returned by {@link validateBulkImport}. + */ +export interface BulkImportValidationResult { + /** Indices (zero-based) of rows that passed all validations. */ + validRows: number[]; + /** All validation errors collected across every row. */ + errors: BulkImportRowError[]; +} + +/** + * Validate an array of invoice-creation rows against the same constraints + * the contract's `_create_invoice_inner` enforces: + * + * 1. **Positive amounts** – every recipient amount must be > 0. + * 2. **Recipients present** – `recipients` array must not be empty. + * 3. **Future deadline** – `deadline` must be in the future (greater than + * `Date.now() / 1000`). + * + * Unlike a fail-fast approach, *all* rows are always validated so the caller + * can surface every problem in one pass. + * + * @param rows - Array of {@link CreateInvoiceParams}-like objects to validate. + * @returns A {@link BulkImportValidationResult} with valid row indices and + * collected per-row errors. + */ +export function validateBulkImport( + rows: CreateInvoiceParams[], +): BulkImportValidationResult { + const validRows: number[] = []; + const errors: BulkImportRowError[] = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let rowValid = true; + + // --- recipients must be present --- + if (!row.recipients || row.recipients.length === 0) { + errors.push({ + row: i, + field: "recipients", + message: "Recipients array must not be empty", + }); + rowValid = false; + } else { + // --- every recipient amount must be positive --- + for (let j = 0; j < row.recipients.length; j++) { + const recipient = row.recipients[j]; + if (recipient.amount <= 0n) { + errors.push({ + row: i, + field: `recipients[${j}].amount`, + message: `Recipient amount must be positive, got ${recipient.amount}`, + }); + rowValid = false; + } + } + } + + // --- deadline must be in the future --- + const nowSeconds = Math.floor(Date.now() / 1000); + if (row.deadline <= nowSeconds) { + errors.push({ + row: i, + field: "deadline", + message: `Deadline must be in the future, got ${row.deadline} but current time is ${nowSeconds}`, + }); + rowValid = false; + } + + if (rowValid) { + validRows.push(i); + } + } + + return { validRows, errors }; +} diff --git a/src/index.ts b/src/index.ts index 38d8fe1..7182494 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,13 @@ export { serializeInvoiceTemplate, deserializeInvoiceTemplate, } from "./invoiceTemplate.js"; +export { + validateBulkImport, +} from "./bulkImportValidator.js"; +export type { + BulkImportRowError, + BulkImportValidationResult, +} from "./bulkImportValidator.js"; export { StellarSplitError, InvoiceNotFoundError, diff --git a/test/bulkImportValidator.test.ts b/test/bulkImportValidator.test.ts new file mode 100644 index 0000000..e595571 --- /dev/null +++ b/test/bulkImportValidator.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { validateBulkImport } from "../src/bulkImportValidator.js"; +import type { CreateInvoiceParams } from "../src/types.js"; + +/** Helper: future deadline (1 hour from now). */ +const FUTURE_DEADLINE = Math.floor(Date.now() / 1000) + 3600; + +/** Helper: past deadline. */ +const PAST_DEADLINE = Math.floor(Date.now() / 1000) - 3600; + +function makeRow(overrides: Partial = {}): CreateInvoiceParams { + return { + creator: "GABCDEFG1234567890", + recipients: [{ address: "GXYZ1234567890", amount: 500n }], + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + deadline: FUTURE_DEADLINE, + ...overrides, + }; +} + +describe("bulkImportValidator", () => { + // Use a fake timer so "now" is deterministic for deadline checks. + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const stableNow = Math.floor(new Date("2026-01-15T00:00:00Z").getTime() / 1000); + const futureDeadline = stableNow + 7200; + const pastDeadline = stableNow - 3600; + + function stableRow(overrides: Partial = {}): CreateInvoiceParams { + return { + creator: "GABCDEFG1234567890", + recipients: [{ address: "GXYZ1234567890", amount: 500n }], + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + deadline: futureDeadline, + ...overrides, + }; + } + + // ----------------------------------------------------------------------- + // All-valid set + // ----------------------------------------------------------------------- + describe("all-valid set", () => { + it("returns all indices as valid when every row is correct", () => { + const rows = [ + stableRow(), + stableRow({ + recipients: [ + { address: "GA1", amount: 100n }, + { address: "GA2", amount: 200n }, + ], + }), + stableRow({ memo: "invoice #3" }), + ]; + + const result = validateBulkImport(rows); + + expect(result.validRows).toEqual([0, 1, 2]); + expect(result.errors).toEqual([]); + }); + + it("handles a single valid row", () => { + const result = validateBulkImport([stableRow()]); + expect(result.validRows).toEqual([0]); + expect(result.errors).toHaveLength(0); + }); + + it("handles an empty input array", () => { + const result = validateBulkImport([]); + expect(result.validRows).toEqual([]); + expect(result.errors).toEqual([]); + }); + }); + + // ----------------------------------------------------------------------- + // All-invalid set + // ----------------------------------------------------------------------- + describe("all-invalid set", () => { + it("returns no valid rows and collects errors for every row", () => { + const rows = [ + stableRow({ recipients: [] }), // empty recipients + stableRow({ + recipients: [{ address: "GA1", amount: 0n }], // zero amount + }), + stableRow({ deadline: pastDeadline }), // past deadline + stableRow({ + recipients: [{ address: "GA1", amount: -10n }], // negative amount + deadline: pastDeadline, // and also past deadline + }), + ]; + + const result = validateBulkImport(rows); + + expect(result.validRows).toEqual([]); + expect(result.errors.length).toBeGreaterThanOrEqual(4); + + // Row 0: empty recipients + expect(result.errors).toContainEqual( + expect.objectContaining({ row: 0, field: "recipients" }), + ); + // Row 1: zero amount + expect(result.errors).toContainEqual( + expect.objectContaining({ row: 1, field: "recipients[0].amount" }), + ); + // Row 2: past deadline + expect(result.errors).toContainEqual( + expect.objectContaining({ row: 2, field: "deadline" }), + ); + // Row 3: negative amount AND past deadline + expect(result.errors).toContainEqual( + expect.objectContaining({ row: 3, field: "recipients[0].amount" }), + ); + expect(result.errors).toContainEqual( + expect.objectContaining({ row: 3, field: "deadline" }), + ); + }); + }); + + // ----------------------------------------------------------------------- + // Mixed set with multiple distinct error rows + // ----------------------------------------------------------------------- + describe("mixed set with distinct error rows", () => { + it("reports errors only for invalid rows and marks the rest valid", () => { + const rows = [ + stableRow(), // valid (idx 0) + stableRow({ recipients: [] }), // invalid (idx 1) + stableRow(), // valid (idx 2) + stableRow({ + recipients: [ + { address: "GA1", amount: 100n }, + { address: "GA2", amount: -5n }, // second recipient negative + ], + }), // invalid (idx 3) + stableRow({ deadline: pastDeadline }), // invalid (idx 4) + stableRow({ memo: "all good here" }), // valid (idx 5) + ]; + + const result = validateBulkImport(rows); + + expect(result.validRows).toEqual([0, 2, 5]); + expect(result.errors).toHaveLength(3); + + // row 1 error + expect(result.errors[0]).toEqual( + expect.objectContaining({ row: 1, field: "recipients" }), + ); + // row 3 error – specifically the second recipient + expect(result.errors[1]).toEqual( + expect.objectContaining({ row: 3, field: "recipients[1].amount" }), + ); + // row 4 error + expect(result.errors[2]).toEqual( + expect.objectContaining({ row: 4, field: "deadline" }), + ); + }); + + it("continues past early errors to validate later rows", () => { + const rows = [ + stableRow({ deadline: pastDeadline }), // invalid first row + stableRow(), // valid second row + ]; + + const result = validateBulkImport(rows); + + expect(result.validRows).toEqual([1]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].row).toBe(0); + }); + }); + + // ----------------------------------------------------------------------- + // Edge-case: multiple errors on a single row + // ----------------------------------------------------------------------- + describe("multiple errors on a single row", () => { + it("reports all errors for a row with both bad amount and bad deadline", () => { + const rows = [ + stableRow({ + recipients: [{ address: "GA1", amount: 0n }], + deadline: pastDeadline, + }), + ]; + + const result = validateBulkImport(rows); + + expect(result.validRows).toEqual([]); + expect(result.errors).toHaveLength(2); + expect(result.errors).toContainEqual( + expect.objectContaining({ row: 0, field: "recipients[0].amount" }), + ); + expect(result.errors).toContainEqual( + expect.objectContaining({ row: 0, field: "deadline" }), + ); + }); + }); +});