From 9b011d8c38cbd6febbe92b3cdd42440d395c61b7 Mon Sep 17 00:00:00 2001 From: ranjeet2063 Date: Sun, 6 Sep 2026 02:45:04 +0545 Subject: [PATCH] feat(builder): aggregate StreamBuilder validation errors into ValidationError (closes #631) --- src/builder.ts | 213 ++++++++++++------ src/errors.ts | 51 +++++ src/index.ts | 3 +- src/tests/builder-create-stream-args.test.ts | 6 +- src/tests/builder-validation-bypass.test.ts | 8 +- src/tests/builder.test.ts | 6 +- src/tests/stream-builder-network-drop.test.ts | 2 +- ...tream-builder-validation-aggregate.test.ts | 212 +++++++++++++++++ 8 files changed, 422 insertions(+), 79 deletions(-) create mode 100644 src/tests/stream-builder-validation-aggregate.test.ts diff --git a/src/builder.ts b/src/builder.ts index 988503d..e01c117 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -7,7 +7,7 @@ import { paramToScVal, validateContext, } from './batch-tx.js'; -import { OperationAbortedError } from './errors.js'; +import { OperationAbortedError, ValidationError, type ValidationIssue } from './errors.js'; import type { BatchTransactionContext, BuiltBatchTransaction, ScValType } from './batch-tx.js'; export interface SubmitOptions { @@ -78,7 +78,7 @@ export class StreamBuilder { * @returns The builder instance for chaining. */ token(address: string): this { - this._token = StreamBuilder._validateAddress(address, 'token'); + this._token = address; return this; } @@ -88,7 +88,7 @@ export class StreamBuilder { * @returns The builder instance for chaining. */ sender(address: string): this { - this._sender = StreamBuilder._validateAddress(address, 'sender'); + this._sender = address; return this; } @@ -98,7 +98,7 @@ export class StreamBuilder { * @returns The builder instance for chaining. */ recipient(address: string): this { - this._recipient = StreamBuilder._validateAddress(address, 'recipient'); + this._recipient = address; return this; } @@ -111,15 +111,6 @@ export class StreamBuilder { * @returns The builder instance for chaining. */ amount(val: number | bigint): this { - if (typeof val === 'bigint') { - if (val <= 0n) { - throw new Error('Invalid StreamBuilder parameter: amount must be a positive value'); - } - } else { - if (!Number.isFinite(val) || val <= 0) { - throw new Error('Invalid StreamBuilder parameter: amount must be a positive finite number'); - } - } this._amount = val; return this; } @@ -133,15 +124,6 @@ export class StreamBuilder { * @returns The builder instance for chaining. */ ratePerSecond(val: number | bigint): this { - if (typeof val === 'bigint') { - if (val <= 0n) { - throw new Error('Invalid StreamBuilder parameter: ratePerSecond must be a positive value'); - } - } else { - if (!Number.isFinite(val) || val <= 0) { - throw new Error('Invalid StreamBuilder parameter: ratePerSecond must be a positive finite number'); - } - } this._ratePerSecond = val; return this; } @@ -154,13 +136,6 @@ export class StreamBuilder { * @returns The builder instance for chaining. */ startTime(val: number): this { - if (!Number.isInteger(val) || val < 0) { - throw new Error('Invalid StreamBuilder parameter: startTime must be a non-negative integer Unix timestamp'); - } - const now = Math.floor(Date.now() / 1000); - if (val < now) { - throw new Error('Invalid StreamBuilder parameter: startTime cannot be in the past'); - } this._startTime = val; return this; } @@ -173,9 +148,6 @@ export class StreamBuilder { * @returns The builder instance for chaining. */ endTime(val: number): this { - if (!Number.isInteger(val) || val < 0) { - throw new Error('Invalid StreamBuilder parameter: endTime must be a non-negative integer Unix timestamp'); - } this._endTime = val; return this; } @@ -187,35 +159,156 @@ export class StreamBuilder { * @returns The builder instance for chaining. */ clawbackEnabled(val: boolean): this { - if (typeof val !== 'boolean') { - throw new Error('Invalid StreamBuilder parameter: clawbackEnabled must be a boolean'); - } this._clawbackEnabled = val; return this; } + /** + * Validates all configured builder fields and returns an array of any + * issues found. Returns an empty array if all fields are valid. + * + * Unlike {@link build}, this does not throw. + */ + validate(): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + // token + if (this._token === undefined) { + issues.push({ field: 'token', message: 'Missing required parameter: token' }); + } else if (typeof this._token !== 'string' || this._token.trim().length === 0) { + issues.push({ field: 'token', message: 'Invalid StreamBuilder parameter: token must be a non-empty string' }); + } else if (!StrKey.isValidContract(this._token)) { + issues.push({ + field: 'token', + message: `Invalid StreamBuilder parameter: token must be a valid Soroban contract ID (C-address), got "${this._token}"`, + }); + } + + // sender + if (this._sender === undefined) { + issues.push({ field: 'sender', message: 'Missing required parameter: sender' }); + } else if (typeof this._sender !== 'string' || this._sender.trim().length === 0) { + issues.push({ field: 'sender', message: 'Invalid StreamBuilder parameter: sender must be a non-empty string' }); + } else if (!StrKey.isValidEd25519PublicKey(this._sender) && !StrKey.isValidContract(this._sender)) { + issues.push({ + field: 'sender', + message: `Invalid StreamBuilder parameter: sender must be a valid Stellar public key or contract address (G-address or C-address), got "${this._sender}"`, + }); + } + + // recipient + if (this._recipient === undefined) { + issues.push({ field: 'recipient', message: 'Missing required parameter: recipient' }); + } else if (typeof this._recipient !== 'string' || this._recipient.trim().length === 0) { + issues.push({ field: 'recipient', message: 'Invalid StreamBuilder parameter: recipient must be a non-empty string' }); + } else if (!StrKey.isValidEd25519PublicKey(this._recipient) && !StrKey.isValidContract(this._recipient)) { + issues.push({ + field: 'recipient', + message: `Invalid StreamBuilder parameter: recipient must be a valid Stellar public key or contract address (G-address or C-address), got "${this._recipient}"`, + }); + } + + // amount + if (this._amount === undefined) { + issues.push({ field: 'amount', message: 'Missing required parameter: amount' }); + } else if (typeof this._amount === 'bigint') { + if (this._amount <= 0n) { + issues.push({ field: 'amount', message: 'Invalid StreamBuilder parameter: amount must be a positive value' }); + } + } else if (typeof this._amount === 'number') { + if (!Number.isFinite(this._amount) || this._amount <= 0) { + issues.push({ field: 'amount', message: 'Invalid StreamBuilder parameter: amount must be a positive finite number' }); + } + } else { + issues.push({ field: 'amount', message: 'Invalid StreamBuilder parameter: amount must be a positive finite number' }); + } + + // ratePerSecond (optional) + if (this._ratePerSecond !== undefined && this._ratePerSecond !== null) { + if (typeof this._ratePerSecond === 'bigint') { + if (this._ratePerSecond <= 0n) { + issues.push({ field: 'ratePerSecond', message: 'Invalid StreamBuilder parameter: ratePerSecond must be a positive value' }); + } + } else if (typeof this._ratePerSecond === 'number') { + if (!Number.isFinite(this._ratePerSecond) || this._ratePerSecond <= 0) { + issues.push({ field: 'ratePerSecond', message: 'Invalid StreamBuilder parameter: ratePerSecond must be a positive finite number' }); + } + } else { + issues.push({ field: 'ratePerSecond', message: 'Invalid StreamBuilder parameter: ratePerSecond must be a positive number or bigint' }); + } + } + + // startTime (optional) + if (this._startTime !== undefined && this._startTime !== null) { + if (typeof this._startTime !== 'number' || !Number.isInteger(this._startTime) || this._startTime < 0) { + issues.push({ field: 'startTime', message: 'Invalid StreamBuilder parameter: startTime must be a non-negative integer Unix timestamp' }); + } else { + const now = Math.floor(Date.now() / 1000); + if (this._startTime < now) { + issues.push({ field: 'startTime', message: 'Invalid StreamBuilder parameter: startTime cannot be in the past' }); + } + } + } + + // endTime (optional) + if (this._endTime !== undefined && this._endTime !== null) { + if (typeof this._endTime !== 'number' || !Number.isInteger(this._endTime) || this._endTime < 0) { + issues.push({ field: 'endTime', message: 'Invalid StreamBuilder parameter: endTime must be a non-negative integer Unix timestamp' }); + } else if ( + typeof this._startTime === 'number' && + this._endTime > 0 && + this._endTime <= this._startTime + ) { + issues.push({ field: 'endTime', message: 'Invalid StreamBuilder parameter: endTime must be greater than startTime' }); + } + } + + // clawbackEnabled (optional) + if (this._clawbackEnabled !== undefined && this._clawbackEnabled !== null) { + if (typeof this._clawbackEnabled !== 'boolean') { + issues.push({ field: 'clawbackEnabled', message: 'Invalid StreamBuilder parameter: clawbackEnabled must be a boolean' }); + } + } + + return issues; + } + /** * Validates and produces the final stream configuration. * Any bigint fields are converted to strings to guarantee safe * serialisation across all browsers (Safari/WebKit included). * @returns An object containing `token`, `sender`, `recipient`, `amount`, and optionally `ratePerSecond`. - * @throws {Error} If any required field (`token`, `sender`, `recipient`, `amount`) is missing or malformed. + * @throws {ValidationError} If any field is missing or malformed, collecting all issues in `error.issues`. + * @throws {Error} If the builder has been destroyed. */ build() { if (this.isDestroyed) { throw new Error('StreamBuilder has been destroyed'); } - if (this._token === undefined || this._token === null || - this._sender === undefined || this._sender === null || - this._recipient === undefined || this._recipient === null || - this._amount === undefined || this._amount === null) { - throw new Error('Missing required parameters for StreamBuilder'); + + const issues = this.validate(); + if (issues.length > 0) { + const missingFields = issues + .filter((i) => i.message.startsWith('Missing required parameter')) + .map((i) => i.field); + const invalidIssues = issues.filter((i) => !i.message.startsWith('Missing required parameter')); + + let summary = ''; + if (missingFields.length > 0 && invalidIssues.length === 0) { + summary = `Missing required parameters for StreamBuilder: ${missingFields.join(', ')}`; + } else if (invalidIssues.length > 0 && missingFields.length === 0) { + summary = `Invalid StreamBuilder parameter: ${invalidIssues.map((i) => i.message).join('; ')}`; + } else { + summary = `Missing required parameters for StreamBuilder: ${missingFields.join(', ')}; Invalid StreamBuilder parameter: ${invalidIssues.map((i) => i.message).join('; ')}`; + } + + throw new ValidationError(issues, summary); } const config: Record = { - token: this._token, - sender: this._sender, - recipient: this._recipient, + token: this._token as string, + sender: this._sender as string, + recipient: this._recipient as string, // Coerce to string regardless of input type: build()'s return type // promises `amount: string`, and ConduitBatcher's payload validation // rejects a raw `number` (a float-precision hazard for token amounts). @@ -270,7 +363,14 @@ export class StreamBuilder { toContractArgs(): unknown[] { const config = this.build(); if (this._ratePerSecond === undefined || this._ratePerSecond === null) { - throw new Error( + throw new ValidationError( + [ + { + field: 'ratePerSecond', + message: + 'Invalid StreamBuilder parameter: ratePerSecond is required to build create_stream contract arguments', + }, + ], 'Invalid StreamBuilder parameter: ratePerSecond is required to build create_stream contract arguments', ); } @@ -412,29 +512,6 @@ export class StreamBuilder { this.activeTimers.clear(); this.pendingQueue = []; } - - private static _validateAddress(address: string, field: string): string { - if (typeof address !== 'string' || address.trim().length === 0) { - throw new Error(`Invalid StreamBuilder parameter: ${field} must be a non-empty string`); - } - - if (field === 'token') { - if (!StrKey.isValidContract(address)) { - throw new Error( - `Invalid StreamBuilder parameter: ${field} must be a valid Soroban contract ID (C-address), got "${address}"`, - ); - } - } else { - // sender / recipient — must be valid Stellar addresses (G-address or C-address) - if (!StrKey.isValidEd25519PublicKey(address) && !StrKey.isValidContract(address)) { - throw new Error( - `Invalid StreamBuilder parameter: ${field} must be a valid Stellar public key or contract address (G-address or C-address), got "${address}"`, - ); - } - } - - return address; - } } export interface BatchOperation { diff --git a/src/errors.ts b/src/errors.ts index 3f7979a..8e1e92f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -490,6 +490,54 @@ export class OperationAbortedError extends Error { } } +// ── Validation error (#631) ─────────────────────────────────────────────────── + +export interface ValidationIssue { + /** The field that failed validation (e.g. 'token', 'sender', 'recipient', 'amount'). */ + field?: string; + /** Human-readable explanation of why validation failed. */ + message: string; +} + +/** + * Thrown when validation fails across one or more builder or payload fields. + * Aggregates all detected problems into a `.issues[]` array rather than halting + * on the first invalid field. + * + * @example + * ```ts + * try { + * new StreamBuilder().token('bad').sender('bad').build(); + * } catch (err) { + * if (err instanceof ValidationError) { + * console.error(err.issues); // [{ field: 'token', ... }, { field: 'sender', ... }] + * } + * } + * ``` + */ +export class ValidationError extends Error { + /** The list of all validation issues detected. */ + readonly issues: readonly ValidationIssue[]; + + constructor(issues: (ValidationIssue | string)[], message?: string) { + const normalizedIssues: ValidationIssue[] = issues.map((issue) => + typeof issue === 'string' ? { message: issue } : issue, + ); + const summary = + message ?? + (normalizedIssues.length === 1 + ? normalizedIssues[0]!.message + : `Validation failed with ${normalizedIssues.length} issues:\n` + + normalizedIssues + .map((i) => ` - ${i.field ? `[${i.field}] ` : ''}${i.message}`) + .join('\n')); + super(summary); + this.name = 'ValidationError'; + this.issues = normalizedIssues; + Object.setPrototypeOf(this, new.target.prototype); + } +} + // ── Type guard ──────────────────────────────────────────────────────────────── /** @@ -506,6 +554,7 @@ export class OperationAbortedError extends Error { * - {@link RpcServiceUnavailableError} * - {@link IndexerTimeoutError} * - {@link OperationAbortedError} + * - {@link ValidationError} */ export function isConduitError(value: unknown): value is Error { if (!(value instanceof Error)) return false; @@ -518,5 +567,7 @@ export function isConduitError(value: unknown): value is Error { 'RpcServiceUnavailableError', 'IndexerTimeoutError', 'OperationAbortedError', + 'ValidationError', ].includes(value.name); } + diff --git a/src/index.ts b/src/index.ts index 403282e..3078330 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,12 +43,13 @@ export { RpcServiceUnavailableError, IndexerTimeoutError, OperationAbortedError, + ValidationError, isConduitError, SUPPORTED_NETWORKS, CAIP2_TO_NETWORK, UNKNOWN_CONTRACT_ERROR_CODE, } from './errors.js'; -export type { ConduitContract } from './errors.js'; +export type { ConduitContract, ValidationIssue } from './errors.js'; export * from './types/index.js'; export type { GetStreamInfosOptions, GetStreamInfosResult, GetStreamInfosFailure } from './types/index.js'; export * from './adapters/index.js'; diff --git a/src/tests/builder-create-stream-args.test.ts b/src/tests/builder-create-stream-args.test.ts index 3f3088c..10f7e20 100644 --- a/src/tests/builder-create-stream-args.test.ts +++ b/src/tests/builder-create-stream-args.test.ts @@ -103,16 +103,16 @@ describe('StreamBuilder.toContractArgs()', () => { }); it('rejects a startTime in the past', () => { - expect(() => baseBuilder().startTime(1)).toThrow('startTime cannot be in the past'); + expect(() => baseBuilder().startTime(1).build()).toThrow('startTime cannot be in the past'); }); it('rejects a non-integer endTime', () => { - expect(() => baseBuilder().endTime(1.5)).toThrow('endTime must be a non-negative integer'); + expect(() => baseBuilder().endTime(1.5).build()).toThrow('endTime must be a non-negative integer'); }); it('rejects a non-boolean clawbackEnabled', () => { const builder = baseBuilder(); - expect(() => builder.clawbackEnabled('yes' as unknown as boolean)).toThrow('clawbackEnabled must be a boolean'); + expect(() => builder.clawbackEnabled('yes' as unknown as boolean).build()).toThrow('clawbackEnabled must be a boolean'); }); }); diff --git a/src/tests/builder-validation-bypass.test.ts b/src/tests/builder-validation-bypass.test.ts index d1c4d66..0a30730 100644 --- a/src/tests/builder-validation-bypass.test.ts +++ b/src/tests/builder-validation-bypass.test.ts @@ -151,12 +151,12 @@ describe('StreamBuilder address validation', () => { it('should reject invalid token address', () => { const builder = new StreamBuilder(); - expect(() => builder.token('not-a-valid-contract')).toThrow(/C-address/); + expect(() => builder.token('not-a-valid-contract').build()).toThrow(/C-address/); }); it('should reject empty token address', () => { const builder = new StreamBuilder(); - expect(() => builder.token('')).toThrow(/non-empty/); + expect(() => builder.token('').build()).toThrow(/non-empty/); }); it('should accept valid sender (G-address)', () => { @@ -166,7 +166,7 @@ describe('StreamBuilder address validation', () => { it('should reject invalid sender address', () => { const builder = new StreamBuilder(); - expect(() => builder.sender('not-a-valid-sender')).toThrow(/public key/); + expect(() => builder.sender('not-a-valid-sender').build()).toThrow(/public key/); }); it('should accept valid recipient (G-address)', () => { @@ -176,6 +176,6 @@ describe('StreamBuilder address validation', () => { it('should reject invalid recipient address', () => { const builder = new StreamBuilder(); - expect(() => builder.recipient('not-a-valid-recipient')).toThrow(/public key/); + expect(() => builder.recipient('not-a-valid-recipient').build()).toThrow(/public key/); }); }); diff --git a/src/tests/builder.test.ts b/src/tests/builder.test.ts index c3ef4e0..1e32be7 100644 --- a/src/tests/builder.test.ts +++ b/src/tests/builder.test.ts @@ -139,7 +139,8 @@ describe('StreamBuilder', () => { .sender('GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H') .recipient('GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA') .amount(1000) - .ratePerSecond(0); + .ratePerSecond(0) + .build(); expect(builder).toThrow('Invalid StreamBuilder parameter: ratePerSecond'); @@ -149,7 +150,8 @@ describe('StreamBuilder', () => { .sender('GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H') .recipient('GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA') .amount(1000) - .ratePerSecond(-1n); + .ratePerSecond(-1n) + .build(); expect(builderNeg).toThrow('Invalid StreamBuilder parameter: ratePerSecond'); }); diff --git a/src/tests/stream-builder-network-drop.test.ts b/src/tests/stream-builder-network-drop.test.ts index 022f5d1..5e5ed9a 100644 --- a/src/tests/stream-builder-network-drop.test.ts +++ b/src/tests/stream-builder-network-drop.test.ts @@ -16,7 +16,7 @@ describe('StreamBuilder Network Interruption & Payload Queueing Regression Tests expect(() => builder.build()).toThrow('Missing required parameters for StreamBuilder'); const nullTokenBuilder = new StreamBuilder(); - expect(() => nullTokenBuilder.token(null as any)).toThrow( + expect(() => nullTokenBuilder.token(null as any).build()).toThrow( 'Invalid StreamBuilder parameter: token must be a non-empty string' ); }); diff --git a/src/tests/stream-builder-validation-aggregate.test.ts b/src/tests/stream-builder-validation-aggregate.test.ts new file mode 100644 index 0000000..c44d3cc --- /dev/null +++ b/src/tests/stream-builder-validation-aggregate.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect } from 'vitest'; +import { StreamBuilder } from '../builder.js'; +import { ValidationError, isConduitError } from '../errors.js'; + +const VALID_TOKEN = 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526'; +const VALID_SENDER = 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H'; +const VALID_RECIPIENT = 'GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA'; + +describe('StreamBuilder: aggregate validation errors (#631)', () => { + it('aggregates multiple invalid fields into a single ValidationError with .issues[]', () => { + const builder = new StreamBuilder() + .token('invalid-token-contract') + .sender('invalid-sender-key') + .recipient('invalid-recipient-key') + .amount(-500) + .ratePerSecond(0); + + let caughtError: unknown; + try { + builder.build(); + } catch (err) { + caughtError = err; + } + + expect(caughtError).toBeInstanceOf(ValidationError); + expect(isConduitError(caughtError)).toBe(true); + + const validationError = caughtError as ValidationError; + expect(validationError.name).toBe('ValidationError'); + expect(validationError.issues).toHaveLength(5); + + const fields = validationError.issues.map((i) => i.field); + expect(fields).toContain('token'); + expect(fields).toContain('sender'); + expect(fields).toContain('recipient'); + expect(fields).toContain('amount'); + expect(fields).toContain('ratePerSecond'); + + // Individual issue messages must detail each invalid field + const tokenIssue = validationError.issues.find((i) => i.field === 'token'); + expect(tokenIssue?.message).toContain('C-address'); + + const senderIssue = validationError.issues.find((i) => i.field === 'sender'); + expect(senderIssue?.message).toContain('public key'); + + const recipientIssue = validationError.issues.find((i) => i.field === 'recipient'); + expect(recipientIssue?.message).toContain('public key'); + + const amountIssue = validationError.issues.find((i) => i.field === 'amount'); + expect(amountIssue?.message).toContain('positive finite number'); + + const rateIssue = validationError.issues.find((i) => i.field === 'ratePerSecond'); + expect(rateIssue?.message).toContain('positive finite number'); + }); + + it('aggregates all missing required fields into .issues[] when none are provided', () => { + const builder = new StreamBuilder(); + + let caughtError: unknown; + try { + builder.build(); + } catch (err) { + caughtError = err; + } + + expect(caughtError).toBeInstanceOf(ValidationError); + const validationError = caughtError as ValidationError; + + expect(validationError.issues).toHaveLength(4); + const missingFields = validationError.issues.map((i) => i.field); + expect(missingFields).toEqual(['token', 'sender', 'recipient', 'amount']); + + for (const issue of validationError.issues) { + expect(issue.message).toMatch(/^Missing required parameter:/); + } + + expect(validationError.message).toContain('Missing required parameters for StreamBuilder'); + }); + + it('aggregates mixed missing and invalid fields into .issues[]', () => { + // token is invalid, amount is invalid, sender and recipient are missing + const builder = new StreamBuilder() + .token('invalid-contract-format') + .amount(-10); + + let caughtError: unknown; + try { + builder.build(); + } catch (err) { + caughtError = err; + } + + expect(caughtError).toBeInstanceOf(ValidationError); + const validationError = caughtError as ValidationError; + + expect(validationError.issues).toHaveLength(4); + const issueFields = validationError.issues.map((i) => i.field); + expect(issueFields).toContain('token'); + expect(issueFields).toContain('sender'); + expect(issueFields).toContain('recipient'); + expect(issueFields).toContain('amount'); + + expect(validationError.message).toContain('Missing required parameters for StreamBuilder'); + expect(validationError.message).toContain('Invalid StreamBuilder parameter'); + }); + + it('exposes a validate() method that returns issues without throwing', () => { + const builder = new StreamBuilder() + .token('invalid-contract') + .amount(100); + + // validate() returns the list of issues for non-throwing inspection + const issues = builder.validate(); + expect(issues.length).toBeGreaterThan(0); + + const fields = issues.map((i) => i.field); + expect(fields).toContain('token'); + expect(fields).toContain('sender'); + expect(fields).toContain('recipient'); + }); + + it('returns an empty array from validate() when all required parameters are valid', () => { + const builder = new StreamBuilder() + .token(VALID_TOKEN) + .sender(VALID_SENDER) + .recipient(VALID_RECIPIENT) + .amount(1000); + + expect(builder.validate()).toEqual([]); + expect(() => builder.build()).not.toThrow(); + }); + + it('validates optional startTime, endTime, and clawbackEnabled constraints in aggregate', () => { + const pastTime = 1000; // Unix timestamp 1000 is far in the past + const builder = new StreamBuilder() + .token(VALID_TOKEN) + .sender(VALID_SENDER) + .recipient(VALID_RECIPIENT) + .amount(1000) + .startTime(pastTime) + .endTime(500) // endTime <= startTime + .clawbackEnabled('not-a-boolean' as unknown as boolean); + + let caughtError: unknown; + try { + builder.build(); + } catch (err) { + caughtError = err; + } + + expect(caughtError).toBeInstanceOf(ValidationError); + const validationError = caughtError as ValidationError; + + const fields = validationError.issues.map((i) => i.field); + expect(fields).toContain('startTime'); + expect(fields).toContain('endTime'); + expect(fields).toContain('clawbackEnabled'); + }); + + it('throws ValidationError from toContractArgs() when ratePerSecond is missing', () => { + const builder = new StreamBuilder() + .token(VALID_TOKEN) + .sender(VALID_SENDER) + .recipient(VALID_RECIPIENT) + .amount(1000); + + let caughtError: unknown; + try { + builder.toContractArgs(); + } catch (err) { + caughtError = err; + } + + expect(caughtError).toBeInstanceOf(ValidationError); + const validationError = caughtError as ValidationError; + expect(validationError.issues).toHaveLength(1); + expect(validationError.issues[0]?.field).toBe('ratePerSecond'); + expect(validationError.message).toContain('ratePerSecond is required'); + }); + + it('allows complete method chaining without premature exceptions', () => { + // Calling setters with invalid values must not throw before build() + expect(() => { + const builder = new StreamBuilder(); + builder + .token('bad-1') + .sender('bad-2') + .recipient('bad-3') + .amount(-1) + .ratePerSecond(-2) + .startTime(-3) + .endTime(-4) + .clawbackEnabled(123 as unknown as boolean); + }).not.toThrow(); + }); + + it('ValidationError formats single issue message cleanly', () => { + const singleIssue = new ValidationError([{ field: 'amount', message: 'amount must be positive' }]); + expect(singleIssue.message).toBe('amount must be positive'); + expect(singleIssue.issues).toHaveLength(1); + }); + + it('ValidationError formats multiple issues into a structured summary', () => { + const multiIssue = new ValidationError([ + { field: 'token', message: 'invalid token' }, + { field: 'sender', message: 'invalid sender' }, + ]); + expect(multiIssue.message).toContain('Validation failed with 2 issues:'); + expect(multiIssue.message).toContain('- [token] invalid token'); + expect(multiIssue.message).toContain('- [sender] invalid sender'); + }); +});