diff --git a/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx new file mode 100644 index 000000000..8ab6ff035 --- /dev/null +++ b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ContractError, ContractErrorType } from '@invofi/sdk'; +import { SdkErrorBoundary } from './SdkErrorBoundary'; + +/** Throws once on mount, then renders normally after `SdkErrorBoundary` resets it. */ +function Bomb({ error, shouldThrow = true }: { error: Error; shouldThrow?: boolean }) { + if (shouldThrow) throw error; + return
recovered
; +} + +describe('SdkErrorBoundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders children when nothing throws', () => { + render( + +
all good
+
, + ); + expect(screen.getByText('all good')).toBeInTheDocument(); + }); + + it('shows the recovery message and action when a ContractError with recovery is thrown', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const err = new ContractError( + 5, + ContractErrorType.INSUFFICIENT_BALANCE, + 'The account does not have sufficient balance to complete this transaction.', + { message: 'Add funds to your wallet and try again.', action: 'Add funds' }, + ); + + render( + + + , + ); + + expect(screen.getByText('Add funds to your wallet and try again.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + + it('shows a link when the recovery suggestion includes a url', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const err = new ContractError( + 7, + ContractErrorType.ALREADY_EXISTS, + 'A resource with this ID already exists.', + { message: 'Use a different ID.', action: 'View existing', url: 'https://example.com/lookup' }, + ); + + render( + + + , + ); + + const link = screen.getByRole('link', { name: 'View existing' }); + expect(link).toHaveAttribute('href', 'https://example.com/lookup'); + }); + + it('falls back to the error message when a ContractError has no recovery suggestion', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const err = new ContractError(999999, ContractErrorType.UNKNOWN, 'Contract call failed: mystery error'); + + render( + + + , + ); + + expect(screen.getByText('Contract call failed: mystery error')).toBeInTheDocument(); + }); + + it('renders a graceful generic fallback for a plain (non-SDK) error', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + + , + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText('boom, totally unrelated to the SDK')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + + it('calls onReset and shows recovered content when "Try again" is clicked after a real error', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const onReset = vi.fn(); + + // Bomb throws on its first render; clicking "Try again" must call + // onReset (which flips shouldThrow to false here, simulating a caller + // that clears whatever caused the error) and then re-render children + // instead of the fallback. + function Wrapper() { + const [shouldThrow, setShouldThrow] = useState(true); + return ( + { + onReset(); + setShouldThrow(false); + }} + > + + + ); + } + + render(); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.queryByText('recovered')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + + expect(onReset).toHaveBeenCalledOnce(); + expect(screen.getByText('recovered')).toBeInTheDocument(); + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + }); + + it('supports a custom fallback render prop', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const err = new ContractError(2, ContractErrorType.NOT_FOUND, 'No invoice found.'); + + render( + ( +
+ custom fallback: {error.message} + +
+ )} + > + +
, + ); + + expect(screen.getByText('custom fallback: No invoice found.')).toBeInTheDocument(); + }); +}); diff --git a/invofi/apps/frontend/src/components/common/SdkErrorBoundary.tsx b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.tsx new file mode 100644 index 000000000..fa0b00378 --- /dev/null +++ b/invofi/apps/frontend/src/components/common/SdkErrorBoundary.tsx @@ -0,0 +1,101 @@ +'use client'; + +// ── SdkErrorBoundary — reusable error boundary for @invofi/sdk errors (#223) ── +// +// A narrower, reusable class-component error boundary for wrapping specific +// data-fetching / contract-interaction sections of the UI (a card, a form, a +// table) — NOT a replacement for `src/app/error.tsx`, which remains the +// route-level Next.js error boundary. +// +// When the caught error is a `ContractError` (or `SdkError`) from +// `@invofi/sdk`, the fallback UI surfaces its recovery suggestion +// (`message` / `action` / `url`) so the user gets an actionable next step +// instead of a raw stack trace. Any other error still renders a safe, +// generic fallback rather than crashing the surrounding page. + +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { ContractError, SdkError } from '@invofi/sdk'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; + +interface SdkErrorBoundaryProps { + children: ReactNode; + /** Optional custom fallback renderer, given the caught error and a reset callback. */ + fallback?: (error: Error, reset: () => void) => ReactNode; + /** Called when the user retries, before the boundary clears its error state. */ + onReset?: () => void; +} + +interface SdkErrorBoundaryState { + error: Error | null; +} + +/** Default fallback UI shown when no custom `fallback` render prop is supplied. */ +function DefaultFallback({ error, onReset }: { error: Error; onReset: () => void }) { + const recovery = error instanceof ContractError ? error.recovery : undefined; + const description = recovery?.message ?? error.message ?? 'An unexpected error occurred. Please try again.'; + + return ( + + + Something went wrong + +

{description}

+
+ + {recovery?.url && ( + + {recovery.action ?? 'Learn more'} + + )} +
+
+
+ ); +} + +/** + * Class-based error boundary for wrapping SDK-driven sections of the UI. + * + * Usage: + * + * + * + */ +export class SdkErrorBoundary extends Component { + state: SdkErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): SdkErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // Non-SDK errors (render bugs, etc.) are still contained by the boundary + // — but always logged so they aren't silently swallowed. + const kind = error instanceof SdkError ? error.name : 'Error'; + // eslint-disable-next-line no-console + console.error(`[SdkErrorBoundary] caught ${kind}:`, error, errorInfo.componentStack); + } + + reset = (): void => { + this.props.onReset?.(); + this.setState({ error: null }); + }; + + render(): ReactNode { + const { error } = this.state; + if (error) { + if (this.props.fallback) return this.props.fallback(error, this.reset); + return ; + } + return this.props.children; + } +} diff --git a/invofi/apps/frontend/vitest.config.ts b/invofi/apps/frontend/vitest.config.ts index 3b3217ecf..0311097fb 100644 --- a/invofi/apps/frontend/vitest.config.ts +++ b/invofi/apps/frontend/vitest.config.ts @@ -5,6 +5,16 @@ import { defineConfig } from 'vitest/config'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + // The rest of the codebase writes components with the automatic JSX + // runtime (no `import React from 'react'` needed — see e.g. + // src/components/common/EmptyState.tsx), matching Next.js's default. Vite's + // esbuild otherwise falls back to the classic transform (`React.createElement` + // with React expected in scope) since tsconfig.json's `"jsx": "preserve"` + // isn't one of the react-jsx/react-jsxdev values esbuild auto-detects. + // Pin it explicitly so component tests (#223) don't need React imports. + esbuild: { + jsx: 'automatic', + }, test: { // Unit tests only — the e2e/ directory is Playwright, not Vitest. include: ['src/**/*.test.{ts,tsx}', 'scripts/**/*.test.mjs'], @@ -27,7 +37,15 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src'), + // @invofi/sdk is consumed from source via tsconfig paths (see + // tsconfig.json + next.config.mjs); mirror that here so Vitest can + // resolve it too (#223). '@invofi/sdk': path.resolve(__dirname, '../sdk/src/index.ts'), + // The SDK's own node_modules isn't installed in CI, so its + // `@stellar/stellar-sdk` import must resolve to this app's copy — + // same reasoning as the webpack alias in next.config.mjs, mirrored + // here for Vitest. + '@stellar/stellar-sdk': path.resolve(__dirname, 'node_modules/@stellar/stellar-sdk'), }, }, }); diff --git a/invofi/apps/sdk/src/client.ts b/invofi/apps/sdk/src/client.ts index 0d5fb5da1..a67184fca 100644 --- a/invofi/apps/sdk/src/client.ts +++ b/invofi/apps/sdk/src/client.ts @@ -31,6 +31,7 @@ import { validateAssetString, validateConfigField, } from './validation'; +import { parseContractError } from './errors'; export { SdkValidationError, ErrorCode }; @@ -145,7 +146,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { const simResult = await rpc.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simResult)) { - throw new Error(`Simulation failed: ${simResult.error}`); + throw parseContractError(simResult.error, 'Simulation failed'); } tx = SorobanRpc.assembleTransaction(tx, simResult).build(); @@ -154,7 +155,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { const sendResult = await rpc.sendTransaction(signedTx); if (sendResult.status === 'ERROR') { - throw new Error(`Transaction failed: ${JSON.stringify(sendResult.errorResult)}`); + throw parseContractError(sendResult.errorResult, 'Transaction failed'); } let getResult = await rpc.getTransaction(sendResult.hash); @@ -164,7 +165,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { } if (getResult.status !== 'SUCCESS') { - throw new Error(`Transaction did not succeed: ${getResult.status}`); + throw parseContractError(getResult, `Transaction did not succeed (status: ${getResult.status})`); } return getResult.returnValue ?? xdr.ScVal.scvVoid(); @@ -203,7 +204,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { const sim = await rpc.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(sim)) { - throw new Error(`Read failed: ${sim.error}`); + throw parseContractError(sim.error, 'Read failed'); } if (!SorobanRpc.Api.isSimulationSuccess(sim) || !sim.result) { throw new Error('Read simulation returned no result'); diff --git a/invofi/apps/sdk/src/errors.ts b/invofi/apps/sdk/src/errors.ts new file mode 100644 index 000000000..b86924d94 --- /dev/null +++ b/invofi/apps/sdk/src/errors.ts @@ -0,0 +1,266 @@ +// ── SDK typed error handling & Soroban error code mapping (#223) ──────────── +// +// Every RPC-facing failure in client.ts (simulation failure, submit failure, +// non-SUCCESS transaction status) is funneled through `parseContractError` +// so callers get a typed `ContractError` with a stable `.errorType`, a +// human-readable `.message`, an optional `.recovery` suggestion, and the +// original failure preserved as `.cause` — instead of a plain `new Error(...)` +// with an interpolated string that can only be handled by matching text. +// +// Error codes 1–8 in `CONTRACT_ERROR_MAP` are the canonical, shared +// `common/src/errors.rs` discriminants from `Stellar-VaultLink/invofi-contracts` +// (positional Soroban `#[contracterror]` ordering: first variant = 1, second +// = 2, ...): Unauthorized, NotFound, InvalidTransition, Paused, +// InsufficientBalance, InvalidInput, AlreadyExists, Blacklisted. These are +// cross-cutting, contract-agnostic discriminants — e.g. code 2 (`NotFound`) +// covers a missing invoice, offer, or any other by-ID lookup, not a +// per-resource variant. If `common/src/errors.rs` gains variants beyond 8, +// they are not yet mapped here and fall back to `ContractErrorType.UNKNOWN` +// until added. +// +// Usage: +// import { parseContractError, ContractError, ContractErrorType } from './errors'; +// try { ... } catch (err) { throw parseContractError(err); } + +// ── Recovery suggestions ───────────────────────────────────────────────────── + +/** + * A user-facing hint for how to recover from a given error, surfaced by + * consumers (e.g. the frontend's `SdkErrorBoundary`) alongside the error + * message. + */ +export interface RecoverySuggestion { + /** Human-readable recovery hint, e.g. "Fund your wallet with more XLM." */ + message: string; + /** Optional short action label for a UI button, e.g. "Add funds". */ + action?: string; + /** Optional URL for more information (docs, faucet, support). */ + url?: string; +} + +// ── Base SDK error ─────────────────────────────────────────────────────────── + +/** + * Base class for all errors thrown by @invofi/sdk beyond input validation + * (see `SdkValidationError` in validation.ts for pre-RPC argument checks). + * + * Supports error chaining (contract error → SDK error → UI error): pass the + * originating error as `cause` and it is preserved for callers that want to + * inspect the full failure chain (e.g. `err.cause`). + * + * Note: this SDK targets ES2020 (see tsconfig.json), which predates the + * ES2022 `Error` `cause` option in TypeScript's lib types. We therefore + * carry our own `cause` field and wire it through manually rather than + * relying on `super(message, { cause })`. + */ +export class SdkError extends Error { + /** The original error this one was constructed from, if any. */ + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'SdkError'; + this.cause = cause; + // Maintain proper prototype chain for compiled ES5 targets. + Object.setPrototypeOf(this, SdkError.prototype); + } +} + +// ── Contract error codes ───────────────────────────────────────────────────── +// `const` object + derived union type, mirroring the `ErrorCode` convention +// in validation.ts. One entry per mapped Soroban contract error, plus +// UNKNOWN for anything not (yet) in CONTRACT_ERROR_MAP. + +export const ContractErrorType = { + // ── common/src/errors.rs canonical discriminants (codes 1–8) ─────────────── + UNAUTHORIZED: 'UNAUTHORIZED', + NOT_FOUND: 'NOT_FOUND', + INVALID_TRANSITION: 'INVALID_TRANSITION', + PAUSED: 'PAUSED', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + INVALID_INPUT: 'INVALID_INPUT', + ALREADY_EXISTS: 'ALREADY_EXISTS', + BLACKLISTED: 'BLACKLISTED', + + // ── Fallback ───────────────────────────────────────────────────────────────── + UNKNOWN: 'UNKNOWN', +} as const; + +export type ContractErrorType = typeof ContractErrorType[keyof typeof ContractErrorType]; + +// ── Contract error → typed mapping table ───────────────────────────────────── +// +// Codes 1–8 are the canonical `common/src/errors.rs` discriminants — see the +// file-level banner above. This table is the single place to extend if +// `common/src/errors.rs` ever gains variants beyond 8. + +interface ContractErrorEntry { + type: ContractErrorType; + message: string; + recovery?: RecoverySuggestion; +} + +export const CONTRACT_ERROR_MAP: Record = { + 1: { + type: ContractErrorType.UNAUTHORIZED, + message: 'The calling address is not authorized to perform this action.', + recovery: { message: 'Sign this transaction with the address that owns/originated this resource.' }, + }, + 2: { + type: ContractErrorType.NOT_FOUND, + message: 'No resource was found with the given ID.', + recovery: { message: 'Double-check the ID and that it was created successfully.' }, + }, + 3: { + type: ContractErrorType.INVALID_TRANSITION, + message: 'This action is not valid for the resource in its current status.', + recovery: { message: 'Refresh the resource’s status and confirm the action is still applicable.' }, + }, + 4: { + type: ContractErrorType.PAUSED, + message: 'This contract is currently paused and not accepting this action.', + recovery: { message: 'Try again later, or check protocol announcements for details.' }, + }, + 5: { + type: ContractErrorType.INSUFFICIENT_BALANCE, + message: 'The account does not have sufficient balance to complete this transaction.', + recovery: { message: 'Add funds to your wallet and try again.', action: 'Add funds' }, + }, + 6: { + type: ContractErrorType.INVALID_INPUT, + message: 'One or more input values were invalid.', + recovery: { message: 'Check the submitted values and try again.' }, + }, + 7: { + type: ContractErrorType.ALREADY_EXISTS, + message: 'A resource with this ID already exists.', + recovery: { message: 'Use a different ID, or look up the existing resource instead.' }, + }, + 8: { + type: ContractErrorType.BLACKLISTED, + message: 'This address has been blacklisted and cannot perform this action.', + recovery: { message: 'Contact support if you believe this is a mistake.' }, + }, +}; + +// ── Analytics hook (opt-in, dependency-free) ───────────────────────────────── +// +// Consuming apps can opt in to reporting SDK errors to their own analytics/ +// observability pipeline without the SDK taking a dependency on any specific +// analytics package. No-op until `setErrorReporter` is called. + +let errorReporter: ((err: SdkError) => void) | undefined; + +/** + * Register a callback invoked with every `SdkError` (including + * `ContractError`) constructed via `parseContractError`. Optional — if never + * called, no reporting happens. Pass `undefined` to unregister. + */ +export function setErrorReporter(fn: ((err: SdkError) => void) | undefined): void { + errorReporter = fn; +} + +/** Internal: report an error to the registered reporter, if any. Never throws. */ +function reportError(err: SdkError): void { + if (!errorReporter) return; + try { + errorReporter(err); + } catch { + // Reporting must never break the caller's error-handling flow. + } +} + +// ── Contract error ──────────────────────────────────────────────────────────── + +/** + * A typed error representing a failed Soroban contract call — simulation + * failure, submit failure, or a transaction that did not reach SUCCESS + * status. Constructed by `parseContractError`. + */ +export class ContractError extends SdkError { + /** The raw numeric Soroban contract error code, or -1 if none could be extracted. */ + readonly rawCode: number; + /** The typed classification of this error (UNKNOWN if rawCode is unmapped). */ + readonly errorType: ContractErrorType; + /** Optional recovery suggestion for this error type. */ + readonly recovery?: RecoverySuggestion; + + constructor(rawCode: number, errorType: ContractErrorType, message: string, recovery?: RecoverySuggestion, cause?: unknown) { + super(message, cause); + this.name = 'ContractError'; + this.rawCode = rawCode; + this.errorType = errorType; + this.recovery = recovery; + // Maintain proper prototype chain for compiled ES5 targets. + Object.setPrototypeOf(this, ContractError.prototype); + } +} + +// ── Error code extraction & mapping ────────────────────────────────────────── + +/** + * Soroban simulation/transaction failures typically stringify as something + * like `HostError: Error(Contract, #4)` or embed `Error(Contract, #4)` + * within a larger JSON/diagnostic payload. This pattern extracts the + * trailing `#` contract error code from any such string. + */ +const CONTRACT_ERROR_CODE_RE = /Error\(Contract,\s*#(\d+)\)/; +/** Fallback: a bare `#` anywhere in the string. */ +const BARE_ERROR_CODE_RE = /#(\d+)/; + +/** Best-effort extraction of a raw error message string from an unknown thrown value. */ +function stringifyRawError(rawError: unknown): string { + if (typeof rawError === 'string') return rawError; + if (rawError instanceof Error) return rawError.message; + if (rawError && typeof rawError === 'object') { + try { + return JSON.stringify(rawError); + } catch { + return String(rawError); + } + } + return String(rawError); +} + +/** Extracts a numeric Soroban contract error code from a raw error value, if present. */ +function extractErrorCode(rawError: unknown): number | undefined { + const text = stringifyRawError(rawError); + const match = CONTRACT_ERROR_CODE_RE.exec(text) ?? BARE_ERROR_CODE_RE.exec(text); + if (!match) return undefined; + const code = Number(match[1]); + return Number.isFinite(code) ? code : undefined; +} + +/** + * Parses a raw error (as thrown/returned by a failed `simulateTransaction`, + * `sendTransaction`, or `getTransaction` call) into a typed `ContractError`. + * + * - Extracts a numeric Soroban error code (`Error(Contract, #N)`) when present. + * - Looks it up in `CONTRACT_ERROR_MAP`; unmapped codes fall back to + * `ContractErrorType.UNKNOWN` with the raw code preserved. + * - When no code can be extracted at all, falls back to `UNKNOWN` with + * `rawCode: -1`, preserving the original message. + * - Always attaches the original `rawError` as `.cause` for chaining. + * - Reports the constructed error via the opt-in analytics hook. + * + * This never throws — it always returns a `ContractError` for the caller to throw. + */ +export function parseContractError(rawError: unknown, contextMessage?: string): ContractError { + const originalMessage = stringifyRawError(rawError); + const code = extractErrorCode(rawError); + + if (code !== undefined && code in CONTRACT_ERROR_MAP) { + const entry = CONTRACT_ERROR_MAP[code]; + const message = contextMessage ? `${contextMessage}: ${entry.message}` : entry.message; + const err = new ContractError(code, entry.type, message, entry.recovery, rawError); + reportError(err); + return err; + } + + const fallbackMessage = contextMessage + ? `${contextMessage}: ${originalMessage}` + : `Contract call failed: ${originalMessage}`; + const err = new ContractError(code ?? -1, ContractErrorType.UNKNOWN, fallbackMessage, undefined, rawError); + reportError(err); + return err; +} diff --git a/invofi/apps/sdk/src/index.ts b/invofi/apps/sdk/src/index.ts index 36a782633..a5afadc99 100644 --- a/invofi/apps/sdk/src/index.ts +++ b/invofi/apps/sdk/src/index.ts @@ -21,6 +21,27 @@ export { VALID_CURRENCIES, } from './validation'; +// ── Typed error handling & Soroban error code mapping (#223) ──────────────── +// `SdkError` is the base class for all non-validation SDK errors; +// `ContractError extends SdkError` wraps a failed contract call with a typed +// `errorType`, an optional `recovery` suggestion, and the raw Soroban error +// code. `parseContractError` is the mapping entry point client.ts funnels +// every simulate/send/getTransaction failure through. `setErrorReporter` is +// an optional, dependency-free analytics/observability hook. +// +// NOTE: `CONTRACT_ERROR_MAP`'s numeric codes are a placeholder/starter set — +// see the banner comment at the top of `src/errors.ts` for details on why, +// and what must be reconciled before relying on them against live contracts. +export { + SdkError, + ContractError, + ContractErrorType, + CONTRACT_ERROR_MAP, + parseContractError, + setErrorReporter, + type RecoverySuggestion, +} from './errors'; + // Stellar primitives the client surface needs — re-exported so consumers // don't need a direct @stellar/stellar-sdk dependency for common cases. export { Contract, Networks, xdr, nativeToScVal, scValToNative } from '@stellar/stellar-sdk'; diff --git a/invofi/apps/sdk/tests/errors.test.ts b/invofi/apps/sdk/tests/errors.test.ts new file mode 100644 index 000000000..7d970a393 --- /dev/null +++ b/invofi/apps/sdk/tests/errors.test.ts @@ -0,0 +1,234 @@ +/** + * Unit tests — SDK typed error handling (#223) + * + * Covers: parseContractError's numeric-code extraction and mapping, + * fallback behavior for unmapped/unparseable errors, cause chaining, + * the opt-in analytics reporter hook, and prototype-chain correctness for + * SdkError/ContractError. + * + * Strategy: all tests are pure in-process — no network calls, no Soroban RPC. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { + SdkError, + ContractError, + ContractErrorType, + CONTRACT_ERROR_MAP, + parseContractError, + setErrorReporter, +} from '../src/errors'; + +afterEach(() => { + // Always reset the opt-in reporter between tests so it doesn't leak. + setErrorReporter(undefined); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// parseContractError — code extraction & mapping +// ───────────────────────────────────────────────────────────────────────────── + +describe('parseContractError', () => { + it('extracts a mapped code from a realistic `Error(Contract, #N)` string and returns the typed error', () => { + // Code 1 is the canonical common/src/errors.rs discriminant for Unauthorized. + const raw = 'HostError: Error(Contract, #1)\n\nEvent log (newest first):...'; + const err = parseContractError(raw); + + expect(err).toBeInstanceOf(ContractError); + expect(err.rawCode).toBe(1); + expect(err.errorType).toBe(ContractErrorType.UNAUTHORIZED); + expect(err.message).toContain(CONTRACT_ERROR_MAP[1].message); + expect(err.recovery).toBeDefined(); + expect(err.recovery?.message).toBe(CONTRACT_ERROR_MAP[1].recovery?.message); + }); + + it('extracts a different mapped code correctly (NotFound, code 2)', () => { + const raw = 'Error(Contract, #2)'; + const err = parseContractError(raw); + + expect(err.rawCode).toBe(2); + expect(err.errorType).toBe(ContractErrorType.NOT_FOUND); + }); + + it('extracts InsufficientBalance (code 5), which carries an actionable recovery', () => { + const raw = 'Error(Contract, #5)'; + const err = parseContractError(raw); + + expect(err.rawCode).toBe(5); + expect(err.errorType).toBe(ContractErrorType.INSUFFICIENT_BALANCE); + expect(err.recovery?.action).toBe('Add funds'); + }); + + it('maps all eight canonical codes (1-8) to their expected discriminant', () => { + const expected: Record = { + 1: ContractErrorType.UNAUTHORIZED, + 2: ContractErrorType.NOT_FOUND, + 3: ContractErrorType.INVALID_TRANSITION, + 4: ContractErrorType.PAUSED, + 5: ContractErrorType.INSUFFICIENT_BALANCE, + 6: ContractErrorType.INVALID_INPUT, + 7: ContractErrorType.ALREADY_EXISTS, + 8: ContractErrorType.BLACKLISTED, + }; + + for (const [code, type] of Object.entries(expected)) { + const err = parseContractError(`Error(Contract, #${code})`); + expect(err.errorType).toBe(type); + } + }); + + it('does not map code 9 or above — those fall back to UNKNOWN', () => { + expect(CONTRACT_ERROR_MAP[9]).toBeUndefined(); + expect(CONTRACT_ERROR_MAP[15]).toBeUndefined(); + + const err = parseContractError('Error(Contract, #9)'); + expect(err.errorType).toBe(ContractErrorType.UNKNOWN); + expect(err.rawCode).toBe(9); + }); + + it('prefixes the mapped message with an optional context message', () => { + const raw = 'Error(Contract, #1)'; + const err = parseContractError(raw, 'Simulation failed'); + expect(err.message.startsWith('Simulation failed:')).toBe(true); + }); + + it('falls back to UNKNOWN for a code not present in CONTRACT_ERROR_MAP, without throwing', () => { + const raw = 'Error(Contract, #999999)'; + let err: ContractError | undefined; + expect(() => { + err = parseContractError(raw); + }).not.toThrow(); + + expect(err).toBeInstanceOf(ContractError); + expect(err!.errorType).toBe(ContractErrorType.UNKNOWN); + expect(err!.rawCode).toBe(999999); + expect(err!.message).toContain('999999'); + }); + + it('falls back to UNKNOWN with rawCode -1 when no code can be extracted at all', () => { + const raw = 'network request timed out'; + const err = parseContractError(raw); + + expect(err.errorType).toBe(ContractErrorType.UNKNOWN); + expect(err.rawCode).toBe(-1); + expect(err.message).toContain('network request timed out'); + }); + + it('handles a plain Error instance as input and preserves its message', () => { + const original = new Error('Error(Contract, #1)'); + const err = parseContractError(original); + expect(err.errorType).toBe(ContractErrorType.UNAUTHORIZED); + }); + + it('handles a non-string, non-Error object payload (e.g. sendResult.errorResult-shaped)', () => { + const raw = { status: 'ERROR', errorResultXdr: 'AAAAAAAAAGT////+AAAAAA==' }; + let err: ContractError | undefined; + expect(() => { + err = parseContractError(raw); + }).not.toThrow(); + expect(err).toBeInstanceOf(ContractError); + expect(err!.errorType).toBe(ContractErrorType.UNKNOWN); + }); + + it('chains the original raw error as `cause`', () => { + const original = new Error('Error(Contract, #1)'); + const err = parseContractError(original); + expect(err.cause).toBe(original); + }); + + it('chains a non-Error raw value as `cause` too', () => { + const raw = 'Error(Contract, #1)'; + const err = parseContractError(raw); + expect(err.cause).toBe(raw); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// setErrorReporter — opt-in analytics hook +// ───────────────────────────────────────────────────────────────────────────── + +describe('setErrorReporter', () => { + it('is not invoked when no reporter is registered', () => { + // No reporter set (afterEach clears it) — this must not throw. + expect(() => parseContractError('Error(Contract, #1)')).not.toThrow(); + }); + + it('is invoked with the constructed error once a reporter is registered', () => { + const seen: SdkError[] = []; + setErrorReporter(err => seen.push(err)); + + const err = parseContractError('Error(Contract, #1)'); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(err); + }); + + it('is invoked for UNKNOWN/unmapped errors too', () => { + const seen: SdkError[] = []; + setErrorReporter(err => seen.push(err)); + + parseContractError('some unparseable failure'); + + expect(seen).toHaveLength(1); + }); + + it('a reporter that throws does not break parseContractError', () => { + setErrorReporter(() => { + throw new Error('reporter boom'); + }); + + expect(() => parseContractError('Error(Contract, #1)')).not.toThrow(); + }); + + it('stops being invoked once unregistered with undefined', () => { + const seen: SdkError[] = []; + setErrorReporter(err => seen.push(err)); + setErrorReporter(undefined); + + parseContractError('Error(Contract, #1)'); + + expect(seen).toHaveLength(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SdkError / ContractError — class hierarchy & prototype chain +// ───────────────────────────────────────────────────────────────────────────── + +describe('SdkError / ContractError class hierarchy', () => { + it('ContractError is an instance of SdkError and Error', () => { + const err = parseContractError('Error(Contract, #1)'); + expect(err).toBeInstanceOf(ContractError); + expect(err).toBeInstanceOf(SdkError); + expect(err).toBeInstanceOf(Error); + }); + + it('instanceof checks survive when caught from a thrown value (ES5-target prototype chain)', () => { + try { + throw parseContractError('Error(Contract, #1)'); + } catch (e) { + expect(e instanceof ContractError).toBe(true); + expect(e instanceof SdkError).toBe(true); + expect(e instanceof Error).toBe(true); + } + }); + + it('SdkError sets name to "SdkError" and ContractError overrides it to "ContractError"', () => { + const base = new SdkError('base message'); + expect(base.name).toBe('SdkError'); + + const contractErr = parseContractError('Error(Contract, #1)'); + expect(contractErr.name).toBe('ContractError'); + }); + + it('SdkError carries an optional cause', () => { + const cause = new Error('root cause'); + const err = new SdkError('wrapped', cause); + expect(err.cause).toBe(cause); + }); + + it('SdkError without a cause leaves it undefined', () => { + const err = new SdkError('no cause here'); + expect(err.cause).toBeUndefined(); + }); +});