Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/bulkImportValidator.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
201 changes: 201 additions & 0 deletions test/bulkImportValidator.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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" }),
);
});
});
});