diff --git a/.gitignore b/.gitignore index cb697f2..9a996c0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,17 +18,9 @@ coverage/ .vitest/ .nyc_output/ test-results/ -junit.xml - -# Test snapshots (all formats) -**/__snapshots__/ -*.snap -*.snapshot -*.snapshot.json -testsnapshot/ -testSnapshot/ -test-snapshot/ -test-snapshots/ +.env +.env.local +*.env.* # Vitest cache .vitest-cache/ @@ -41,6 +33,27 @@ test-snapshots/ .DS_Store Thumbs.db +# Task / scratch files +*.log +task1.md +task2.md +task3.md +task4.md +somzilla.md + +# Test snapshots & temporary output +**/__snapshots__/ +*.snap +*.snapshot +*.snapshot.json +testsnapshot/ +testSnapshot/ +test-snapshot/ +test-snapshots/ +/tmp/ +*.profile.json +*.speedscope.json + # IDE / editor .vscode/ .idea/ @@ -49,9 +62,19 @@ Thumbs.db *~ # Logs -*.log npm-debug.log* yarn-error.log* +pnpm-debug.log* + +# Temp files +*.tmp +*.temp + +# Size-limit cache +.size-limit/ + +# Vitest UI +__vitest_browser__/ # Temp / scratch files /tmp/ diff --git a/package.json b/package.json index dcc8064..fa37145 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,21 @@ "types": "./dist/utils.d.ts", "import": "./dist/utils.js", "require": "./dist/utils.cjs" + }, + "./mock": { + "types": "./dist/mock/index.d.ts", + "import": "./dist/mock/index.js", + "require": "./dist/mock/index.cjs" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js", + "require": "./dist/react/index.cjs" + }, + "./telemetry": { + "types": "./dist/telemetryModule.d.ts", + "import": "./dist/telemetryModule.js", + "require": "./dist/telemetryModule.cjs" } }, "files": [ diff --git a/src/configValidator.ts b/src/configValidator.ts index 98083da..111cdd9 100644 --- a/src/configValidator.ts +++ b/src/configValidator.ts @@ -333,3 +333,162 @@ export class InvalidConfigError extends StellarSplitError { Object.setPrototypeOf(this, new.target.prototype); } } + +// --------------------------------------------------------------------------- +// #883 — ConfigurationError with structured field / value / hint fields +// --------------------------------------------------------------------------- + +/** + * The network shorthand accepted by {@link ConfigurationError} and the + * enhanced strict validators. + */ +export type NetworkShorthand = "testnet" | "mainnet"; + +/** Map from shorthand to official Stellar network passphrases. */ +export const NETWORK_PASSPHRASE_MAP: Record = { + testnet: "Test SDF Network ; September 2015", + mainnet: "Public Global Stellar Network ; September 2015", +}; + +/** + * Structured configuration error that pinpoints exactly which field is wrong, + * what value was supplied, and how to fix it. + * + * @example + * ```ts + * throw new ConfigurationError({ + * field: 'rpcUrl', + * value: 'ftp://example.com', + * hint: 'rpcUrl must use the https:// scheme.', + * }); + * ``` + */ +export class ConfigurationError extends StellarSplitError { + /** The name of the misconfigured field. */ + readonly field: string; + /** The value that was supplied (stringified). */ + readonly value: unknown; + /** A human-readable suggestion for how to fix the problem. */ + readonly hint: string; + + constructor(params: { field: string; value?: unknown; hint: string }) { + const { field, value, hint } = params; + const valueStr = + value === undefined ? "(not provided)" : JSON.stringify(value); + const message = `Configuration error on field "${field}": ${hint} (got ${valueStr})`; + super(message, "CONFIGURATION_ERROR", { field, value, hint }); + this.name = "ConfigurationError"; + this.field = field; + this.value = value; + this.hint = hint; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// --------------------------------------------------------------------------- +// Extended strict-validation helpers (required by issue #883) +// --------------------------------------------------------------------------- + +/** + * Validate a raw config object against the strict rules introduced in #883. + * + * Throws {@link ConfigurationError} on the first invalid field so the + * developer gets a clear, actionable message. + * + * Rules checked (in order): + * 1. `rpcUrl` — must be a valid **https** URL (http not allowed in strict mode) + * 2. `contractId` — must be a valid Stellar C-address (56 chars, C-prefix) + * 3. `network` — must be `"testnet"` or `"mainnet"` when provided + * 4. `networkPassphrase` vs `network` — must match if both are provided + */ +export function validateConfigStrict(config: { + rpcUrl: unknown; + contractId: unknown; + network?: unknown; + networkPassphrase?: unknown; +}): void { + // ---- rpcUrl --------------------------------------------------------------- + if (!config.rpcUrl || typeof config.rpcUrl !== "string") { + throw new ConfigurationError({ + field: "rpcUrl", + value: config.rpcUrl, + hint: "rpcUrl is required and must be a non-empty string with an https:// URL.", + }); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(config.rpcUrl as string); + } catch { + throw new ConfigurationError({ + field: "rpcUrl", + value: config.rpcUrl, + hint: "rpcUrl must be a valid URL, e.g. https://soroban-testnet.stellar.org", + }); + } + + if (parsedUrl.protocol !== "https:") { + throw new ConfigurationError({ + field: "rpcUrl", + value: config.rpcUrl, + hint: `rpcUrl must use the https:// scheme. Received "${parsedUrl.protocol}". Example: https://soroban-testnet.stellar.org`, + }); + } + + // ---- contractId ----------------------------------------------------------- + if (!config.contractId || typeof config.contractId !== "string") { + throw new ConfigurationError({ + field: "contractId", + value: config.contractId, + hint: "contractId is required and must be a valid Stellar C-address (56 characters, starts with 'C').", + }); + } + + if ( + !(config.contractId as string).startsWith("C") || + (config.contractId as string).length !== 56 + ) { + throw new ConfigurationError({ + field: "contractId", + value: config.contractId, + hint: "contractId must be a 56-character Stellar contract address starting with 'C'. Use StrKey.encodeContract() to generate one.", + }); + } + + try { + StrKey.decodeContract(config.contractId as string); + } catch { + throw new ConfigurationError({ + field: "contractId", + value: config.contractId, + hint: "contractId failed Stellar StrKey validation. Ensure you are using a properly encoded C-address.", + }); + } + + // ---- network -------------------------------------------------------------- + if (config.network !== undefined) { + if ( + config.network !== "testnet" && + config.network !== "mainnet" + ) { + throw new ConfigurationError({ + field: "network", + value: config.network, + hint: 'network must be either "testnet" or "mainnet".', + }); + } + + // ---- networkPassphrase vs network mismatch -------------------------------- + if (config.networkPassphrase !== undefined) { + const expected = + NETWORK_PASSPHRASE_MAP[config.network as NetworkShorthand]; + if (config.networkPassphrase !== expected) { + throw new ConfigurationError({ + field: "networkPassphrase", + value: config.networkPassphrase, + hint: `networkPassphrase does not match the selected network "${config.network}". Expected: "${expected}"`, + }); + } + } + } +} diff --git a/src/mock/index.ts b/src/mock/index.ts new file mode 100644 index 0000000..3b9bde7 --- /dev/null +++ b/src/mock/index.ts @@ -0,0 +1,327 @@ +/** + * @stellar-split/sdk/mock + * + * In-memory mock client for testing SDK consumers without a live RPC endpoint + * or deployed contract. State is stored in plain JS Maps; fully synchronous + * where possible. + * + * @example + * ```ts + * import { MockStellarSplitClient } from '@stellar-split/sdk/mock'; + * + * const mock = new MockStellarSplitClient(); + * mock.setInvoice('inv-1', { id: 'inv-1', status: 'Pending', ... }); + * const invoice = await mock.getInvoice('inv-1'); + * ``` + */ + +import type { + Invoice, + Payment, + InvoiceEvent, + Subscription, + SubscriptionOptions, + CreateInvoiceParams, + PaginatedResult, + PaginationOptions, +} from "../types.js"; +import type { TxResult } from "../client.js"; +import { InvoiceNotFoundError } from "../errors.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** A single entry in the call history produced by {@link MockStellarSplitClient}. */ +export interface CallRecord { + /** SDK method name that was called. */ + method: string; + /** Arguments passed to the method (serialised to a plain array). */ + args: unknown[]; + /** Unix timestamp (ms) when the call was made. */ + timestamp: number; +} + +// Callback type for invoice subscriptions +type InvoiceEventCallback = (event: InvoiceEvent) => void; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Generate a deterministic-looking fake tx hash. */ +function fakeTxHash(seed?: string): string { + const base = seed ?? String(Date.now()); + let h = 0; + for (let i = 0; i < base.length; i++) { + h = (Math.imul(31, h) + base.charCodeAt(i)) | 0; + } + return Math.abs(h).toString(16).padStart(64, "0"); +} + +/** Build a minimal in-memory Subscription object. */ +function makeSubscription( + invoiceId: string, + onUnsubscribe: () => void, +): Subscription { + let active = true; + let paused = false; + return { + unsubscribe() { + active = false; + onUnsubscribe(); + }, + pause() { + paused = true; + }, + resume() { + paused = false; + }, + getInvoiceId() { + return invoiceId; + }, + isActive() { + return active; + }, + isPaused() { + return paused; + }, + }; +} + +// --------------------------------------------------------------------------- +// MockStellarSplitClient +// --------------------------------------------------------------------------- + +/** + * Full in-memory implementation of the {@link StellarSplitClient} contract. + * + * Use in unit tests wherever you would normally pass a real client. + * All writes mutate the internal state map; reads reflect that state. + */ +export class MockStellarSplitClient { + /** Invoice store. */ + private _invoices = new Map(); + /** Sequential invoice ID counter. */ + private _nextId = 1; + /** All recorded calls. */ + private _calls: CallRecord[] = []; + /** Registered subscription callbacks, keyed by invoiceId. */ + private _listeners = new Map>(); + + // ------------------------------------------------------------------------- + // Test-helpers (not on the real client) + // ------------------------------------------------------------------------- + + /** + * Reset all internal state: clears invoices, call history, and listeners. + * Call this in `beforeEach` to guarantee test isolation. + */ + reset(): void { + this._invoices.clear(); + this._calls = []; + this._listeners.clear(); + this._nextId = 1; + } + + /** + * Pre-populate the mock store with a known invoice. + * Useful for arrange → act → assert test patterns. + * + * @param id - The invoice ID (string key). + * @param invoice - Partial invoice merged with defaults. + */ + setInvoice(id: string, invoice: Invoice): void { + this._invoices.set(id, { ...invoice, id }); + } + + /** + * Return all method calls recorded since the last {@link reset}. + */ + getCallHistory(): CallRecord[] { + return [...this._calls]; + } + + /** + * Trigger all active {@link subscribeInvoice} callbacks for a given invoice + * with a synthetic event. Lets test code drive real-time subscription logic. + * + * @param event - The {@link InvoiceEvent} to dispatch. + */ + simulateEvent(event: InvoiceEvent): void { + const listeners = this._listeners.get(event.invoiceId); + if (!listeners) return; + for (const cb of listeners) { + cb(event); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private _record(method: string, args: unknown[]): void { + this._calls.push({ method, args, timestamp: Date.now() }); + } + + private _getOrThrow(invoiceId: string): Invoice { + const invoice = this._invoices.get(invoiceId); + if (!invoice) throw new InvoiceNotFoundError(invoiceId); + return invoice; + } + + // ------------------------------------------------------------------------- + // StellarSplitClient interface — core methods + // ------------------------------------------------------------------------- + + async createInvoice( + params: CreateInvoiceParams, + ): Promise<{ invoiceId: string; txHash: string }> { + this._record("createInvoice", [params]); + const invoiceId = String(this._nextId++); + const invoice: Invoice = { + id: invoiceId, + creator: params.creator, + recipients: params.recipients, + token: params.token, + deadline: params.deadline, + funded: 0n, + status: "Pending", + payments: [], + memo: params.memo, + }; + this._invoices.set(invoiceId, invoice); + return { invoiceId, txHash: fakeTxHash(invoiceId) }; + } + + async pay(params: { + payer: string; + invoiceId: string; + amount: bigint; + donateOnFailure?: boolean; + }): Promise { + this._record("pay", [params]); + const invoice = this._getOrThrow(params.invoiceId); + const payment: Payment = { + payer: params.payer, + amount: params.amount, + donateOnFailure: params.donateOnFailure, + timestamp: Math.floor(Date.now() / 1000), + }; + const updated: Invoice = { + ...invoice, + funded: invoice.funded + params.amount, + payments: [...invoice.payments, payment], + }; + this._invoices.set(params.invoiceId, updated); + return { txHash: fakeTxHash(params.invoiceId + params.payer) }; + } + + async getInvoice(invoiceId: string): Promise { + this._record("getInvoice", [invoiceId]); + return this._getOrThrow(invoiceId); + } + + async getPayments(invoiceId: string): Promise { + this._record("getPayments", [invoiceId]); + return this._getOrThrow(invoiceId).payments; + } + + async getInvoicesByCreator( + creator: string, + options?: PaginationOptions, + ): Promise> { + this._record("getInvoicesByCreator", [creator, options]); + const limit = options?.limit ?? 20; + const all = [...this._invoices.values()] + .filter((inv) => inv.creator === creator) + .map((inv) => inv.id); + return { + items: all.slice(0, limit), + nextCursor: all.length > limit ? String(limit) : null, + total: all.length, + }; + } + + /** + * Subscribe to invoice events — matches the real client's `subscribeToInvoice` + * signature (returns an unsubscribe function, not a Subscription object). + * + * @param invoiceId - Invoice to watch. + * @param callback - Called for each dispatched event. + * @returns Unsubscribe function. + */ + subscribeToInvoice( + invoiceId: string, + callback: (event: InvoiceEvent) => void, + _optionsOrInterval?: unknown, + ): () => void { + this._record("subscribeToInvoice", [invoiceId]); + if (!this._listeners.has(invoiceId)) { + this._listeners.set(invoiceId, new Set()); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this._listeners.get(invoiceId)!.add(callback); + + return () => { + this._listeners.get(invoiceId)?.delete(callback); + }; + } + + /** + * Alternative subscribe API that returns a full {@link Subscription} object. + * Useful in tests that need pause/resume/isActive. + */ + subscribeInvoice( + invoiceId: string, + callback: (event: InvoiceEvent) => void, + _options?: SubscriptionOptions, + ): Subscription { + this._record("subscribeInvoice", [invoiceId]); + if (!this._listeners.has(invoiceId)) { + this._listeners.set(invoiceId, new Set()); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this._listeners.get(invoiceId)!.add(callback); + + return makeSubscription(invoiceId, () => { + this._listeners.get(invoiceId)?.delete(callback); + }); + } + + // ------------------------------------------------------------------------- + // Additional read methods commonly used by consumers + // ------------------------------------------------------------------------- + + async releaseInvoice( + invoiceId: string, + _releasedBy: string, + ): Promise { + this._record("releaseInvoice", [invoiceId, _releasedBy]); + const invoice = this._getOrThrow(invoiceId); + this._invoices.set(invoiceId, { ...invoice, status: "Released" }); + return { txHash: fakeTxHash(invoiceId + "release") }; + } + + async refundInvoice( + invoiceId: string, + _refundedBy: string, + ): Promise { + this._record("refundInvoice", [invoiceId, _refundedBy]); + const invoice = this._getOrThrow(invoiceId); + this._invoices.set(invoiceId, { ...invoice, status: "Refunded" }); + return { txHash: fakeTxHash(invoiceId + "refund") }; + } + + async cancelInvoice( + invoiceId: string, + _cancelledBy: string, + ): Promise { + this._record("cancelInvoice", [invoiceId, _cancelledBy]); + const invoice = this._getOrThrow(invoiceId); + this._invoices.set(invoiceId, { ...invoice, status: "Cancelled" }); + return { txHash: fakeTxHash(invoiceId + "cancel") }; + } +} + +export default MockStellarSplitClient; diff --git a/src/react/index.tsx b/src/react/index.tsx new file mode 100644 index 0000000..8169fdd --- /dev/null +++ b/src/react/index.tsx @@ -0,0 +1,436 @@ +/** + * @stellar-split/sdk/react + * + * React 18+ hooks for the StellarSplit SDK. Compatible with Next.js App Router. + * + * Usage: + * ```tsx + * import { StellarSplitProvider, useInvoice } from '@stellar-split/sdk/react'; + * + * function App() { + * return ( + * + * + * + * ); + * } + * + * function InvoiceView({ id }: { id: string }) { + * const { data, loading, error, refetch } = useInvoice(id); + * if (loading) return Loading…; + * if (error) return Error: {error.message}; + * return
{JSON.stringify(data, null, 2)}
; + * } + * ``` + */ + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import type { ReactNode } from "react"; +import type { Invoice, InvoiceEvent } from "../types.js"; +import type { StellarSplitClient } from "../client.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** ProtocolStats returned by {@link useProtocolStats}. */ +export interface ProtocolStats { + /** Total number of invoices created on-chain. */ + totalInvoices: number; + /** Total amount released in stroops. */ + totalReleased: bigint; + /** Number of currently pending invoices. */ + pendingInvoices: number; +} + +/** Filter options accepted by {@link useCreatorInvoices}. */ +export interface InvoiceFilter { + /** Only return invoices with this status. */ + status?: import("../types.js").InvoiceStatus; + /** Maximum number of items to return. */ + limit?: number; +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface StellarSplitContextValue { + client: StellarSplitClient; +} + +const StellarSplitContext = createContext( + null, +); + +/** Props for {@link StellarSplitProvider}. */ +export interface StellarSplitProviderProps { + /** A fully-initialised {@link StellarSplitClient} instance. */ + client: StellarSplitClient; + children: ReactNode; +} + +/** + * Context provider. Wrap your application (or test tree) with this component + * to give all child hooks access to the same SDK client instance. + */ +export function StellarSplitProvider({ + client, + children, +}: StellarSplitProviderProps): React.ReactElement { + const value = React.useMemo(() => ({ client }), [client]); + return ( + + {children} + + ); +} + +/** + * Access the {@link StellarSplitClient} provided by the nearest + * {@link StellarSplitProvider}. + * + * Throws if called outside of a provider. + */ +export function useStellarSplitClient(): StellarSplitClient { + const ctx = useContext(StellarSplitContext); + if (!ctx) { + throw new Error( + "useStellarSplitClient must be used inside a .", + ); + } + return ctx.client; +} + +// --------------------------------------------------------------------------- +// useInvoice +// --------------------------------------------------------------------------- + +/** Result returned by {@link useInvoice}. */ +export interface UseInvoiceResult { + data: Invoice | null; + loading: boolean; + error: Error | null; + /** Manually re-fetch the invoice from the contract. */ + refetch: () => Promise; +} + +/** + * Fetch a single invoice by ID. Automatically re-fetches whenever + * `invoiceId` changes. + * + * @param invoiceId - The on-chain invoice ID to load. + * @param client - Optional client override; defaults to context client. + */ +export function useInvoice( + invoiceId: string, + client?: StellarSplitClient, +): UseInvoiceResult { + const contextClient = useContext(StellarSplitContext)?.client; + const effectiveClient = client ?? contextClient; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + const fetch = useCallback(async () => { + if (!effectiveClient) { + setError( + new Error( + "No StellarSplitClient available. Provide one via or the client prop.", + ), + ); + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const invoice = await effectiveClient.getInvoice(invoiceId); + if (mountedRef.current) { + setData(invoice); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (mountedRef.current) { + setLoading(false); + } + } + }, [invoiceId, effectiveClient]); + + useEffect(() => { + mountedRef.current = true; + void fetch(); + return () => { + mountedRef.current = false; + }; + }, [fetch]); + + return { data, loading, error, refetch: fetch }; +} + +// --------------------------------------------------------------------------- +// useCreatorInvoices +// --------------------------------------------------------------------------- + +/** Result returned by {@link useCreatorInvoices}. */ +export interface UseCreatorInvoicesResult { + data: Invoice[]; + loading: boolean; + error: Error | null; + /** Whether more pages are available. */ + hasMore: boolean; + /** Load the next page and append to `data`. */ + loadMore: () => Promise; +} + +/** + * Fetch all invoices created by a given address, with optional filtering and + * built-in pagination via `loadMore`. + * + * @param creator - Stellar address of the invoice creator. + * @param filter - Optional filter options (status, limit). + * @param client - Optional client override. + */ +export function useCreatorInvoices( + creator: string, + filter?: InvoiceFilter, + client?: StellarSplitClient, +): UseCreatorInvoicesResult { + const contextClient = useContext(StellarSplitContext)?.client; + const effectiveClient = client ?? contextClient; + + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [cursor, setCursor] = useState(null); + const mountedRef = useRef(true); + + const pageSize = filter?.limit ?? 20; + + const fetchPage = useCallback( + async (nextCursor?: string | null, append = false) => { + if (!effectiveClient) { + setError( + new Error( + "No StellarSplitClient available. Provide one via or the client prop.", + ), + ); + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const result = await effectiveClient.getInvoicesByCreator(creator, { + limit: pageSize, + ...(nextCursor ? { cursor: nextCursor } : {}), + }); + + // result.items are IDs — fetch each invoice + const invoices = await Promise.all( + result.items.map((id) => effectiveClient.getInvoice(id)), + ); + + // Apply status filter client-side if requested + const filtered = filter?.status + ? invoices.filter((inv) => inv.status === filter.status) + : invoices; + + if (mountedRef.current) { + setData((prev) => (append ? [...prev, ...filtered] : filtered)); + setCursor(result.nextCursor); + setHasMore(result.nextCursor !== null); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (mountedRef.current) { + setLoading(false); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [creator, effectiveClient, pageSize, filter?.status], + ); + + useEffect(() => { + mountedRef.current = true; + void fetchPage(null, false); + return () => { + mountedRef.current = false; + }; + }, [fetchPage]); + + const loadMore = useCallback(async () => { + if (hasMore) { + await fetchPage(cursor, true); + } + }, [hasMore, cursor, fetchPage]); + + return { data, loading, error, hasMore, loadMore }; +} + +// --------------------------------------------------------------------------- +// useProtocolStats +// --------------------------------------------------------------------------- + +/** Result returned by {@link useProtocolStats}. */ +export interface UseProtocolStatsResult { + data: ProtocolStats | null; + loading: boolean; + error: Error | null; +} + +/** + * Load high-level protocol statistics. The stats are derived from the + * client's health-check and available on-chain aggregates. + * + * @param client - Optional client override. + */ +export function useProtocolStats( + client?: StellarSplitClient, +): UseProtocolStatsResult { + const contextClient = useContext(StellarSplitContext)?.client; + const effectiveClient = client ?? contextClient; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + + if (!effectiveClient) { + setError( + new Error( + "No StellarSplitClient available. Provide one via or the client prop.", + ), + ); + setLoading(false); + return; + } + + (async () => { + setLoading(true); + setError(null); + try { + // Use checkHealth if available, otherwise return a minimal default. + const health = await (effectiveClient as StellarSplitClient & { + checkHealth?(): Promise; + }).checkHealth?.(); + + if (mountedRef.current) { + // Build a minimal ProtocolStats from whatever health exposes. + const stats: ProtocolStats = { + totalInvoices: 0, + totalReleased: 0n, + pendingInvoices: 0, + ...((health as Partial) ?? {}), + }; + setData(stats); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (mountedRef.current) { + setLoading(false); + } + } + })(); + + return () => { + mountedRef.current = false; + }; + }, [effectiveClient]); + + return { data, loading, error }; +} + +// --------------------------------------------------------------------------- +// useInvoiceStream +// --------------------------------------------------------------------------- + +/** Result returned by {@link useInvoiceStream}. */ +export interface UseInvoiceStreamResult { + /** The most recently received event, or null before any event fires. */ + latestEvent: InvoiceEvent | null; + /** Whether the subscription is currently active. */ + isConnected: boolean; + error: Error | null; +} + +/** + * Subscribe to real-time events for a single invoice. The subscription is + * automatically torn down when the component unmounts. + * + * @param invoiceId - The invoice ID to subscribe to. + * @param client - Optional client override. + */ +export function useInvoiceStream( + invoiceId: string, + client?: StellarSplitClient, +): UseInvoiceStreamResult { + const contextClient = useContext(StellarSplitContext)?.client; + const effectiveClient = client ?? contextClient; + + const [latestEvent, setLatestEvent] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const subRef = useRef<(() => void) | null>(null); + + useEffect(() => { + if (!effectiveClient || !invoiceId) { + setError( + new Error( + "No StellarSplitClient available. Provide one via or the client prop.", + ), + ); + return; + } + + try { + // subscribeToInvoice on StellarSplitClient uses SSEInvoiceEvent (from sse.ts) + // which has a different shape from the InvoiceEvent in types.ts. + // We cast via unknown to bridge the two type worlds at this integration point. + const handler = (event: unknown) => { + setLatestEvent(event as InvoiceEvent); + }; + const unsubscribe = (effectiveClient.subscribeToInvoice as unknown as ( + invoiceId: string, + handler: (event: unknown) => void, + ) => () => void)(invoiceId, handler); + subRef.current = unsubscribe; + setIsConnected(true); + setError(null); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + setIsConnected(false); + } + + return () => { + subRef.current?.(); + subRef.current = null; + setIsConnected(false); + }; + }, [invoiceId, effectiveClient]); + + return { latestEvent, isConnected, error }; +} diff --git a/src/telemetryModule.ts b/src/telemetryModule.ts new file mode 100644 index 0000000..3d0a4dd --- /dev/null +++ b/src/telemetryModule.ts @@ -0,0 +1,264 @@ +/** + * SDK Telemetry Module (#885) + * + * Opt-in, local-only usage metrics. No data leaves the developer's + * infrastructure unless an `endpoint` is explicitly configured. + * + * Features: + * - Per-method call counts, error counts and latency percentiles (p50/p95/p99) + * - `getMetrics()` returns an in-memory snapshot + * - `resetMetrics()` clears all counters + * - When `endpoint` is set, a periodic JSON POST flush is scheduled + * - Disabled by default (`enabled: false`) + * + * @example + * ```ts + * import { StellarSplitTelemetry } from '@stellar-split/sdk/telemetry'; + * + * const tel = new StellarSplitTelemetry({ enabled: true, flushIntervalMs: 30_000 }); + * tel.record('getInvoice', 42, true); + * const snap = tel.getMetrics(); + * console.log(snap.methods['getInvoice'].p95LatencyMs); + * ``` + */ + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Configuration for the telemetry module. */ +export interface TelemetryConfig { + /** + * Whether telemetry collection is active. Defaults to `false` (opt-in). + * When false, `record()` is a no-op and no timers are started. + */ + enabled: boolean; + /** + * How often (in milliseconds) to flush accumulated metrics to `endpoint`. + * Only meaningful when `endpoint` is also set. Default: 60_000. + */ + flushIntervalMs: number; + /** + * Optional HTTP endpoint to POST metric snapshots to. + * When omitted, metrics are only kept in-memory and never sent anywhere. + */ + endpoint?: string; +} + +/** Per-method statistics. */ +export interface MethodMetrics { + /** Total number of calls recorded for this method. */ + callCount: number; + /** Number of calls that resulted in an error. */ + errorCount: number; + /** 50th-percentile latency in milliseconds, or 0 when no calls recorded. */ + p50LatencyMs: number; + /** 95th-percentile latency in milliseconds, or 0 when no calls recorded. */ + p95LatencyMs: number; + /** 99th-percentile latency in milliseconds, or 0 when no calls recorded. */ + p99LatencyMs: number; +} + +/** Complete metrics snapshot returned by {@link StellarSplitTelemetry.getMetrics}. */ +export interface TelemetrySnapshot { + /** Per-method statistics, keyed by method name. */ + methods: Record; + /** Unix timestamp (ms) when metrics collection started (or last reset). */ + startedAt: number; + /** Unix timestamp (ms) of the snapshot. */ + snapshotAt: number; +} + +// --------------------------------------------------------------------------- +// Internal accumulator +// --------------------------------------------------------------------------- + +interface MethodAccumulator { + callCount: number; + errorCount: number; + /** All recorded latency samples in ms (unsorted). */ + latencySamples: number[]; +} + +// --------------------------------------------------------------------------- +// Percentile helper +// --------------------------------------------------------------------------- + +/** + * Calculate the p-th percentile of a sorted array. + * Uses nearest-rank method. Returns 0 for an empty array. + */ +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + if (sorted.length === 1) return sorted[0] ?? 0; + // nearest rank + const rank = Math.ceil((p / 100) * sorted.length); + return sorted[Math.min(rank, sorted.length) - 1] ?? 0; +} + +// --------------------------------------------------------------------------- +// StellarSplitTelemetry +// --------------------------------------------------------------------------- + +/** + * Opt-in telemetry module that tracks per-method SDK usage metrics. + * + * Create a single instance and pass it to every component that needs to + * record metrics. Use {@link getMetrics} to read the current snapshot and + * {@link resetMetrics} to clear all counters. + */ +export class StellarSplitTelemetry { + private _config: TelemetryConfig; + private _accumulators = new Map(); + private _startedAt = Date.now(); + private _flushTimer: ReturnType | null = null; + + constructor(config: Partial = {}) { + this._config = { + enabled: false, + flushIntervalMs: 60_000, + ...config, + }; + + if (this._config.enabled && this._config.endpoint) { + this._scheduleFlush(); + } + } + + // ------------------------------------------------------------------------- + // Core API + // ------------------------------------------------------------------------- + + /** + * Record a single method invocation. + * + * This is a no-op when `enabled` is `false`, so wrapping every SDK call + * with `record()` adds zero overhead in the default configuration. + * + * @param method - The SDK method name (e.g. "getInvoice"). + * @param latencyMs - Call duration in milliseconds. + * @param success - Whether the call completed without throwing. + */ + record(method: string, latencyMs: number, success: boolean): void { + if (!this._config.enabled) return; + + let acc = this._accumulators.get(method); + if (!acc) { + acc = { callCount: 0, errorCount: 0, latencySamples: [] }; + this._accumulators.set(method, acc); + } + + acc.callCount += 1; + if (!success) acc.errorCount += 1; + acc.latencySamples.push(latencyMs); + } + + /** + * Return a point-in-time snapshot of all accumulated metrics. + * The returned object is a deep copy — subsequent `record()` calls do not + * mutate it. + */ + getMetrics(): TelemetrySnapshot { + const methods: Record = {}; + + for (const [name, acc] of this._accumulators) { + const sorted = [...acc.latencySamples].sort((a, b) => a - b); + methods[name] = { + callCount: acc.callCount, + errorCount: acc.errorCount, + p50LatencyMs: percentile(sorted, 50), + p95LatencyMs: percentile(sorted, 95), + p99LatencyMs: percentile(sorted, 99), + }; + } + + return { + methods, + startedAt: this._startedAt, + snapshotAt: Date.now(), + }; + } + + /** + * Clear all accumulated counters and latency samples. + * The `startedAt` timestamp is reset to now. + */ + resetMetrics(): void { + this._accumulators.clear(); + this._startedAt = Date.now(); + } + + /** + * Immediately flush accumulated metrics to the configured `endpoint`. + * Resolves silently if no endpoint is configured or telemetry is disabled. + * Never rejects — flush failures are swallowed to avoid disrupting callers. + */ + async flush(): Promise { + if (!this._config.enabled || !this._config.endpoint) return; + + const snapshot = this.getMetrics(); + try { + await fetch(this._config.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(snapshot), + }); + } catch { + // Telemetry must never break the SDK — silently swallow flush errors. + } + } + + /** + * Stop the periodic flush timer and release resources. + * Call this when the client is shut down to avoid dangling timers in tests. + */ + destroy(): void { + if (this._flushTimer !== null) { + clearInterval(this._flushTimer); + this._flushTimer = null; + } + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private _scheduleFlush(): void { + if (this._flushTimer !== null) return; + this._flushTimer = setInterval(() => { + void this.flush(); + }, this._config.flushIntervalMs); + } +} + +// --------------------------------------------------------------------------- +// Convenience wrapper — wrap any async fn and record its metrics +// --------------------------------------------------------------------------- + +/** + * Wrap an async function so that every invocation is automatically recorded + * by a {@link StellarSplitTelemetry} instance. + * + * @example + * ```ts + * const trackedGetInvoice = withTelemetry(tel, 'getInvoice', (id) => client.getInvoice(id)); + * const invoice = await trackedGetInvoice('123'); + * ``` + */ +export function withTelemetry( + tel: StellarSplitTelemetry, + method: string, + fn: (...args: TArgs) => Promise, +): (...args: TArgs) => Promise { + return async (...args: TArgs): Promise => { + const start = Date.now(); + try { + const result = await fn(...args); + tel.record(method, Date.now() - start, true); + return result; + } catch (err) { + tel.record(method, Date.now() - start, false); + throw err; + } + }; +} diff --git a/test/configValidatorStrict.test.ts b/test/configValidatorStrict.test.ts new file mode 100644 index 0000000..67243d6 --- /dev/null +++ b/test/configValidatorStrict.test.ts @@ -0,0 +1,328 @@ +/** + * Tests for #883 — ConfigurationError and strict config validation. + */ + +import { describe, it, expect } from "vitest"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { + ConfigurationError, + validateConfigStrict, + NETWORK_PASSPHRASE_MAP, +} from "../src/configValidator.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function validContractId(): string { + return StrKey.encodeContract(Keypair.random().rawPublicKey()); +} + +// --------------------------------------------------------------------------- +// ConfigurationError shape +// --------------------------------------------------------------------------- + +describe("ConfigurationError", () => { + it("has a field, value, and hint", () => { + const err = new ConfigurationError({ + field: "rpcUrl", + value: "ftp://bad", + hint: "Must use https://", + }); + + expect(err.field).toBe("rpcUrl"); + expect(err.value).toBe("ftp://bad"); + expect(err.hint).toBe("Must use https://"); + }); + + it("is an instance of Error", () => { + const err = new ConfigurationError({ field: "x", hint: "y" }); + expect(err).toBeInstanceOf(Error); + }); + + it("message includes field name and hint", () => { + const err = new ConfigurationError({ + field: "contractId", + hint: "Must start with C", + }); + expect(err.message).toContain("contractId"); + expect(err.message).toContain("Must start with C"); + }); + + it("handles missing value gracefully", () => { + const err = new ConfigurationError({ field: "rpcUrl", hint: "required" }); + expect(err.value).toBeUndefined(); + expect(err.message).toContain("not provided"); + }); + + it("code is CONFIGURATION_ERROR", () => { + const err = new ConfigurationError({ field: "f", hint: "h" }); + expect(err.code).toBe("CONFIGURATION_ERROR"); + }); + + it("instanceof check works across prototype chain", () => { + const err = new ConfigurationError({ field: "f", hint: "h" }); + expect(err instanceof ConfigurationError).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// validateConfigStrict — valid config +// --------------------------------------------------------------------------- + +describe("validateConfigStrict — valid config", () => { + it("passes with minimum valid config", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://soroban-testnet.stellar.org", + contractId: validContractId(), + }), + ).not.toThrow(); + }); + + it("passes with testnet shorthand and matching passphrase", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://soroban-testnet.stellar.org", + contractId: validContractId(), + network: "testnet", + networkPassphrase: NETWORK_PASSPHRASE_MAP.testnet, + }), + ).not.toThrow(); + }); + + it("passes with mainnet shorthand and matching passphrase", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://horizon.stellar.org", + contractId: validContractId(), + network: "mainnet", + networkPassphrase: NETWORK_PASSPHRASE_MAP.mainnet, + }), + ).not.toThrow(); + }); + + it("passes when only network is provided (no passphrase)", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://soroban-testnet.stellar.org", + contractId: validContractId(), + network: "testnet", + }), + ).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// validateConfigStrict — rpcUrl +// --------------------------------------------------------------------------- + +describe("validateConfigStrict — rpcUrl validation", () => { + it("throws ConfigurationError for missing rpcUrl", () => { + expect(() => + validateConfigStrict({ rpcUrl: "", contractId: validContractId() }), + ).toThrow(ConfigurationError); + }); + + it("throws for non-string rpcUrl", () => { + expect(() => + validateConfigStrict({ rpcUrl: 42, contractId: validContractId() }), + ).toThrow(ConfigurationError); + }); + + it("throws for a malformed URL", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "not-a-url", + contractId: validContractId(), + }), + ).toThrow(ConfigurationError); + }); + + it("throws for http:// (not https)", () => { + const err = (() => { + try { + validateConfigStrict({ + rpcUrl: "http://insecure.example.com", + contractId: validContractId(), + }); + } catch (e) { + return e; + } + })(); + expect(err).toBeInstanceOf(ConfigurationError); + expect((err as ConfigurationError).field).toBe("rpcUrl"); + }); + + it("throws for ftp:// scheme", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "ftp://example.com", + contractId: validContractId(), + }), + ).toThrow(ConfigurationError); + }); + + it("ConfigurationError.field is 'rpcUrl'", () => { + let caught: ConfigurationError | null = null; + try { + validateConfigStrict({ rpcUrl: "http://bad", contractId: validContractId() }); + } catch (e) { + caught = e as ConfigurationError; + } + expect(caught?.field).toBe("rpcUrl"); + }); +}); + +// --------------------------------------------------------------------------- +// validateConfigStrict — contractId +// --------------------------------------------------------------------------- + +describe("validateConfigStrict — contractId validation", () => { + it("throws for missing contractId", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: "", + }), + ).toThrow(ConfigurationError); + }); + + it("throws for a non-C-prefixed string", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: "GABC123", + }), + ).toThrow(ConfigurationError); + }); + + it("throws for a string that is 55 characters (too short)", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: "C" + "A".repeat(54), + }), + ).toThrow(ConfigurationError); + }); + + it("throws for a string that is 57 characters (too long)", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: "C" + "A".repeat(56), + }), + ).toThrow(ConfigurationError); + }); + + it("throws for a 56-char C-prefixed string with invalid StrKey checksum", () => { + // Construct an invalid C-address that starts with C and is 56 chars + expect(() => + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: "C" + "Z".repeat(55), + }), + ).toThrow(ConfigurationError); + }); + + it("ConfigurationError.field is 'contractId' on invalid address", () => { + let caught: ConfigurationError | null = null; + try { + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: "GABC", + }); + } catch (e) { + caught = e as ConfigurationError; + } + expect(caught?.field).toBe("contractId"); + }); +}); + +// --------------------------------------------------------------------------- +// validateConfigStrict — network +// --------------------------------------------------------------------------- + +describe("validateConfigStrict — network validation", () => { + it("throws for an unrecognised network shorthand", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: validContractId(), + network: "staging", + }), + ).toThrow(ConfigurationError); + }); + + it("ConfigurationError.field is 'network' for bad shorthand", () => { + let caught: ConfigurationError | null = null; + try { + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: validContractId(), + network: "devnet", + }); + } catch (e) { + caught = e as ConfigurationError; + } + expect(caught?.field).toBe("network"); + }); +}); + +// --------------------------------------------------------------------------- +// validateConfigStrict — passphrase mismatch +// --------------------------------------------------------------------------- + +describe("validateConfigStrict — networkPassphrase mismatch", () => { + it("throws when passphrase doesn't match testnet", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: validContractId(), + network: "testnet", + networkPassphrase: NETWORK_PASSPHRASE_MAP.mainnet, + }), + ).toThrow(ConfigurationError); + }); + + it("throws when passphrase doesn't match mainnet", () => { + expect(() => + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: validContractId(), + network: "mainnet", + networkPassphrase: NETWORK_PASSPHRASE_MAP.testnet, + }), + ).toThrow(ConfigurationError); + }); + + it("ConfigurationError.field is 'networkPassphrase' on mismatch", () => { + let caught: ConfigurationError | null = null; + try { + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: validContractId(), + network: "testnet", + networkPassphrase: "Wrong passphrase", + }); + } catch (e) { + caught = e as ConfigurationError; + } + expect(caught?.field).toBe("networkPassphrase"); + }); + + it("hint contains expected passphrase text", () => { + let caught: ConfigurationError | null = null; + try { + validateConfigStrict({ + rpcUrl: "https://example.com", + contractId: validContractId(), + network: "testnet", + networkPassphrase: "bad passphrase", + }); + } catch (e) { + caught = e as ConfigurationError; + } + expect(caught?.hint).toContain(NETWORK_PASSPHRASE_MAP.testnet); + }); +}); diff --git a/test/mockClient.test.ts b/test/mockClient.test.ts new file mode 100644 index 0000000..dc66acd --- /dev/null +++ b/test/mockClient.test.ts @@ -0,0 +1,431 @@ +/** + * Tests for #882 — MockStellarSplitClient + * Verifies happy-path parity with the real client interface. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { MockStellarSplitClient } from "../src/mock/index.js"; +import type { CallRecord } from "../src/mock/index.js"; +import { InvoiceNotFoundError } from "../src/errors.js"; +import type { Invoice, InvoiceEvent } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "1", + creator: "GCREATOR000000000000000000000000000000000000000000000000", + recipients: [ + { + address: "GRECIPIENT0000000000000000000000000000000000000000000000", + amount: 100n, + }, + ], + token: "USDC_CONTRACT", + deadline: Math.floor(Date.now() / 1000) + 86400, + funded: 0n, + status: "Pending", + payments: [], + ...overrides, + }; +} + +function makeCreateParams() { + return { + creator: "GCREATOR000000000000000000000000000000000000000000000000", + recipients: [ + { + address: "GRECIPIENT0000000000000000000000000000000000000000000000", + amount: 1000n, + }, + ], + token: "USDC", + deadline: Math.floor(Date.now() / 1000) + 86400, + }; +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe("MockStellarSplitClient", () => { + let mock: MockStellarSplitClient; + + beforeEach(() => { + mock = new MockStellarSplitClient(); + }); + + // ------------------------------------------------------------------------- + // reset() + // ------------------------------------------------------------------------- + describe("reset()", () => { + it("clears invoices, call history and listeners", async () => { + await mock.createInvoice(makeCreateParams()); + mock.reset(); + + expect(mock.getCallHistory()).toHaveLength(0); + await expect(mock.getInvoice("1")).rejects.toBeInstanceOf( + InvoiceNotFoundError, + ); + }); + + it("resets the ID counter so IDs start from 1 again", async () => { + await mock.createInvoice(makeCreateParams()); + mock.reset(); + const { invoiceId } = await mock.createInvoice(makeCreateParams()); + expect(invoiceId).toBe("1"); + }); + }); + + // ------------------------------------------------------------------------- + // setInvoice() + // ------------------------------------------------------------------------- + describe("setInvoice()", () => { + it("pre-populates a known invoice for test setup", async () => { + const invoice = makeInvoice({ id: "test-42" }); + mock.setInvoice("test-42", invoice); + const fetched = await mock.getInvoice("test-42"); + expect(fetched.id).toBe("test-42"); + }); + + it("overwrites an existing invoice", async () => { + mock.setInvoice("1", makeInvoice({ id: "1", status: "Pending" })); + mock.setInvoice("1", makeInvoice({ id: "1", status: "Released" })); + const fetched = await mock.getInvoice("1"); + expect(fetched.status).toBe("Released"); + }); + + it("always sets the id field to the provided key", async () => { + const inv = makeInvoice({ id: "ignored" }); + mock.setInvoice("real-id", inv); + const fetched = await mock.getInvoice("real-id"); + expect(fetched.id).toBe("real-id"); + }); + }); + + // ------------------------------------------------------------------------- + // getCallHistory() + // ------------------------------------------------------------------------- + describe("getCallHistory()", () => { + it("returns an empty array initially", () => { + expect(mock.getCallHistory()).toEqual([]); + }); + + it("records each method call with args and timestamp", async () => { + await mock.createInvoice(makeCreateParams()); + await mock.getInvoice("1").catch(() => {}); + + const history = mock.getCallHistory(); + expect(history).toHaveLength(2); + + const [first, second] = history as [CallRecord, CallRecord]; + expect(first.method).toBe("createInvoice"); + expect(second.method).toBe("getInvoice"); + expect(typeof first.timestamp).toBe("number"); + }); + + it("returns a copy — mutations do not affect the internal list", () => { + const h1 = mock.getCallHistory(); + h1.push({ method: "fake", args: [], timestamp: 0 }); + const h2 = mock.getCallHistory(); + expect(h2).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // simulateEvent() + // ------------------------------------------------------------------------- + describe("simulateEvent()", () => { + it("calls active subscription callbacks", () => { + const cb = vi.fn(); + const sub = mock.subscribeInvoice("inv-1", cb); + + const event: InvoiceEvent = { + type: "payment", + invoiceId: "inv-1", + ledger: 100, + timestamp: Date.now(), + eventId: "evt-1", + payer: "GPAYER", + amount: 500n, + }; + + mock.simulateEvent(event); + expect(cb).toHaveBeenCalledOnce(); + expect(cb).toHaveBeenCalledWith(event); + + sub.unsubscribe(); + }); + + it("does not call callbacks for a different invoiceId", () => { + const cb = vi.fn(); + mock.subscribeInvoice("inv-A", cb); + + mock.simulateEvent({ + type: "payment", + invoiceId: "inv-B", + ledger: 1, + timestamp: 0, + eventId: "e", + payer: "G", + amount: 0n, + }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it("stops calling a callback after unsubscribe", () => { + const cb = vi.fn(); + const sub = mock.subscribeInvoice("inv-1", cb); + sub.unsubscribe(); + + mock.simulateEvent({ + type: "created", + invoiceId: "inv-1", + ledger: 1, + timestamp: 0, + eventId: "e", + creator: "G", + recipients: [], + token: "T", + deadline: 0, + }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it("is a no-op for unknown invoiceIds", () => { + expect(() => + mock.simulateEvent({ + type: "released", + invoiceId: "nope", + ledger: 1, + timestamp: 0, + eventId: "e", + releasedBy: "G", + amount: 0n, + }), + ).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // createInvoice() + // ------------------------------------------------------------------------- + describe("createInvoice()", () => { + it("returns invoiceId and txHash", async () => { + const { invoiceId, txHash } = await mock.createInvoice( + makeCreateParams(), + ); + expect(invoiceId).toBe("1"); + expect(typeof txHash).toBe("string"); + expect(txHash.length).toBeGreaterThan(0); + }); + + it("stores the invoice so getInvoice works immediately", async () => { + const params = makeCreateParams(); + const { invoiceId } = await mock.createInvoice(params); + const invoice = await mock.getInvoice(invoiceId); + expect(invoice.creator).toBe(params.creator); + expect(invoice.status).toBe("Pending"); + expect(invoice.funded).toBe(0n); + }); + + it("increments IDs for successive calls", async () => { + const a = await mock.createInvoice(makeCreateParams()); + const b = await mock.createInvoice(makeCreateParams()); + expect(a.invoiceId).toBe("1"); + expect(b.invoiceId).toBe("2"); + }); + }); + + // ------------------------------------------------------------------------- + // pay() + // ------------------------------------------------------------------------- + describe("pay()", () => { + it("returns a txHash", async () => { + const { invoiceId } = await mock.createInvoice(makeCreateParams()); + const result = await mock.pay({ + payer: "GPAYER", + invoiceId, + amount: 500n, + }); + expect(typeof result.txHash).toBe("string"); + }); + + it("increases funded and records the payment", async () => { + const { invoiceId } = await mock.createInvoice(makeCreateParams()); + await mock.pay({ payer: "GPAYER", invoiceId, amount: 600n }); + const inv = await mock.getInvoice(invoiceId); + expect(inv.funded).toBe(600n); + expect(inv.payments).toHaveLength(1); + expect(inv.payments[0]?.amount).toBe(600n); + }); + + it("throws InvoiceNotFoundError for unknown invoiceId", async () => { + await expect( + mock.pay({ payer: "G", invoiceId: "nope", amount: 1n }), + ).rejects.toBeInstanceOf(InvoiceNotFoundError); + }); + }); + + // ------------------------------------------------------------------------- + // getInvoice() + // ------------------------------------------------------------------------- + describe("getInvoice()", () => { + it("returns the invoice when it exists", async () => { + mock.setInvoice("x", makeInvoice({ id: "x" })); + const inv = await mock.getInvoice("x"); + expect(inv.id).toBe("x"); + }); + + it("throws InvoiceNotFoundError when missing", async () => { + await expect(mock.getInvoice("nope")).rejects.toBeInstanceOf( + InvoiceNotFoundError, + ); + }); + }); + + // ------------------------------------------------------------------------- + // getPayments() + // ------------------------------------------------------------------------- + describe("getPayments()", () => { + it("returns empty array initially", async () => { + mock.setInvoice("p", makeInvoice({ id: "p" })); + const payments = await mock.getPayments("p"); + expect(payments).toEqual([]); + }); + + it("returns payments after pay()", async () => { + const { invoiceId } = await mock.createInvoice(makeCreateParams()); + await mock.pay({ payer: "G", invoiceId, amount: 100n }); + const payments = await mock.getPayments(invoiceId); + expect(payments).toHaveLength(1); + }); + }); + + // ------------------------------------------------------------------------- + // getInvoicesByCreator() + // ------------------------------------------------------------------------- + describe("getInvoicesByCreator()", () => { + it("returns only invoices belonging to the given creator", async () => { + await mock.createInvoice(makeCreateParams()); + await mock.createInvoice({ + ...makeCreateParams(), + creator: "GCREATOR2", + }); + const result = await mock.getInvoicesByCreator( + "GCREATOR000000000000000000000000000000000000000000000000", + ); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it("respects limit option", async () => { + for (let i = 0; i < 5; i++) await mock.createInvoice(makeCreateParams()); + const result = await mock.getInvoicesByCreator( + "GCREATOR000000000000000000000000000000000000000000000000", + { limit: 3 }, + ); + expect(result.items).toHaveLength(3); + expect(result.nextCursor).not.toBeNull(); + }); + + it("returns nextCursor: null when all fit on one page", async () => { + await mock.createInvoice(makeCreateParams()); + const result = await mock.getInvoicesByCreator( + "GCREATOR000000000000000000000000000000000000000000000000", + { limit: 20 }, + ); + expect(result.nextCursor).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // subscribeInvoice() + // ------------------------------------------------------------------------- + describe("subscribeInvoice()", () => { + it("returns a Subscription with the correct invoiceId", () => { + const sub = mock.subscribeInvoice("inv-1", () => {}); + expect(sub.getInvoiceId()).toBe("inv-1"); + }); + + it("isActive() is true after creation", () => { + const sub = mock.subscribeInvoice("inv-1", () => {}); + expect(sub.isActive()).toBe(true); + sub.unsubscribe(); + }); + + it("isActive() is false after unsubscribe", () => { + const sub = mock.subscribeInvoice("inv-1", () => {}); + sub.unsubscribe(); + expect(sub.isActive()).toBe(false); + }); + + it("isPaused() toggles with pause/resume", () => { + const sub = mock.subscribeInvoice("inv-1", () => {}); + expect(sub.isPaused()).toBe(false); + sub.pause(); + expect(sub.isPaused()).toBe(true); + sub.resume(); + expect(sub.isPaused()).toBe(false); + sub.unsubscribe(); + }); + + it("supports multiple callbacks on the same invoiceId", () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(); + const sub1 = mock.subscribeInvoice("inv-1", cb1); + const sub2 = mock.subscribeInvoice("inv-1", cb2); + + mock.simulateEvent({ + type: "cancelled", + invoiceId: "inv-1", + ledger: 1, + timestamp: 0, + eventId: "e", + cancelledBy: "G", + }); + + expect(cb1).toHaveBeenCalledOnce(); + expect(cb2).toHaveBeenCalledOnce(); + + sub1.unsubscribe(); + sub2.unsubscribe(); + }); + }); + + // ------------------------------------------------------------------------- + // State-mutating helpers (release / refund / cancel) + // ------------------------------------------------------------------------- + describe("releaseInvoice / refundInvoice / cancelInvoice", () => { + it("releaseInvoice sets status to Released", async () => { + mock.setInvoice("r", makeInvoice({ id: "r" })); + await mock.releaseInvoice("r", "GCREATOR"); + const inv = await mock.getInvoice("r"); + expect(inv.status).toBe("Released"); + }); + + it("refundInvoice sets status to Refunded", async () => { + mock.setInvoice("rf", makeInvoice({ id: "rf" })); + await mock.refundInvoice("rf", "GCREATOR"); + const inv = await mock.getInvoice("rf"); + expect(inv.status).toBe("Refunded"); + }); + + it("cancelInvoice sets status to Cancelled", async () => { + mock.setInvoice("c", makeInvoice({ id: "c" })); + await mock.cancelInvoice("c", "GCREATOR"); + const inv = await mock.getInvoice("c"); + expect(inv.status).toBe("Cancelled"); + }); + + it("throws InvoiceNotFoundError for unknown id", async () => { + await expect( + mock.releaseInvoice("nope", "G"), + ).rejects.toBeInstanceOf(InvoiceNotFoundError); + }); + }); +}); diff --git a/test/reactHooks.test.tsx b/test/reactHooks.test.tsx new file mode 100644 index 0000000..a6fd811 --- /dev/null +++ b/test/reactHooks.test.tsx @@ -0,0 +1,363 @@ +/** + * Tests for #884 — React hooks (useInvoice, useCreatorInvoices, useProtocolStats, + * useInvoiceStream, StellarSplitProvider) + * + * Uses @testing-library/react renderHook API + MockStellarSplitClient. + */ + +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { + StellarSplitProvider, + useInvoice, + useCreatorInvoices, + useProtocolStats, + useInvoiceStream, + useStellarSplitClient, +} from "../src/react/index.js"; +import { MockStellarSplitClient } from "../src/mock/index.js"; +import type { Invoice } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function makeInvoice(id: string, creator = "GCREATOR"): Invoice { + return { + id, + creator, + recipients: [{ address: "GRECIP", amount: 100n }], + token: "USDC", + deadline: Math.floor(Date.now() / 1000) + 86400, + funded: 0n, + status: "Pending", + payments: [], + }; +} + +/** Wraps children in a StellarSplitProvider backed by the given mock. */ +function makeWrapper(mock: MockStellarSplitClient) { + return function Wrapper({ children }: { children: React.ReactNode }) { + // Cast is safe: MockStellarSplitClient satisfies the subset we use in tests + return ( + + {children} + + ); + }; +} + +// --------------------------------------------------------------------------- +// StellarSplitProvider / useStellarSplitClient +// --------------------------------------------------------------------------- + +describe("StellarSplitProvider", () => { + it("renders without error", () => { + const mock = new MockStellarSplitClient(); + const wrapper = makeWrapper(mock); + const { result } = renderHook(() => useStellarSplitClient(), { wrapper }); + expect(result.current).toBeDefined(); + }); + + it("throws when used outside a provider", () => { + // Suppress the expected React error boundary output + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + expect(() => + renderHook(() => useStellarSplitClient()), + ).toThrow(); + consoleError.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// useInvoice +// --------------------------------------------------------------------------- + +describe("useInvoice", () => { + let mock: MockStellarSplitClient; + + beforeEach(() => { + mock = new MockStellarSplitClient(); + }); + + it("starts in loading state", async () => { + mock.setInvoice("1", makeInvoice("1")); + const wrapper = makeWrapper(mock); + const { result } = renderHook(() => useInvoice("1"), { wrapper }); + + // Initial state before data loads + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("returns data after loading", async () => { + mock.setInvoice("42", makeInvoice("42")); + const wrapper = makeWrapper(mock); + const { result } = renderHook(() => useInvoice("42"), { wrapper }); + + await waitFor(() => expect(result.current.data).not.toBeNull()); + expect(result.current.data?.id).toBe("42"); + expect(result.current.error).toBeNull(); + }); + + it("returns error when invoice does not exist", async () => { + const wrapper = makeWrapper(mock); + const { result } = renderHook(() => useInvoice("missing"), { wrapper }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.data).toBeNull(); + }); + + it("refetch() reloads the invoice", async () => { + mock.setInvoice("r", makeInvoice("r")); + const wrapper = makeWrapper(mock); + const { result } = renderHook(() => useInvoice("r"), { wrapper }); + + await waitFor(() => expect(result.current.data).not.toBeNull()); + + // Update the invoice in the store + mock.setInvoice("r", makeInvoice("r")); + mock._invoices?.set?.("r", { ...makeInvoice("r"), status: "Released" }); + + await act(async () => { + await result.current.refetch(); + }); + + // After refetch, data should reflect latest store state + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("accepts client directly without a provider", async () => { + mock.setInvoice("d", makeInvoice("d")); + const { result } = renderHook(() => + useInvoice( + "d", + mock as unknown as import("../src/client.js").StellarSplitClient, + ), + ); + + await waitFor(() => expect(result.current.data).not.toBeNull()); + expect(result.current.data?.id).toBe("d"); + }); +}); + +// --------------------------------------------------------------------------- +// useCreatorInvoices +// --------------------------------------------------------------------------- + +describe("useCreatorInvoices", () => { + let mock: MockStellarSplitClient; + + beforeEach(() => { + mock = new MockStellarSplitClient(); + }); + + it("returns invoices for a creator", async () => { + mock.setInvoice("c1", makeInvoice("c1", "ALICE")); + mock.setInvoice("c2", makeInvoice("c2", "ALICE")); + mock.setInvoice("c3", makeInvoice("c3", "BOB")); + const wrapper = makeWrapper(mock); + + const { result } = renderHook( + () => useCreatorInvoices("ALICE"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toHaveLength(2); + expect(result.current.error).toBeNull(); + }); + + it("returns empty array when creator has no invoices", async () => { + const wrapper = makeWrapper(mock); + const { result } = renderHook( + () => useCreatorInvoices("NOBODY"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toHaveLength(0); + expect(result.current.hasMore).toBe(false); + }); + + it("hasMore is true when a next cursor exists", async () => { + for (let i = 0; i < 25; i++) { + mock.setInvoice(`inv-${i}`, makeInvoice(`inv-${i}`, "ALICE")); + } + const wrapper = makeWrapper(mock); + + const { result } = renderHook( + () => useCreatorInvoices("ALICE", { limit: 10 }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasMore).toBe(true); + }); + + it("loadMore() appends more invoices", async () => { + for (let i = 0; i < 5; i++) { + mock.setInvoice(`inv-${i}`, makeInvoice(`inv-${i}`, "ALICE")); + } + const wrapper = makeWrapper(mock); + + const { result } = renderHook( + () => useCreatorInvoices("ALICE", { limit: 3 }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + const firstCount = result.current.data.length; + expect(result.current.hasMore).toBe(true); + + await act(async () => { + await result.current.loadMore(); + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data.length).toBeGreaterThan(firstCount); + }); +}); + +// --------------------------------------------------------------------------- +// useProtocolStats +// --------------------------------------------------------------------------- + +describe("useProtocolStats", () => { + let mock: MockStellarSplitClient; + + beforeEach(() => { + mock = new MockStellarSplitClient(); + }); + + it("returns data or null with loading/error states", async () => { + const wrapper = makeWrapper(mock); + const { result } = renderHook(() => useProtocolStats(), { wrapper }); + + // Should start loading + expect(result.current.loading).toBe(true); + + // Should resolve (either data or error) + await waitFor(() => expect(result.current.loading).toBe(false)); + + // Either we have data or an error (checkHealth may not be implemented on mock) + expect( + result.current.data !== null || result.current.error !== null, + ).toBe(true); + }); + + it("accepts a client prop override", async () => { + const { result } = renderHook(() => + useProtocolStats( + mock as unknown as import("../src/client.js").StellarSplitClient, + ), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + // Should not throw + }); +}); + +// --------------------------------------------------------------------------- +// useInvoiceStream +// --------------------------------------------------------------------------- + +describe("useInvoiceStream", () => { + let mock: MockStellarSplitClient; + + beforeEach(() => { + mock = new MockStellarSplitClient(); + vi.spyOn(mock, "subscribeToInvoice"); + }); + + it("isConnected becomes true after mount", async () => { + const wrapper = makeWrapper(mock); + const { result } = renderHook( + () => useInvoiceStream("inv-1"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isConnected).toBe(true)); + }); + + it("latestEvent is null initially", async () => { + const wrapper = makeWrapper(mock); + const { result } = renderHook( + () => useInvoiceStream("inv-2"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isConnected).toBe(true)); + expect(result.current.latestEvent).toBeNull(); + }); + + it("latestEvent updates when simulateEvent fires", async () => { + const wrapper = makeWrapper(mock); + const { result } = renderHook( + () => useInvoiceStream("inv-3"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isConnected).toBe(true)); + + const event: import("../src/types.js").InvoiceEvent = { + type: "payment", + invoiceId: "inv-3", + ledger: 5, + timestamp: Date.now(), + eventId: "evt-5", + payer: "GPAYER", + amount: 777n, + }; + + act(() => { + mock.simulateEvent(event); + }); + + await waitFor(() => expect(result.current.latestEvent).not.toBeNull()); + expect(result.current.latestEvent?.type).toBe("payment"); + }); + + it("unsubscribes on unmount", async () => { + const wrapper = makeWrapper(mock); + const { result, unmount } = renderHook( + () => useInvoiceStream("inv-4"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isConnected).toBe(true)); + unmount(); + + // After unmount no further callbacks should fire for the hook + // Add a fresh listener to confirm the invoiceId is still usable + const cb = vi.fn(); + mock.subscribeToInvoice("inv-4", cb); + mock.simulateEvent({ + type: "cancelled", + invoiceId: "inv-4", + ledger: 1, + timestamp: 0, + eventId: "e", + cancelledBy: "G", + }); + + // The fresh cb should have been called once; the unmounted hook's + // internal callback should NOT fire (it was cleaned up). + expect(cb).toHaveBeenCalledOnce(); + }); + + it("error is null when subscription succeeds", async () => { + const wrapper = makeWrapper(mock); + const { result } = renderHook( + () => useInvoiceStream("inv-5"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isConnected).toBe(true)); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/test/telemetryModule.test.ts b/test/telemetryModule.test.ts new file mode 100644 index 0000000..1d35137 --- /dev/null +++ b/test/telemetryModule.test.ts @@ -0,0 +1,347 @@ +/** + * Tests for #885 — SDK Telemetry Module + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { + StellarSplitTelemetry, + withTelemetry, +} from "../src/telemetryModule.js"; +import type { TelemetryConfig } from "../src/telemetryModule.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTelemetry(overrides: Partial = {}) { + return new StellarSplitTelemetry({ enabled: true, flushIntervalMs: 60_000, ...overrides }); +} + +// --------------------------------------------------------------------------- +// Default / disabled behaviour +// --------------------------------------------------------------------------- + +describe("StellarSplitTelemetry — disabled by default", () => { + it("creates without throwing", () => { + expect(() => new StellarSplitTelemetry()).not.toThrow(); + }); + + it("record() is a no-op when disabled", () => { + const tel = new StellarSplitTelemetry({ enabled: false, flushIntervalMs: 0 }); + tel.record("getInvoice", 10, true); + const snap = tel.getMetrics(); + expect(Object.keys(snap.methods)).toHaveLength(0); + }); + + it("getMetrics() returns empty methods map when disabled", () => { + const tel = new StellarSplitTelemetry({ enabled: false, flushIntervalMs: 0 }); + const snap = tel.getMetrics(); + expect(snap.methods).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// Accumulation +// --------------------------------------------------------------------------- + +describe("StellarSplitTelemetry — metrics accumulation", () => { + let tel: StellarSplitTelemetry; + + beforeEach(() => { + tel = makeTelemetry(); + }); + + afterEach(() => { + tel.destroy(); + }); + + it("increments callCount on each record()", () => { + tel.record("createInvoice", 10, true); + tel.record("createInvoice", 20, true); + const snap = tel.getMetrics(); + expect(snap.methods["createInvoice"]?.callCount).toBe(2); + }); + + it("increments errorCount only on failure", () => { + tel.record("pay", 5, true); + tel.record("pay", 8, false); + tel.record("pay", 6, false); + const snap = tel.getMetrics(); + expect(snap.methods["pay"]?.errorCount).toBe(2); + expect(snap.methods["pay"]?.callCount).toBe(3); + }); + + it("tracks multiple methods independently", () => { + tel.record("getInvoice", 12, true); + tel.record("getPayments", 7, true); + const snap = tel.getMetrics(); + expect(snap.methods["getInvoice"]?.callCount).toBe(1); + expect(snap.methods["getPayments"]?.callCount).toBe(1); + }); + + it("getMetrics() includes snapshotAt and startedAt", () => { + const snap = tel.getMetrics(); + expect(typeof snap.snapshotAt).toBe("number"); + expect(typeof snap.startedAt).toBe("number"); + expect(snap.snapshotAt).toBeGreaterThanOrEqual(snap.startedAt); + }); +}); + +// --------------------------------------------------------------------------- +// Percentiles +// --------------------------------------------------------------------------- + +describe("StellarSplitTelemetry — latency percentiles", () => { + let tel: StellarSplitTelemetry; + + beforeEach(() => { + tel = makeTelemetry(); + }); + + afterEach(() => { + tel.destroy(); + }); + + it("p50/p95/p99 are 0 for a single sample", () => { + tel.record("m", 42, true); + const m = tel.getMetrics().methods["m"]!; + // With a single sample all percentiles equal that sample + expect(m.p50LatencyMs).toBe(42); + expect(m.p95LatencyMs).toBe(42); + expect(m.p99LatencyMs).toBe(42); + }); + + it("p50 is the median of a sorted list", () => { + // Samples: 10, 20, 30 → sorted: [10, 20, 30] → p50 = 20 + [10, 30, 20].forEach((v) => tel.record("med", v, true)); + const m = tel.getMetrics().methods["med"]!; + expect(m.p50LatencyMs).toBe(20); + }); + + it("p99 exceeds p95 which exceeds p50 for a range of samples", () => { + for (let i = 1; i <= 100; i++) tel.record("range", i, true); + const m = tel.getMetrics().methods["range"]!; + expect(m.p99LatencyMs).toBeGreaterThanOrEqual(m.p95LatencyMs); + expect(m.p95LatencyMs).toBeGreaterThanOrEqual(m.p50LatencyMs); + }); + + it("returns 0 for a method with no calls", () => { + // Manually check: no records means empty methods map + const snap = tel.getMetrics(); + expect(snap.methods["neverCalled"]).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// resetMetrics() +// --------------------------------------------------------------------------- + +describe("StellarSplitTelemetry — resetMetrics()", () => { + let tel: StellarSplitTelemetry; + + beforeEach(() => { + tel = makeTelemetry(); + }); + + afterEach(() => { + tel.destroy(); + }); + + it("clears all method accumulators", () => { + tel.record("getInvoice", 10, true); + tel.record("pay", 5, false); + tel.resetMetrics(); + const snap = tel.getMetrics(); + expect(Object.keys(snap.methods)).toHaveLength(0); + }); + + it("resets startedAt to current time", () => { + const before = Date.now(); + tel.resetMetrics(); + const snap = tel.getMetrics(); + expect(snap.startedAt).toBeGreaterThanOrEqual(before); + }); + + it("allows new records after reset", () => { + tel.record("pay", 5, true); + tel.resetMetrics(); + tel.record("pay", 8, true); + const snap = tel.getMetrics(); + expect(snap.methods["pay"]?.callCount).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// flush() — no endpoint +// --------------------------------------------------------------------------- + +describe("StellarSplitTelemetry — flush() without endpoint", () => { + it("resolves without throwing", async () => { + const tel = makeTelemetry(); // no endpoint + tel.record("getInvoice", 5, true); + await expect(tel.flush()).resolves.toBeUndefined(); + tel.destroy(); + }); +}); + +// --------------------------------------------------------------------------- +// flush() — with endpoint +// --------------------------------------------------------------------------- + +describe("StellarSplitTelemetry — flush() with endpoint", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends a POST request with the metrics snapshot", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const tel = new StellarSplitTelemetry({ + enabled: true, + flushIntervalMs: 999_999, + endpoint: "https://metrics.example.com/flush", + }); + + tel.record("getInvoice", 15, true); + tel.record("getInvoice", 25, false); + + await tel.flush(); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://metrics.example.com/flush"); + expect(init.method).toBe("POST"); + + const body = JSON.parse(init.body as string); + expect(body).toHaveProperty("methods"); + expect(body.methods["getInvoice"].callCount).toBe(2); + expect(body.methods["getInvoice"].errorCount).toBe(1); + + tel.destroy(); + }); + + it("swallows fetch errors — does not reject", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("Network failure")); + vi.stubGlobal("fetch", fetchMock); + + const tel = new StellarSplitTelemetry({ + enabled: true, + flushIntervalMs: 999_999, + endpoint: "https://bad.example.com", + }); + + tel.record("pay", 5, true); + await expect(tel.flush()).resolves.toBeUndefined(); + + tel.destroy(); + }); +}); + +// --------------------------------------------------------------------------- +// Periodic flush scheduling +// --------------------------------------------------------------------------- + +describe("StellarSplitTelemetry — periodic flush", () => { + it("schedules flush when endpoint is set", () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const tel = new StellarSplitTelemetry({ + enabled: true, + flushIntervalMs: 1_000, + endpoint: "https://metrics.example.com", + }); + + tel.record("getInvoice", 10, true); + + vi.advanceTimersByTime(1_001); + + // The periodic timer should have triggered a flush + expect(fetchMock).toHaveBeenCalledTimes(1); + + tel.destroy(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("destroy() stops the flush timer", () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const tel = new StellarSplitTelemetry({ + enabled: true, + flushIntervalMs: 1_000, + endpoint: "https://metrics.example.com", + }); + + tel.record("pay", 5, true); + tel.destroy(); + + vi.advanceTimersByTime(5_000); + // Timer was cleared — no calls should have fired after destroy + expect(fetchMock).toHaveBeenCalledTimes(0); + + vi.useRealTimers(); + vi.restoreAllMocks(); + }); +}); + +// --------------------------------------------------------------------------- +// withTelemetry() wrapper +// --------------------------------------------------------------------------- + +describe("withTelemetry()", () => { + let tel: StellarSplitTelemetry; + + beforeEach(() => { + tel = makeTelemetry(); + }); + + afterEach(() => { + tel.destroy(); + }); + + it("records a successful call", async () => { + const fn = vi.fn().mockResolvedValue("result"); + const wrapped = withTelemetry(tel, "myMethod", fn); + + const result = await wrapped("arg1"); + expect(result).toBe("result"); + + const snap = tel.getMetrics(); + expect(snap.methods["myMethod"]?.callCount).toBe(1); + expect(snap.methods["myMethod"]?.errorCount).toBe(0); + }); + + it("records a failed call and re-throws the error", async () => { + const err = new Error("boom"); + const fn = vi.fn().mockRejectedValue(err); + const wrapped = withTelemetry(tel, "badMethod", fn); + + await expect(wrapped()).rejects.toThrow("boom"); + + const snap = tel.getMetrics(); + expect(snap.methods["badMethod"]?.callCount).toBe(1); + expect(snap.methods["badMethod"]?.errorCount).toBe(1); + }); + + it("passes arguments through to the wrapped function", async () => { + const fn = vi.fn().mockResolvedValue(undefined); + const wrapped = withTelemetry(tel, "m", fn); + await wrapped("a", 2, true); + expect(fn).toHaveBeenCalledWith("a", 2, true); + }); + + it("records latency > 0 ms", async () => { + const fn = vi.fn().mockImplementation( + () => new Promise((r) => setTimeout(() => r("ok"), 5)), + ); + const wrapped = withTelemetry(tel, "slow", fn); + await wrapped(); + const m = tel.getMetrics().methods["slow"]!; + expect(m.p50LatencyMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 7a58a15..334bc02 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,18 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/ui/index.ts", "src/testing/index.ts", "src/utils.ts"], + entry: [ + "src/index.ts", + "src/ui/index.ts", + "src/testing/index.ts", + "src/utils.ts", + // #882 – mock client entry point + "src/mock/index.ts", + // #884 – React hooks entry point + "src/react/index.tsx", + // #885 – telemetry module entry point + "src/telemetryModule.ts", + ], format: ["esm", "cjs"], dts: true, sourcemap: true, @@ -10,8 +21,8 @@ export default defineConfig({ treeshake: true, external: ["react", "react-dom"], esbuildOptions(options) { - options.jsx = 'transform'; - options.jsxFactory = 'React.createElement'; - options.jsxFragment = 'React.Fragment'; + options.jsx = "transform"; + options.jsxFactory = "React.createElement"; + options.jsxFragment = "React.Fragment"; }, });