diff --git a/invofi/apps/sdk/README.md b/invofi/apps/sdk/README.md index 60f82bc7..86842ffd 100644 --- a/invofi/apps/sdk/README.md +++ b/invofi/apps/sdk/README.md @@ -208,6 +208,110 @@ changes. | `startLedger` | `number` | latest | Starting ledger (omit for live-only) | | `maxRetries` | `number` | `3` | Max consecutive failures before back-off | +## Contract-interaction testing framework (issue #226) + +The mock client is a full contract-interaction testing framework. Tests run +entirely in memory — no testnet, no RPC, no wallet — and can assert on the +same typed surfaces the real client exposes, including the `ProtocolEvent` +shapes that `listenToEvents` delivers. + +### What you get + +- **In-memory state** — `createMockClient()` starts from deterministic + pre-seeded fixtures (invoices in every status, offers, position-token + balances). Each instance gets a fresh copy, so tests never leak state. +- **Event emission tracking** — every successful state-changing call records + the protocol event it would have emitted on-chain in `client.events` + (`inv_reg`, `inv_cxl`, `off_new`, `off_acc`, `off_rej`, `inv_rep`, + `inv_ovd`, `off_def`), each with a deterministic fake `ledger`/`txHash`. +- **Typed failure scenarios** — domain failures throw typed `ContractError`s: + `NOT_FOUND` (missing id), `UNAUTHORIZED` (wrong originator/lender), + `ALREADY_EXISTS` (duplicate id), `INSUFFICIENT_BALANCE` (overdraft). +- **Configurable failure injection** — simulate arbitrary RPC/contract + failures deterministically with the `failures` option, `failNext(...)`, or + `addFailure(...)`. +- **State control** — `reset()` restores the seed between tests, + `setBalance`/`getBalance` set up balance scenarios, and + `seededInvoices()`/`seededOffers()` expose seeded fixtures. +- **Fixture builders** — `createTestInvoice()` / `createTestOffer()` compose + SDK-valid pre-seeded data with sensible defaults and full overrides. + +### Example — happy path with event assertions + +```ts +import { createMockClient, createTestInvoice, toStroops, MOCK_BUSINESS_A, ContractErrorType } from '@invofi/sdk'; + +const client = createMockClient(); + +// Compose a custom fixture and register it like a real call. +const invoice = createTestInvoice({ id: 'inv_42', amount: toStroops(500) }); +await client.registerInvoice( + { id: invoice.id, amount: invoice.amount, currency: invoice.currency, dueDate: invoice.due_date }, + invoice.originator, +); + +// The mock emitted the same event the registry contract would publish: +expect(client.events).toHaveLength(1); +const emitted = client.events[0]; +expect(emitted.type).toBe('inv_reg'); +if (emitted.type === 'inv_reg') { + expect(emitted.subjectId).toBe('inv_42'); + expect(emitted.data.amount).toBe(toStroops(500)); +} +``` + +### Example — failure scenarios + +```ts +// Typed domain failures need no setup: +await expect(client.getInvoice('inv_nope')).rejects.toMatchObject({ + errorType: ContractErrorType.NOT_FOUND, +}); +await expect(client.cancelInvoice('inv_mock_p001', MOCK_BUSINESS_A)).rejects.toMatchObject({ + errorType: ContractErrorType.UNAUTHORIZED, // only the originator may cancel +}); + +// Inject an arbitrary failure for the next call only: +client.failNext('acceptOffer', undefined, 'simulated outage'); +const offerId = 'off_mock_006'; +const originator = MOCK_BUSINESS_B; +await expect(client.acceptOffer(offerId, originator)).rejects.toThrow(/simulated outage/); + +// Or configure a sticky rule up front: +const flaky = createMockClient({ + failures: [{ on: 'transferPositionToken', message: 'token contract paused' }], +}); +``` + +### Example — reset between test cases + +```ts +const client = createMockClient(); + +await client.acceptOffer('off_mock_006', MOCK_BUSINESS_B); // mutates state + emits off_acc +await client.reset(); // back to the seed + +expect((await client.getInvoice('inv_mock_p002')).status).toBe('Pending'); +expect(client.events).toHaveLength(0); +``` + +### Fixture builders + +| Helper | Defaults | +|--------------------|-----------------------------------------------------------------------| +| `createTestInvoice`| `inv_test_001`, `MOCK_BUSINESS_A`, 100 XLM, due in 30 days, `Pending` | +| `createTestOffer` | `off_test_001`, `inv_test_001`, `MOCK_LENDER_B`, 5 %, 30 days, `Pending` | + +Both accept full overrides (`id`, `amount`, `currency`, `status`, …) plus +readable aliases (`dueDate` for `due_date`, `invoiceId` for `invoice_id`). Use +`toStroops(n)` to convert whole XLM/USDC units to stroops. + +> **Design note:** the mock implements the complete `InvofiClient` method +> surface, so it is a drop-in for real contract interactions in tests and +> demo mode alike. It deliberately does not simulate Soroban transaction +> assembly/signing — validation, typed errors, events, and state transitions +> are what test suites exercise against it. + ## Local dev ```bash diff --git a/invofi/apps/sdk/src/index.ts b/invofi/apps/sdk/src/index.ts index 6e0708ea..67067181 100644 --- a/invofi/apps/sdk/src/index.ts +++ b/invofi/apps/sdk/src/index.ts @@ -18,48 +18,28 @@ export { export type { InvofiClientConfig } from './config'; export type { Currency, FinancingOffer, Invoice, InvoiceStatus, OfferStatus } from './types'; -// ── Typed contract call builder (#215) ────────────────────────────────────── -// `client.contracts..(params)` — compile-time checked function -// names and parameter types (derived from the ABI below), plus runtime -// validation of parameter values before each call reaches the network. -// Available on both `createInvofiClient` and `createMockClient` results. -export { - createContractsNamespace, - buildTypedContract, - validateAbiParams, - type ContractsNamespace, - type RegistryContract, - type FinancingContract, - type RepaymentContract, - type PositionTokenContract, - type TypedContract, - type TypedContractImpl, -} from './contracts'; -export { - REGISTRY_ABI, - FINANCING_ABI, - REPAYMENT_ABI, - POSITION_TOKEN_ABI, - type AbiScalarType, - type AbiNativeType, - type AbiParamDef, - type AbiFunctionDef, - type InferParams, - type RegistryReturns, - type FinancingReturns, - type RepaymentReturns, - type PositionTokenReturns, -} from './types/contract-abi'; - -// ── Offline mock client (#177) ────────────────────────────────────────────── +// ── Offline mock client (#177) + contract-interaction testing (#226) ──────── // `createMockClient` is a drop-in replacement for `createInvofiClient` backed // by in-memory state — no RPC, Horizon, wallet, or testnet required. It is for // UI development only (no crypto/signing simulation). Deterministic fixtures // cover every invoice status, offers, and position-token balances. +// +// Since #226 it doubles as a contract-interaction testing framework: every +// successful state-changing call records the protocol event it would have +// emitted on-chain (`client.events`, same `ProtocolEvent` shapes as +// `listenToEvents`), domain failures throw typed `ContractError`s, failure +// rules (`failures` option / `failNext`) simulate deterministic RPC/contract +// failures, and `reset()`/`setBalance`/`seededInvoices`/`seededOffers` give +// tests full control over in-memory state. Pair with `createTestInvoice` / +// `createTestOffer` (below) to compose pre-seeded data. export { createMockClient, type MockClient, type MockClientOptions, + // Testing framework (#226) types. + type MockTestingSurface, + type MockFailureRule, + type MockMethodName, // Deterministic mock identities + fixtures (shared with the frontend mock). MOCK_WALLET_ADDRESS, MOCK_BUSINESS_A, @@ -68,8 +48,24 @@ export { MOCK_LENDER_B, MOCK_POSITION_TOKEN_ID, MOCK_POSITION_BALANCE, + // Contract ids reported in mock-emitted events. + MOCK_REGISTRY_ID, + MOCK_FINANCING_ID, + MOCK_REPAYMENT_ID, } from './mock'; +// ── Test fixture builders (#226) ──────────────────────────────────────────── +// `createTestInvoice` / `createTestOffer` produce SDK-valid fixture objects +// for composing custom pre-seeded data in contract-interaction tests. +export { + createTestInvoice, + createTestOffer, + toStroops, + STROOP_BASE, + type TestInvoiceOverrides, + type TestOfferOverrides, +} from './testing'; + // Validation helpers re-exported for consumers who want to pre-validate // before calling SDK methods (e.g. form-level validation in the frontend). export { validate, type ErrorCode as ValidationErrorCode } from './validation'; @@ -160,23 +156,6 @@ export type { ReputationRecordedData, } from './events'; -// ── Contract interaction testing framework (#226) ─────────────────────────── -// `createTestInvoice` / `createTestOffer` — typed factory helpers with -// sensible defaults + partial overrides. -// `MockServerBuilder` — fluent builder for configuring failure scenarios on -// the mock client (insufficient balance, auth errors, network errors, …). -// `EventTracker` — wraps any InvofiClient and captures protocol events emitted -// by each state-changing call so tests can assert on event history. -export { - createTestInvoice, - createTestOffer, - MockServerBuilder, - createMockServerBuilder, - EventTracker, - createEventTracker, -} from './testing'; -export type { TrackedEventType, TrackedEvent } from './testing'; - // ── Offline cache (IndexedDB, stale-while-revalidate) ─────────────────────── // Browser-only, gracefully no-ops under SSR/Node (see cache.ts). Caches // invoice/offer/position reads with configurable per-type TTLs and evicts diff --git a/invofi/apps/sdk/src/mock.ts b/invofi/apps/sdk/src/mock.ts index 37044d3c..6fbc1a1b 100644 --- a/invofi/apps/sdk/src/mock.ts +++ b/invofi/apps/sdk/src/mock.ts @@ -10,10 +10,31 @@ // Validation is shared with the real client (imported from `./validation`), so // a caller cannot tell the two apart from their error behaviour — only that // the mock never performs any IO. +// +// ## Contract-interaction testing framework (#226) +// +// The mock doubles as a test-time replacement for the real Soroban backend: +// - **Event tracking** — every successful state-changing call records the +// protocol event it would have emitted on-chain (`client.events`), using +// the same `ProtocolEvent` shapes that `listenToEvents` consumes, so +// tests can assert on events without a testnet. +// - **Typed failures** — domain failures (not found / unauthorized / +// insufficient balance / already exists) throw `ContractError`s matching +// the real client's error contract, and failure rules can be configured +// up front (`failures` option) or queued per call (`failNext`) to +// simulate arbitrary RPC/contract failures deterministically. +// - **State control** — `reset()` restores the seeded state between test +// cases, `setBalance`/`getBalance` set up balance scenarios explicitly, +// and `seededInvoices()`/`seededOffers()` expose the fixture builders. +// - **Fixture helpers** — `createTestInvoice` / `createTestOffer` live in +// `./testing.ts` (re-exported from the package root) for composing +// custom pre-seeded data. import type { InvofiClient, InvofiClientMethods } from './client'; import type { Currency, FinancingOffer, Invoice } from './types'; -import type { CacheHandle, CacheScope, CacheEntry, StaleWhileRevalidateResult } from './cache'; +import type { CacheEntry, CacheHandle, CacheScope, StaleWhileRevalidateResult } from './cache'; +import { ContractError, ContractErrorType } from './errors'; +import type { ProtocolEvent } from './events'; import { xdr } from '@stellar/stellar-sdk'; import { createContractsNamespace } from './contracts'; import { @@ -42,6 +63,16 @@ export const MOCK_LENDER_B = `G${'L'.repeat(55)}`; /** Position-token contract id the mock reports (a valid `C…` contract address). */ export const MOCK_POSITION_TOKEN_ID = 'CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35B4AE7LZLLI4VXMCF7'; +/** + * Contract ids the mock reports in the `contractId` field of emitted protocol + * events. Mock-only labels (valid `C…` addresses that back no deployed + * contract) so a recorded event carries exactly the same shape as one + * delivered by `listenToEvents`. + */ +export const MOCK_REGISTRY_ID = 'CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35BMOCKLZLLI4VXMCF7'; +export const MOCK_FINANCING_ID = 'CBGRA3457ZFXYZNEQLO4YGUQ3OBEWOE6US6ZREHKMOCKDLZYBO73IFVW'; +export const MOCK_REPAYMENT_ID = 'CCDATW5GMVDOPK55Q4MLXV5SGA3VLXPD67ABLBNMMOCK6BLL2IZBUVEP'; + /** 1 XLM / USDC base unit in stroops — mirrors the protocol's 7-decimal convention. */ const BASE = 10_000_000n; const xlm = (n: number | bigint): bigint => BigInt(n) * BASE; @@ -67,6 +98,74 @@ export interface MockClientOptions { tokenDecimals?: number; /** Optional override for the demo wallet's starting token balance (stroops). */ positionBalance?: bigint; + /** + * Failure rules installed at construction time (testing framework, #226). + * Each matching call throws the configured error instead of executing. + * Use `client.failNext(...)` for one-shot injection, or `addFailure` for a + * sticky rule added after construction. + */ + failures?: MockFailureRule[]; +} + +/** Any callable method on the client — valid failure-rule target. */ +export type MockMethodName = Exclude; + +/** + * A deterministic, injectable failure rule (testing framework, #226). + * + * When a client method matches `on`, it throws `error` — or a default + * `ContractError(UNKNOWN)` built from `message` — instead of executing. A rule + * with a finite `times` is removed once it has fired that many times. + * + * @example + * ```ts + * const client = createMockClient({ + * failures: [{ on: 'acceptOffer', error: new ContractError(5, ContractErrorType.INSUFFICIENT_BALANCE, 'Lender has no funds') }], + * }); + * ``` + */ +export interface MockFailureRule { + /** Method to fail; `'*'` (the default) matches every method. */ + on?: MockMethodName | '*'; + /** Error to throw. Defaults to a `ContractError(UNKNOWN)` built from `message`. */ + error?: Error; + /** Message used to build the default error when `error` is omitted. */ + message?: string; + /** Times to fire before the rule is removed. Defaults to Infinity. */ + times?: number; +} + +/** + * Test-oriented surface returned alongside the `InvofiClient` methods by + * `createMockClient` (testing framework, #226). + */ +export interface MockTestingSurface { + /** + * Protocol events emitted by successful state-changing calls so far, in + * arrival order, with deterministic fake ledger/txHash fields. Same + * `ProtocolEvent` shapes `listenToEvents` delivers on-chain. + */ + readonly events: ReadonlyArray; + /** Clear the recorded event log (in-memory state is untouched). */ + clearEvents(): void; + /** + * Restore the seeded in-memory state (fresh fixtures, demo balance, + * trustlines), clear the event log, and restore the `failures` supplied via + * `MockClientOptions` (one-shot rules queued with `failNext` are dropped). + */ + reset(): Promise; + /** Queue a one-shot injected failure for the next matching call. */ + failNext(on: MockMethodName | '*', error?: Error, message?: string): void; + /** Add a sticky failure rule (kept until consumed or `reset()`). */ + addFailure(rule: MockFailureRule): void; + /** Read a mock address's position-token balance (stroops). */ + getBalance(address: string): bigint; + /** Override a mock address's position-token balance (e.g. to set up an overdraft). */ + setBalance(address: string, amount: bigint): void; + /** A fresh copy of the seeded invoice fixtures. */ + seededInvoices(): Invoice[]; + /** A fresh copy of the seeded offer fixtures. */ + seededOffers(): FinancingOffer[]; } // ── Deterministic fixtures ─────────────────────────────────────────────────── @@ -147,7 +246,7 @@ function seedOffers(): FinancingOffer[] { // ── Factory ────────────────────────────────────────────────────────────────── -export function createMockClient(options: MockClientOptions = {}): InvofiClient { +export function createMockClient(options: MockClientOptions = {}): MockClient { const positionTokenId = options.positionTokenId ?? MOCK_POSITION_TOKEN_ID; const tokenDecimals = options.tokenDecimals ?? 7; const positionBalance = options.positionBalance ?? MOCK_POSITION_BALANCE; @@ -159,15 +258,70 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient const balances = new Map([[MOCK_WALLET_ADDRESS, positionBalance]]); const trustlines = new Set([MOCK_WALLET_ADDRESS]); + // ── Event log + failure injection (testing framework, #226) ─────────────── + // Every successfully-executed state-changing call records the protocol event + // the real contract would have published; injected failures are consumed + // before any work happens and reject like a failed contract call. + + const events: ProtocolEvent[] = []; + let ledger = 1000; + let txSeq = 0; + + /** Distributive Omit — preserves the per-variant `type`↔`data` correlation across a union. */ + type DistributiveOmit = T extends unknown ? Omit : never; + + /** Record a protocol event with deterministic (fake) ledger + txHash fields. */ + function emit(event: DistributiveOmit): void { + ledger += 1; + txSeq += 1; + // The `as` cast is required because spreading a union-typed value widens it; + // `DistributiveOmit` keeps the per-variant `type`↔`data` correlation at the + // call sites, so the cast is safe here. + events.push({ + ...event, + ledger, + txHash: txSeq.toString(16).padStart(64, '0'), + } as ProtocolEvent); + } + + // Deep-copied so rule bookkeeping (times, removal) never mutates the + // caller's `options.failures` objects, and `reset()` can faithfully + // restore the originally-configured rules. + const failures: MockFailureRule[] = (options.failures ?? []).map(rule => ({ ...rule })); + + /** + * Consume the first failure rule matching `method` (if any) and return its + * error. Returns `undefined` when no rule matches — the call proceeds. + */ + function takeFailure(method: MockMethodName): Error | undefined { + const idx = failures.findIndex(rule => (rule.on ?? '*') === '*' || rule.on === method); + if (idx === -1) return undefined; + const rule = failures[idx]; + const error = rule.error ?? new ContractError( + -1, + ContractErrorType.UNKNOWN, + rule.message ?? `Simulated failure while calling ${method}`, + ); + if (typeof rule.times === 'number') { + rule.times -= 1; + if (rule.times <= 0) failures.splice(idx, 1); + } + return error; + } + const requireInvoice = (id: string): Invoice => { const invoice = invoices.get(id); - if (!invoice) throw new Error(`Invoice not found: ${id}`); + if (!invoice) { + throw new ContractError(2, ContractErrorType.NOT_FOUND, `Invoice not found: ${id}`); + } return invoice; }; const requireOffer = (id: string): FinancingOffer => { const offer = offers.get(id); - if (!offer) throw new Error(`Offer not found: ${id}`); + if (!offer) { + throw new ContractError(2, ContractErrorType.NOT_FOUND, `Offer not found: ${id}`); + } return offer; }; @@ -191,9 +345,6 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient }; const base: InvofiClientMethods = { - // ── Cache (no-op for offline mock) ────────────────────────────────────── - cache: mockCache, - // ── Registry ──────────────────────────────────────────────────────────── async registerInvoice(params, originatorAddress) { validateStellarAddress(originatorAddress, 'originatorAddress'); @@ -202,8 +353,10 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient validateCurrency(params.currency, 'params.currency'); validateFutureTimestamp(params.dueDate, 'params.dueDate'); + const injected = takeFailure('registerInvoice'); + if (injected) throw injected; if (invoices.has(params.id)) { - throw new Error(`Invoice already exists: ${params.id}`); + throw new ContractError(7, ContractErrorType.ALREADY_EXISTS, `Invoice already exists: ${params.id}`); } const invoice: Invoice = { id: params.id, @@ -215,6 +368,12 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient created_at: new Date().toISOString(), }; invoices.set(invoice.id, invoice); + emit({ + type: 'inv_reg', + subjectId: invoice.id, + contractId: MOCK_REGISTRY_ID, + data: { originator: invoice.originator, amount: invoice.amount, dueDate: BigInt(invoice.due_date) }, + }); return invoice; }, @@ -223,17 +382,24 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient if (sourceAccount !== undefined) validateStellarAddress(sourceAccount, 'sourceAccount'); // Resolve asynchronously so a missing invoice rejects (matching the real // client's RPC path) while argument validation still throws synchronously. - return Promise.resolve().then(() => requireInvoice(id)); + return Promise.resolve().then(() => { + const injected = takeFailure('getInvoice'); + if (injected) throw injected; + return requireInvoice(id); + }); }, async cancelInvoice(invoiceId, originatorAddress) { validateSymbolId(invoiceId, 'invoiceId'); validateStellarAddress(originatorAddress, 'originatorAddress'); + const injected = takeFailure('cancelInvoice'); + if (injected) throw injected; const invoice = requireInvoice(invoiceId); if (invoice.originator !== originatorAddress) { - throw new Error('Only the invoice originator can cancel an invoice'); + throw new ContractError(1, ContractErrorType.UNAUTHORIZED, 'Only the invoice originator can cancel an invoice'); } invoice.status = 'Cancelled'; + emit({ type: 'inv_cxl', subjectId: invoice.id, contractId: MOCK_REGISTRY_ID, data: { originator: invoice.originator } }); return invoice; }, @@ -247,8 +413,10 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient validateInterestRate(params.interestRate, 'params.interestRate'); validateDuration(params.duration, 'params.duration'); + const injected = takeFailure('createOffer'); + if (injected) throw injected; if (offers.has(params.offerId)) { - throw new Error(`Offer already exists: ${params.offerId}`); + throw new ContractError(7, ContractErrorType.ALREADY_EXISTS, `Offer already exists: ${params.offerId}`); } requireInvoice(params.invoiceId); const offer: FinancingOffer = { @@ -264,34 +432,59 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient funded_at: 0, }; offers.set(offer.id, offer); + emit({ + type: 'off_new', + subjectId: offer.id, + contractId: MOCK_FINANCING_ID, + data: { invoiceId: offer.invoice_id, lender: offer.lender, amount: offer.amount, interestRate: offer.interest_rate }, + }); return offer; }, getOffer(id, sourceAccount) { validateSymbolId(id, 'id'); if (sourceAccount !== undefined) validateStellarAddress(sourceAccount, 'sourceAccount'); - return Promise.resolve().then(() => requireOffer(id)); + return Promise.resolve().then(() => { + const injected = takeFailure('getOffer'); + if (injected) throw injected; + return requireOffer(id); + }); }, async acceptOffer(offerId, originatorAddress) { validateSymbolId(offerId, 'offerId'); validateStellarAddress(originatorAddress, 'originatorAddress'); + const injected = takeFailure('acceptOffer'); + if (injected) throw injected; const offer = requireOffer(offerId); const invoice = requireInvoice(offer.invoice_id); if (invoice.originator !== originatorAddress) { - throw new Error('Only the invoice originator can accept an offer'); + throw new ContractError(1, ContractErrorType.UNAUTHORIZED, 'Only the invoice originator can accept an offer'); } offer.status = 'Accepted'; offer.funded_at = Math.floor(Date.now() / 1000); invoice.status = 'Financed'; + emit({ + type: 'off_acc', + subjectId: offer.id, + contractId: MOCK_FINANCING_ID, + data: { invoiceId: offer.invoice_id, lender: offer.lender, amount: offer.amount }, + }); return offer; }, async rejectOffer(offerId, originatorAddress) { validateSymbolId(offerId, 'offerId'); validateStellarAddress(originatorAddress, 'originatorAddress'); + const injected = takeFailure('rejectOffer'); + if (injected) throw injected; const offer = requireOffer(offerId); + const invoice = requireInvoice(offer.invoice_id); + if (invoice.originator !== originatorAddress) { + throw new ContractError(1, ContractErrorType.UNAUTHORIZED, 'Only the invoice originator can reject an offer'); + } offer.status = 'Rejected'; + emit({ type: 'off_rej', subjectId: offer.id, contractId: MOCK_FINANCING_ID, data: { invoiceId: offer.invoice_id } }); return offer; }, @@ -302,10 +495,12 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient validateStellarAddress(repayerAddress, 'repayerAddress'); validatePositiveI128(amount, 'amount'); + const injected = takeFailure('repayInvoice'); + if (injected) throw injected; const invoice = requireInvoice(invoiceId); const offer = requireOffer(offerId); if (offer.invoice_id !== invoiceId) { - throw new Error(`Offer ${offerId} does not finance invoice ${invoiceId}`); + throw new ContractError(6, ContractErrorType.INVALID_INPUT, `Offer ${offerId} does not finance invoice ${invoiceId}`); } offer.amount_repaid += amount; @@ -313,14 +508,23 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient const fullyRepaid = offer.amount_repaid >= totalDue; offer.status = fullyRepaid ? 'Repaid' : 'Financed'; invoice.status = fullyRepaid ? 'Repaid' : 'Financed'; + emit({ + type: 'inv_rep', + subjectId: invoice.id, + contractId: MOCK_REPAYMENT_ID, + data: { offerId, amount, fullyRepaid }, + }); return invoice; }, async markOverdue(invoiceId, callerAddress) { validateSymbolId(invoiceId, 'invoiceId'); validateStellarAddress(callerAddress, 'callerAddress'); + const injected = takeFailure('markOverdue'); + if (injected) throw injected; const invoice = requireInvoice(invoiceId); invoice.status = 'Overdue'; + emit({ type: 'inv_ovd', subjectId: invoice.id, contractId: MOCK_REPAYMENT_ID, data: { dueDate: BigInt(invoice.due_date) } }); return invoice; }, @@ -328,30 +532,45 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient validateSymbolId(invoiceId, 'invoiceId'); validateSymbolId(offerId, 'offerId'); validateStellarAddress(lenderAddress, 'lenderAddress'); + const injected = takeFailure('reclaimInvoice'); + if (injected) throw injected; const offer = requireOffer(offerId); if (offer.lender !== lenderAddress) { - throw new Error('Only the lender can reclaim an invoice'); + throw new ContractError(1, ContractErrorType.UNAUTHORIZED, 'Only the lender can reclaim an invoice'); } offer.status = 'Defaulted'; + emit({ type: 'off_def', subjectId: offer.invoice_id, contractId: MOCK_REPAYMENT_ID, data: { invoiceId: offer.invoice_id, lender: offer.lender } }); return offer; }, // ── Position tokens ───────────────────────────────────────────────────── getPositionTokenId(sourceAccount) { if (sourceAccount !== undefined) validateStellarAddress(sourceAccount, 'sourceAccount'); - return Promise.resolve(positionTokenId); + return Promise.resolve().then(() => { + const injected = takeFailure('getPositionTokenId'); + if (injected) throw injected; + return positionTokenId; + }); }, getTokenBalance(tokenId, address) { validateStellarAddress(tokenId, 'tokenId'); validateStellarAddress(address, 'address'); - if (tokenId !== positionTokenId) return Promise.resolve(0n); - return Promise.resolve(balances.get(address) ?? 0n); + return Promise.resolve().then(() => { + const injected = takeFailure('getTokenBalance'); + if (injected) throw injected; + if (tokenId !== positionTokenId) return 0n; + return balances.get(address) ?? 0n; + }); }, getTokenDecimals(tokenId) { validateStellarAddress(tokenId, 'tokenId'); - return Promise.resolve(tokenDecimals); + return Promise.resolve().then(() => { + const injected = takeFailure('getTokenDecimals'); + if (injected) throw injected; + return tokenDecimals; + }); }, async transferPositionToken(tokenId, fromAddress, toAddress, amount) { @@ -360,9 +579,11 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient validateStellarAddress(toAddress, 'toAddress'); validatePositiveI128(amount, 'amount'); + const injected = takeFailure('transferPositionToken'); + if (injected) throw injected; const fromBalance = balances.get(fromAddress) ?? 0n; if (amount > fromBalance) { - throw new Error('Insufficient position-token balance'); + throw new ContractError(5, ContractErrorType.INSUFFICIENT_BALANCE, 'Insufficient position-token balance'); } balances.set(fromAddress, fromBalance - amount); balances.set(toAddress, (balances.get(toAddress) ?? 0n) + amount); @@ -371,32 +592,88 @@ export function createMockClient(options: MockClientOptions = {}): InvofiClient // ── Trustlines ────────────────────────────────────────────────────────── async hasPositionTrustline(address) { validateStellarAddress(address, 'address'); + const injected = takeFailure('hasPositionTrustline'); + if (injected) throw injected; return trustlines.has(address); }, async addPositionTrustline(address) { validateStellarAddress(address, 'address'); + const injected = takeFailure('addPositionTrustline'); + if (injected) throw injected; trustlines.add(address); }, - // ── Batch ────────────────────────────────────────────────────────────── + // ── Batch ─────────────────────────────────────────────────────────────── async batch(calls, sourceAddress) { validateStellarAddress(sourceAddress, 'sourceAddress'); + const injected = takeFailure('batch'); + if (injected) throw injected; // In mock mode, return a dummy empty ScVal for each call. // Real batch execution is network-dependent and cannot be simulated // without a Soroban RPC endpoint. return calls.map(() => xdr.ScVal.scvVoid()); }, + + // ── Offline cache (Task 218) ───────────────────────────────────────────── + // Type parity with the real client, which always exposes a `cache` handle. + // The mock is pure in-memory and never touches IndexedDB, so `cache` is a + // no-op handle — it exists purely so `MockClient` is a drop-in for + // `InvofiClient`. Reads fall through to the fetcher immediately (no stale + // data is ever served), which mirrors the real client's cache contract + // from a caller's perspective while keeping the mock fully offline. + cache: mockCache, }; const client: InvofiClient = { ...base, - // Typed call builder (#215) — same wrapping as the real client, so - // `client.contracts.*` behaves identically against mock or live state. contracts: createContractsNamespace(base), }; - return client; + // ── Testing surface (#226) ───────────────────────────────────────────────── + // Attached alongside the InvofiClient methods — see `MockTestingSurface`. + const testingSurface = { + events, + clearEvents(): void { + events.length = 0; + }, + async reset(): Promise { + invoices.clear(); + for (const invoice of seedInvoices()) invoices.set(invoice.id, invoice); + offers.clear(); + for (const offer of seedOffers()) offers.set(offer.id, offer); + balances.clear(); + balances.set(MOCK_WALLET_ADDRESS, positionBalance); + trustlines.clear(); + trustlines.add(MOCK_WALLET_ADDRESS); + events.length = 0; + ledger = 1000; + txSeq = 0; + failures.splice(0, failures.length, ...(options.failures ?? []).map(rule => ({ ...rule }))); + }, + failNext(on: MockMethodName | '*', error?: Error, message?: string): void { + failures.unshift({ on, error, message, times: 1 }); + }, + addFailure(rule: MockFailureRule): void { + failures.push(rule); + }, + getBalance(address: string): bigint { + validateStellarAddress(address, 'address'); + return balances.get(address) ?? 0n; + }, + setBalance(address: string, amount: bigint): void { + validateStellarAddress(address, 'address'); + balances.set(address, amount); + }, + seededInvoices(): Invoice[] { + return seedInvoices(); + }, + seededOffers(): FinancingOffer[] { + return seedOffers(); + }, + }; + + return Object.assign(client, testingSurface); } -export type MockClient = InvofiClient; +export type MockClient = InvofiClient & MockTestingSurface; diff --git a/invofi/apps/sdk/src/testing.ts b/invofi/apps/sdk/src/testing.ts index 1f3b1fed..1ec64e45 100644 --- a/invofi/apps/sdk/src/testing.ts +++ b/invofi/apps/sdk/src/testing.ts @@ -1,544 +1,131 @@ -// ── Testing framework — mock Soroban environment (#226) ───────────────────── +// ── Test fixture builders (contract-interaction testing framework, #226) ──── // -// Provides helpers for writing contract-interaction tests against the InvoFi -// SDK without any live network. Three layers of tooling: +// Helpers for composing pre-seeded test data without hand-rolling every field. +// The defaults are chosen so the produced objects pass the SDK's own validators +// (`registerInvoice` / `createOffer` accept them as-is), and any field can be +// overridden — including the `dueDate` / `invoiceId` aliases for readability. // -// 1. `createTestInvoice` / `createTestOffer` — typed factory helpers that -// produce fully-populated Invoice / FinancingOffer objects with sensible -// defaults, accepting partial overrides. -// -// 2. `MockServerBuilder` — fluent builder for configuring failure scenarios -// on an in-memory mock client (insufficient balance, auth errors, network -// errors, etc.) before calling `.build()`. -// -// 3. `EventTracker` — wraps any `InvofiClient` and intercepts every -// state-changing call to record which protocol events would have been -// emitted. `.getEvents()`, `.getEventCount(type)`, and `.reset()` let -// tests assert on event history without touching real Soroban RPC. +// Combine with `createMockClient` from `./mock` for fast, isolated contract +// interaction tests: seed a client, register an invoice created here, assert +// on the returned shapes and on `client.events`, and reset between cases. -import type { InvofiClient, InvofiClientMethods } from './client'; -import { createContractsNamespace } from './contracts'; -import { createMockClient } from './mock'; import type { Currency, FinancingOffer, Invoice, InvoiceStatus, OfferStatus } from './types'; - -// ── Well-known test addresses ──────────────────────────────────────────────── -// Use valid Stellar G-addresses that pass SDK validation but correspond to no -// real accounts on any network. - -const TEST_ORIGINATOR = 'GCHVSUK5XKL44CSZ3WGI2W2OZCC7SXZMM5B34TCOQ2YNEGPNP3BLOVMT'; -const TEST_LENDER = 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; - -// ── 1. Test factory helpers ────────────────────────────────────────────────── +import { MOCK_BUSINESS_A, MOCK_LENDER_B } from './mock'; /** - * Create an `Invoice` object with sensible defaults. - * - * All fields can be overridden via `overrides`. The returned object satisfies - * the `Invoice` interface exactly — useful for seeding mocks or asserting - * shapes in unit tests. - * - * @example - * ```ts - * const inv = createTestInvoice({ id: 'inv_test_001', status: 'Financed' }); - * ``` + * One XLM / USDC base unit in stroops — mirrors the protocol's 7-decimal + * convention used throughout the SDK and the mock fixtures. */ -export function createTestInvoice(overrides: Partial = {}): Invoice { - const now = Math.floor(Date.now() / 1000); - const defaults: Invoice = { - id: 'inv_test_default', - originator: TEST_ORIGINATOR, - amount: 10_000_000n, // 1 unit (1 XLM / 1 USDC in stroops) - currency: 'XLM' as Currency, - due_date: now + 30 * 86_400, // 30 days from now - status: 'Pending' as InvoiceStatus, - created_at: new Date().toISOString(), - }; - return { ...defaults, ...overrides }; -} +export const STROOP_BASE = 10_000_000n; -/** - * Create a `FinancingOffer` object with sensible defaults. - * - * All fields can be overridden via `overrides`. The returned object satisfies - * the `FinancingOffer` interface exactly. - * - * @example - * ```ts - * const offer = createTestOffer({ id: 'off_test_001', interest_rate: 750 }); - * ``` - */ -export function createTestOffer(overrides: Partial = {}): FinancingOffer { - const defaults: FinancingOffer = { - id: 'off_test_default', - invoice_id: 'inv_test_default', - lender: TEST_LENDER, - amount: 10_000_000n, // 1 unit in stroops - currency: 'XLM' as Currency, - interest_rate: 500, // 5.00% - duration: 30 * 86_400, // 30 days in seconds - amount_repaid: 0n, - status: 'Pending' as OfferStatus, - funded_at: 0, - }; - return { ...defaults, ...overrides }; +/** Convert a whole-unit (XLM/USDC) amount to stroops. */ +export function toStroops(units: number | bigint): bigint { + return BigInt(units) * STROOP_BASE; } -// ── 2. MockServerBuilder ───────────────────────────────────────────────────── +/** A future Unix timestamp (seconds) — valid for `registerInvoice` due dates. */ +function futureTimestamp(daysFromNow: number): number { + return Math.floor(Date.now() / 1000) + daysFromNow * 86_400; +} -/** Failure mode flags configured on the builder before `.build()`. */ -interface MockServerFailureConfig { - insufficientBalance: boolean; - authError: boolean; - networkError: boolean; - rejectedOffer: boolean; +/** + * Overrides for {@link createTestInvoice}. All fields of {@link Invoice} can be + * overridden; `dueDate` is a convenience alias for `due_date` (and wins if both + * are given). + */ +export interface TestInvoiceOverrides extends Partial { + /** Convenience alias for `due_date` (Unix seconds in the future). */ + dueDate?: number; } /** - * Fluent builder for a pre-configured in-memory mock client. - * - * Each `.withXxx()` method activates a specific failure scenario; `.build()` - * returns the fully configured `InvofiClient`. + * A ready-to-use {@link Invoice} fixture with deterministic defaults: * - * @example - * ```ts - * const client = createMockServerBuilder() - * .withAuthError() - * .build(); + * | Field | Default | + * |--------------|--------------------------------------| + * | `id` | `inv_test_001` | + * | `originator` | `MOCK_BUSINESS_A` | + * | `amount` | `1_000_000_000n` (100 XLM / USDC) | + * | `currency` | `XLM` | + * | `due_date` | now + 30 days | + * | `status` | `Pending` | + * | `created_at` | now (ISO) | * - * await expect(client.registerInvoice(...)).rejects.toThrow('Auth error: unauthorized'); - * ``` + * Pass the result straight to `registerInvoice(params, originator)` — every + * default passes the SDK's validators. Override fields to shape the fixture, + * e.g. `createTestInvoice({ id: 'inv_a', amount: toStroops(5), currency: 'USDC' })`. */ -export class MockServerBuilder { - private readonly failures: MockServerFailureConfig = { - insufficientBalance: false, - authError: false, - networkError: false, - rejectedOffer: false, +export function createTestInvoice(overrides: TestInvoiceOverrides = {}): Invoice { + const { dueDate, ...rest } = overrides; + const base: Invoice = { + id: 'inv_test_001', + originator: MOCK_BUSINESS_A, + amount: toStroops(100), + currency: 'XLM', + due_date: futureTimestamp(30), + status: 'Pending', + created_at: new Date().toISOString(), + }; + return { + ...base, + ...rest, + due_date: dueDate ?? rest.due_date ?? base.due_date, }; - - private readonly overdueInvoiceIds: string[] = []; - - /** - * Makes token-related calls (transferPositionToken, getTokenBalance, - * getTokenDecimals, addPositionTrustline) fail with `'Insufficient balance'`. - */ - withInsufficientBalance(): this { - this.failures.insufficientBalance = true; - return this; - } - - /** - * Makes all state-changing calls (register, createOffer, acceptOffer, - * rejectOffer, repayInvoice, markOverdue, reclaimInvoice, cancelInvoice, - * transferPositionToken, addPositionTrustline) fail with - * `'Auth error: unauthorized'`. - */ - withAuthError(): this { - this.failures.authError = true; - return this; - } - - /** - * Makes every async SDK method fail with `'Network error'`, simulating a - * completely unavailable RPC / Horizon connection. - */ - withNetworkError(): this { - this.failures.networkError = true; - return this; - } - - /** - * Makes `acceptOffer` always reject with `'Offer rejected'` regardless of - * inputs. - */ - withRejectedOffer(): this { - this.failures.rejectedOffer = true; - return this; - } - - /** - * Seeds an invoice with the given `invoiceId` in the `'Overdue'` status so - * callers can exercise overdue-handling code paths. - * - * If the ID already exists in the fixture set (e.g. `'inv_mock_o001'`) the - * builder records it for post-construction status override; if it does not - * exist the builder registers a fresh one. - */ - withOverdueInvoice(invoiceId: string): this { - this.overdueInvoiceIds.push(invoiceId); - return this; - } - - /** - * Build and return the configured `InvofiClient`. - * - * The returned client is a standard `InvofiClient` where all requested - * failure scenarios have been wired in. Pass it to `EventTracker.wrap()` - * to also capture event history. - */ - build(): InvofiClient { - // Start from a clean in-memory mock so all fixtures and validators work. - const base = createMockClient(); - const { insufficientBalance, authError, networkError, rejectedOffer } = this.failures; - - // ── Seed overdue invoices ─────────────────────────────────────────────── - // We use markOverdue on the base client directly (bypasses all failure - // shims that we haven't installed yet) to put the requested invoices into - // Overdue state. For IDs that don't exist in the seed we register them - // first with a past due_date. - const seedOverdue = async (): Promise => { - for (const id of this.overdueInvoiceIds) { - try { - // Try getting it first — it might already be in the fixture set. - await base.getInvoice(id); - } catch { - // Not found → register a fresh one. Due date slightly in the past - // so the keeper is allowed to mark it overdue. - const pastDue = Math.floor(Date.now() / 1000) - 86_400; - await base.registerInvoice( - { id, amount: 10_000_000n, currency: 'XLM', dueDate: pastDue + 1 }, - TEST_ORIGINATOR, - ).catch(() => undefined); // ignore if already registered - } - await base.markOverdue(id, TEST_ORIGINATOR).catch(() => undefined); - } - }; - - // We can't await here (synchronous build), so we kick the overdue seeding - // off immediately and the returned proxy delegates all reads through the - // base client which will have the state by the time the test awaits any - // method. In practice, tests that call withOverdueInvoice() and then - // immediately .build() should either use a short await or the built-in - // fixture IDs ('inv_mock_o001') which are already Overdue. - void seedOverdue(); - - // ── Build the wrapped client ──────────────────────────────────────────── - - if (!insufficientBalance && !authError && !networkError && !rejectedOffer) { - // No failure modes → return the base client directly. - return base; - } - - // Wrap the base client with a proxy that injects the requested failures. - // Build the method surface first, then attach `contracts` so the typed - // call builder delegates through the same failure shims. - const methods: InvofiClientMethods = { - // Cache pass-through (no failure shim — cache is purely local). - cache: base.cache, - - // ── Read methods ────────────────────────────────────────────────────── - getInvoice: networkError - ? () => Promise.reject(new Error('Network error')) - : (id, src) => base.getInvoice(id, src), - - getOffer: networkError - ? () => Promise.reject(new Error('Network error')) - : (id, src) => base.getOffer(id, src), - - getPositionTokenId: networkError - ? () => Promise.reject(new Error('Network error')) - : (src) => base.getPositionTokenId(src), - - getTokenBalance: networkError - ? () => Promise.reject(new Error('Network error')) - : insufficientBalance - ? () => Promise.reject(new Error('Insufficient balance')) - : (tokenId, address) => base.getTokenBalance(tokenId, address), - - getTokenDecimals: networkError - ? () => Promise.reject(new Error('Network error')) - : insufficientBalance - ? () => Promise.reject(new Error('Insufficient balance')) - : (tokenId) => base.getTokenDecimals(tokenId), - - hasPositionTrustline: networkError - ? () => Promise.reject(new Error('Network error')) - : (address) => base.hasPositionTrustline(address), - - // ── State-changing methods ───────────────────────────────────────────── - registerInvoice: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : (params, originator) => base.registerInvoice(params, originator), - - cancelInvoice: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : (invoiceId, originator) => base.cancelInvoice(invoiceId, originator), - - createOffer: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : (params, lender) => base.createOffer(params, lender), - - acceptOffer: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : rejectedOffer - ? () => Promise.reject(new Error('Offer rejected')) - : (offerId, originator) => base.acceptOffer(offerId, originator), - - rejectOffer: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : (offerId, originator) => base.rejectOffer(offerId, originator), - - repayInvoice: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : insufficientBalance - ? () => Promise.reject(new Error('Insufficient balance')) - : (invoiceId, offerId, repayer, amount) => base.repayInvoice(invoiceId, offerId, repayer, amount), - - markOverdue: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : (invoiceId, caller) => base.markOverdue(invoiceId, caller), - - reclaimInvoice: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : (invoiceId, offerId, lender) => base.reclaimInvoice(invoiceId, offerId, lender), - - transferPositionToken: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : insufficientBalance - ? () => Promise.reject(new Error('Insufficient balance')) - : (tokenId, from, to, amount) => base.transferPositionToken(tokenId, from, to, amount), - - addPositionTrustline: networkError - ? () => Promise.reject(new Error('Network error')) - : authError - ? () => Promise.reject(new Error('Auth error: unauthorized')) - : insufficientBalance - ? () => Promise.reject(new Error('Insufficient balance')) - : (address) => base.addPositionTrustline(address), - - batch: networkError - ? () => Promise.reject(new Error('Network error')) - : (calls, sourceAddress) => base.batch(calls, sourceAddress), - }; - - const wrapped: InvofiClient = { - ...methods, - contracts: createContractsNamespace(methods), - }; - - return wrapped; - } } /** - * Convenience factory — creates a new `MockServerBuilder`. - * - * @example - * ```ts - * const client = createMockServerBuilder().withNetworkError().build(); - * ``` + * Overrides for {@link createTestOffer}. All fields of {@link FinancingOffer} + * can be overridden; `invoiceId` is a convenience alias for `invoice_id` (and + * wins if both are given). */ -export function createMockServerBuilder(): MockServerBuilder { - return new MockServerBuilder(); -} - -// ── 3. EventTracker ────────────────────────────────────────────────────────── - -/** The protocol event names that the tracker captures. */ -export type TrackedEventType = - | 'inv_reg' // registerInvoice - | 'inv_cxl' // cancelInvoice - | 'off_new' // createOffer - | 'off_acc' // acceptOffer - | 'off_rej' // rejectOffer - | 'inv_rep' // repayInvoice - | 'inv_ovd' // markOverdue - | 'off_def'; // reclaimInvoice (offer defaulted) - -/** A single tracked event record. */ -export interface TrackedEvent { - type: TrackedEventType; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - payload: Record; +export interface TestOfferOverrides extends Partial { + /** Convenience alias for `invoice_id`. */ + invoiceId?: string; } /** - * Intercepts all state-changing `InvofiClient` calls and records the - * protocol event that would have been emitted by the corresponding contract - * function. + * A ready-to-use {@link FinancingOffer} fixture with deterministic defaults: * - * The tracker holds a reference to a wrapped client; all method calls are - * forwarded to the underlying implementation, and on success the tracker - * appends the corresponding event record. + * | Field | Default | + * |-----------------|-----------------------------------| + * | `id` | `off_test_001` | + * | `invoice_id` | `inv_test_001` | + * | `lender` | `MOCK_LENDER_B` | + * | `amount` | `1_000_000_000n` (100 XLM / USDC) | + * | `currency` | `XLM` | + * | `interest_rate` | `500` (5%) | + * | `duration` | 30 days (seconds) | + * | `amount_repaid` | `0n` | + * | `status` | `Pending` | + * | `funded_at` | `0` | * - * @example - * ```ts - * const tracker = createEventTracker(createMockClient()); - * await tracker.client.registerInvoice(params, address); - * expect(tracker.getEventCount('inv_reg')).toBe(1); - * ``` + * Pass the result straight to `createOffer(params, lenderAddress)` — every + * default passes the SDK's validators. */ -export class EventTracker { - private events: TrackedEvent[] = []; - - /** - * The wrapped `InvofiClient`. Use this to make SDK calls — events are - * captured automatically on every successful call. - */ - readonly client: InvofiClient; - - constructor(baseClient: InvofiClient) { - this.client = this._wrap(baseClient); - } - - /** Static factory — wraps any existing `InvofiClient` in an `EventTracker`. */ - static wrap(client: InvofiClient): EventTracker { - return new EventTracker(client); - } - - /** Returns a shallow copy of the tracked event array. */ - getEvents(): TrackedEvent[] { - return [...this.events]; - } - - /** Returns the number of events of the given `type` that have been captured. */ - getEventCount(type: TrackedEventType): number { - return this.events.filter(e => e.type === type).length; - } - - /** Clears all tracked events. */ - reset(): void { - this.events = []; - } - - // ── Internal ────────────────────────────────────────────────────────────── - - private record(type: TrackedEventType, payload: Record): void { - this.events.push({ type, payload }); - } - - private _wrap(base: InvofiClient): InvofiClient { - // We capture `this` (the tracker) in closures below. - const tracker = this; - - const methods: InvofiClientMethods = { - cache: base.cache, - - // ── Read-only pass-throughs (no events) ────────────────────────────── - getInvoice: (id, src) => base.getInvoice(id, src), - getOffer: (id, src) => base.getOffer(id, src), - getPositionTokenId: (src) => base.getPositionTokenId(src), - getTokenBalance: (tokenId, address) => base.getTokenBalance(tokenId, address), - getTokenDecimals: (tokenId) => base.getTokenDecimals(tokenId), - hasPositionTrustline:(address) => base.hasPositionTrustline(address), - - // ── State-changing wrappers (emit events on success) ───────────────── - - async registerInvoice(params, originatorAddress) { - const invoice = await base.registerInvoice(params, originatorAddress); - tracker.record('inv_reg', { - originator: originatorAddress, - amount: invoice.amount, - due_date: invoice.due_date, - }); - return invoice; - }, - - async cancelInvoice(invoiceId, originatorAddress) { - const invoice = await base.cancelInvoice(invoiceId, originatorAddress); - tracker.record('inv_cxl', { originator: originatorAddress }); - return invoice; - }, - - async createOffer(params, lenderAddress) { - const offer = await base.createOffer(params, lenderAddress); - tracker.record('off_new', { - invoice_id: params.invoiceId, - lender: lenderAddress, - amount: offer.amount, - interest_rate: offer.interest_rate, - }); - return offer; - }, - - async acceptOffer(offerId, originatorAddress) { - const offer = await base.acceptOffer(offerId, originatorAddress); - tracker.record('off_acc', { - invoice_id: offer.invoice_id, - lender: offer.lender, - amount: offer.amount, - }); - return offer; - }, - - async rejectOffer(offerId, originatorAddress) { - const offer = await base.rejectOffer(offerId, originatorAddress); - tracker.record('off_rej', { invoice_id: offer.invoice_id }); - return offer; - }, - - async repayInvoice(invoiceId, offerId, repayerAddress, amount) { - const invoice = await base.repayInvoice(invoiceId, offerId, repayerAddress, amount); - tracker.record('inv_rep', { - offer_id: offerId, - amount, - fully_repaid: invoice.status === 'Repaid', - }); - return invoice; - }, - - async markOverdue(invoiceId, callerAddress) { - const invoice = await base.markOverdue(invoiceId, callerAddress); - tracker.record('inv_ovd', { due_date: invoice.due_date }); - return invoice; - }, - - async reclaimInvoice(invoiceId, offerId, lenderAddress) { - const offer = await base.reclaimInvoice(invoiceId, offerId, lenderAddress); - tracker.record('off_def', { invoice_id: invoiceId, lender: lenderAddress }); - return offer; - }, - - // No protocol event for these (position-token internals). - async transferPositionToken(tokenId, fromAddress, toAddress, amount) { - return base.transferPositionToken(tokenId, fromAddress, toAddress, amount); - }, - - async addPositionTrustline(address) { - return base.addPositionTrustline(address); - }, - - // Batch is a multi-op submit, not a single protocol event. - async batch(calls, sourceAddress) { - return base.batch(calls, sourceAddress); - }, - }; - - const wrapped: InvofiClient = { - ...methods, - contracts: createContractsNamespace(methods), - }; - - return wrapped; - } +export function createTestOffer(overrides: TestOfferOverrides = {}): FinancingOffer { + const { invoiceId, ...rest } = overrides; + const base: FinancingOffer = { + id: 'off_test_001', + invoice_id: 'inv_test_001', + lender: MOCK_LENDER_B, + amount: toStroops(100), + currency: 'XLM', + interest_rate: 500, + duration: 30 * 86_400, + amount_repaid: 0n, + status: 'Pending', + funded_at: 0, + }; + return { + ...base, + ...rest, + invoice_id: invoiceId ?? rest.invoice_id ?? base.invoice_id, + }; } -/** - * Convenience factory — wraps a mock client in an `EventTracker`. - * - * @example - * ```ts - * const tracker = createEventTracker(createMockClient()); - * ``` - */ -export function createEventTracker(client: InvofiClient): EventTracker { - return new EventTracker(client); -} +// ── Re-exports for convenience ─────────────────────────────────────────────── +// Consumers building fixtures alongside mock state usually need the mock +// identities too; re-export them so one import covers both concerns. + +export { MOCK_BUSINESS_A, MOCK_LENDER_B } from './mock'; +export type { Currency, FinancingOffer, Invoice, InvoiceStatus, OfferStatus }; diff --git a/invofi/apps/sdk/tests/testing.test.ts b/invofi/apps/sdk/tests/testing.test.ts index e954c5d9..48729823 100644 --- a/invofi/apps/sdk/tests/testing.test.ts +++ b/invofi/apps/sdk/tests/testing.test.ts @@ -1,757 +1,385 @@ /** - * Unit tests — contract interaction testing framework (#226) + * Unit tests — contract-interaction testing framework (#226) * - * Covers: - * - createTestInvoice / createTestOffer factory helpers - * - MockServerBuilder configurable failure scenarios - * - EventTracker event capture, counting, and reset - * - Combining MockServerBuilder with EventTracker + * Covers the mock's testing surface: emitted protocol-event tracking, typed + * domain failures (not found / unauthorized / insufficient balance / already + * exists), deterministic failure injection (`failures` option, `failNext`, + * `addFailure`), state control (`reset`, `setBalance`/`getBalance`, + * `seededInvoices`/`seededOffers`), and the `createTestInvoice` / + * `createTestOffer` fixture builders. */ import { describe, it, expect } from 'vitest'; import { createMockClient, - MOCK_WALLET_ADDRESS, - MOCK_BUSINESS_A, -} from '../src/mock'; -import { createTestInvoice, createTestOffer, - MockServerBuilder, - createMockServerBuilder, - EventTracker, - createEventTracker, -} from '../src/testing'; -import type { Invoice, FinancingOffer } from '../src/types'; - -// ── Shared fixtures ────────────────────────────────────────────────────────── - -const FUTURE_TS = Math.floor(Date.now() / 1000) + 365 * 24 * 3600; - -// A Pending invoice already in the mock's seed set (originator = MOCK_BUSINESS_A). -const EXISTING_INVOICE_ID = 'inv_mock_p001'; -// A seeded offer from MOCK_WALLET_ADDRESS on a Financed invoice. -const EXISTING_OFFER_ID = 'off_mock_001'; -const EXISTING_FINANCED_INVOICE_ID = 'inv_mock_f001'; - -// ── createTestInvoice ──────────────────────────────────────────────────────── - -describe('createTestInvoice', () => { - it('returns a valid Invoice with sensible defaults', () => { + toStroops, + STROOP_BASE, + MOCK_WALLET_ADDRESS, + MOCK_BUSINESS_A, + MOCK_BUSINESS_B, + MOCK_LENDER_B, + MOCK_POSITION_TOKEN_ID, + MOCK_REGISTRY_ID, + MOCK_FINANCING_ID, + MOCK_REPAYMENT_ID, + ContractError, + ContractErrorType, +} from '../src/index'; +import { SdkValidationError } from '../src/validation'; + +describe('fixture builders — createTestInvoice', () => { + it('produces valid deterministic defaults', () => { const invoice = createTestInvoice(); - - expect(typeof invoice.id).toBe('string'); - expect(invoice.id.length).toBeGreaterThan(0); - expect(typeof invoice.originator).toBe('string'); - expect(invoice.originator.length).toBeGreaterThan(0); - expect(typeof invoice.amount).toBe('bigint'); - expect(invoice.amount > 0n).toBe(true); - expect(['XLM', 'USDC']).toContain(invoice.currency); - expect(typeof invoice.due_date).toBe('number'); - expect(invoice.due_date > Math.floor(Date.now() / 1000)).toBe(true); + expect(invoice.id).toBe('inv_test_001'); + expect(invoice.originator).toBe(MOCK_BUSINESS_A); + expect(invoice.amount).toBe(100n * STROOP_BASE); + expect(invoice.currency).toBe('XLM'); expect(invoice.status).toBe('Pending'); + expect(invoice.due_date).toBeGreaterThan(Math.floor(Date.now() / 1000)); + expect(invoice.created_at).toBeTruthy(); }); - it('satisfies the Invoice TypeScript interface', () => { - const invoice: Invoice = createTestInvoice(); - expect(invoice).toBeDefined(); - }); - - it('applies partial overrides while keeping other defaults', () => { - const invoice = createTestInvoice({ id: 'inv_custom', status: 'Financed', currency: 'USDC' }); - + it('applies field overrides and the dueDate alias', () => { + const due = Math.floor(Date.now() / 1000) + 10 * 86_400; + const invoice = createTestInvoice({ id: 'inv_custom', amount: toStroops(5), currency: 'USDC', dueDate: due }); expect(invoice.id).toBe('inv_custom'); - expect(invoice.status).toBe('Financed'); + expect(invoice.amount).toBe(5n * STROOP_BASE); expect(invoice.currency).toBe('USDC'); - // defaults preserved - expect(typeof invoice.amount).toBe('bigint'); - expect(invoice.amount > 0n).toBe(true); - expect(typeof invoice.originator).toBe('string'); + expect(invoice.due_date).toBe(due); + // dueDate wins over due_date when both are supplied. + const both = createTestInvoice({ due_date: due + 1, dueDate: due }); + expect(both.due_date).toBe(due); }); - it('allows overriding every field', () => { - const now = Math.floor(Date.now() / 1000); - const custom: Invoice = createTestInvoice({ - id: 'inv_full_override', - originator: MOCK_BUSINESS_A, - amount: 999n, - currency: 'USDC', - due_date: now + 7 * 86_400, - status: 'Repaid', - created_at: '2026-01-01T00:00:00.000Z', - }); - - expect(custom.id).toBe('inv_full_override'); - expect(custom.originator).toBe(MOCK_BUSINESS_A); - expect(custom.amount).toBe(999n); - expect(custom.currency).toBe('USDC'); - expect(custom.status).toBe('Repaid'); - expect(custom.created_at).toBe('2026-01-01T00:00:00.000Z'); + it('is accepted by the mock client (registerInvoice) as-is', async () => { + const client = createMockClient(); + const invoice = createTestInvoice(); + const registered = await client.registerInvoice( + { id: invoice.id, amount: invoice.amount, currency: invoice.currency, dueDate: invoice.due_date }, + invoice.originator, + ); + expect(registered.status).toBe('Pending'); + expect((await client.getInvoice(invoice.id)).id).toBe(invoice.id); }); }); -// ── createTestOffer ────────────────────────────────────────────────────────── - -describe('createTestOffer', () => { - it('returns a valid FinancingOffer with sensible defaults', () => { +describe('fixture builders — createTestOffer', () => { + it('produces valid deterministic defaults', () => { const offer = createTestOffer(); - - expect(typeof offer.id).toBe('string'); - expect(offer.id.length).toBeGreaterThan(0); - expect(typeof offer.invoice_id).toBe('string'); - expect(typeof offer.lender).toBe('string'); - expect(typeof offer.amount).toBe('bigint'); - expect(offer.amount > 0n).toBe(true); - expect(['XLM', 'USDC']).toContain(offer.currency); - expect(typeof offer.interest_rate).toBe('number'); - expect(offer.interest_rate).toBeGreaterThan(0); - expect(typeof offer.duration).toBe('number'); - expect(offer.duration).toBeGreaterThan(0); - expect(typeof offer.amount_repaid).toBe('bigint'); + expect(offer.id).toBe('off_test_001'); + expect(offer.invoice_id).toBe('inv_test_001'); + expect(offer.lender).toBe(MOCK_LENDER_B); + expect(offer.amount).toBe(100n * STROOP_BASE); + expect(offer.interest_rate).toBe(500); + expect(offer.duration).toBe(30 * 86_400); expect(offer.amount_repaid).toBe(0n); expect(offer.status).toBe('Pending'); expect(offer.funded_at).toBe(0); }); - it('satisfies the FinancingOffer TypeScript interface', () => { - const offer: FinancingOffer = createTestOffer(); - expect(offer).toBeDefined(); - }); - - it('applies partial overrides while keeping other defaults', () => { - const offer = createTestOffer({ id: 'off_custom', interest_rate: 750, status: 'Financed' }); - + it('applies field overrides and the invoiceId alias', () => { + const offer = createTestOffer({ id: 'off_custom', invoiceId: 'inv_x', interest_rate: 800 }); expect(offer.id).toBe('off_custom'); - expect(offer.interest_rate).toBe(750); - expect(offer.status).toBe('Financed'); - // defaults preserved - expect(typeof offer.amount).toBe('bigint'); - expect(offer.amount > 0n).toBe(true); + expect(offer.invoice_id).toBe('inv_x'); + expect(offer.interest_rate).toBe(800); + // invoiceId wins over invoice_id when both are supplied. + const both = createTestOffer({ invoice_id: 'inv_y', invoiceId: 'inv_x' }); + expect(both.invoice_id).toBe('inv_x'); }); - it('allows overriding every field', () => { - const custom: FinancingOffer = createTestOffer({ - id: 'off_full_override', - invoice_id: 'inv_full_override', - lender: MOCK_WALLET_ADDRESS, - amount: 500_000_000n, - currency: 'USDC', - interest_rate: 1000, - duration: 60 * 86_400, - amount_repaid: 250_000_000n, - status: 'Repaid', - funded_at: 1_700_000_000, - }); - - expect(custom.id).toBe('off_full_override'); - expect(custom.invoice_id).toBe('inv_full_override'); - expect(custom.lender).toBe(MOCK_WALLET_ADDRESS); - expect(custom.amount).toBe(500_000_000n); - expect(custom.currency).toBe('USDC'); - expect(custom.interest_rate).toBe(1000); - expect(custom.duration).toBe(60 * 86_400); - expect(custom.amount_repaid).toBe(250_000_000n); - expect(custom.status).toBe('Repaid'); - expect(custom.funded_at).toBe(1_700_000_000); - }); -}); - -// ── MockServerBuilder — failure scenarios ──────────────────────────────────── - -describe('MockServerBuilder — no failures (baseline)', () => { - it('build() with no failure config returns a working mock client', async () => { - const client = createMockServerBuilder().build(); - - const invoice = await client.getInvoice(EXISTING_INVOICE_ID); - expect(invoice.id).toBe(EXISTING_INVOICE_ID); + it('is accepted by the mock client (createOffer) as-is', async () => { + const client = createMockClient(); + const invoice = createTestInvoice(); + await client.registerInvoice( + { id: invoice.id, amount: invoice.amount, currency: invoice.currency, dueDate: invoice.due_date }, + invoice.originator, + ); + const offer = createTestOffer({ invoiceId: invoice.id }); + const created = await client.createOffer( + { + offerId: offer.id, + invoiceId: offer.invoice_id, + amount: offer.amount, + currency: offer.currency, + interestRate: offer.interest_rate, + duration: offer.duration, + }, + offer.lender, + ); + expect(created.status).toBe('Pending'); }); }); -describe('MockServerBuilder — withInsufficientBalance()', () => { - it('getTokenBalance rejects with Insufficient balance', async () => { - const client = createMockServerBuilder().withInsufficientBalance().build(); - await expect( - client.getTokenBalance('CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35B4AE7LZLLI4VXMCF7', MOCK_WALLET_ADDRESS), - ).rejects.toThrow('Insufficient balance'); - }); - - it('getTokenDecimals rejects with Insufficient balance', async () => { - const client = createMockServerBuilder().withInsufficientBalance().build(); - await expect( - client.getTokenDecimals('CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35B4AE7LZLLI4VXMCF7'), - ).rejects.toThrow('Insufficient balance'); - }); - - it('transferPositionToken rejects with Insufficient balance', async () => { - const client = createMockServerBuilder().withInsufficientBalance().build(); - await expect( - client.transferPositionToken( - 'CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35B4AE7LZLLI4VXMCF7', - MOCK_WALLET_ADDRESS, - MOCK_BUSINESS_A, - 1_000n, - ), - ).rejects.toThrow('Insufficient balance'); - }); - - it('repayInvoice rejects with Insufficient balance', async () => { - const client = createMockServerBuilder().withInsufficientBalance().build(); - await expect( - client.repayInvoice(EXISTING_FINANCED_INVOICE_ID, EXISTING_OFFER_ID, MOCK_BUSINESS_A, 1_000n), - ).rejects.toThrow('Insufficient balance'); - }); +describe('event emission tracking', () => { + it('records inv_reg on registerInvoice and inv_cxl on cancelInvoice', async () => { + const client = createMockClient(); + const invoice = createTestInvoice(); + await client.registerInvoice( + { id: invoice.id, amount: invoice.amount, currency: invoice.currency, dueDate: invoice.due_date }, + invoice.originator, + ); + expect(client.events).toHaveLength(1); + const reg = client.events[0]; + expect(reg.type).toBe('inv_reg'); + expect(reg.subjectId).toBe(invoice.id); + expect(reg.contractId).toBe(MOCK_REGISTRY_ID); + expect(reg.ledger).toBeGreaterThan(0); + expect(reg.txHash).toMatch(/^0+[0-9a-f]+$/); + if (reg.type === 'inv_reg') { + expect(reg.data.originator).toBe(invoice.originator); + expect(reg.data.amount).toBe(invoice.amount); + } + + await client.cancelInvoice(invoice.id, invoice.originator); + const cxl = client.events[1]; + expect(cxl.type).toBe('inv_cxl'); + if (cxl.type === 'inv_cxl') expect(cxl.data.originator).toBe(invoice.originator); + }); + + it('records off_new, off_acc, off_rej with financing contract ids', async () => { + const client = createMockClient(); + await client.createOffer( + { offerId: 'off_t1', invoiceId: 'inv_mock_p002', amount: 25_000n, currency: 'XLM', interestRate: 500, duration: 86_400 }, + MOCK_WALLET_ADDRESS, + ); + expect(client.events[0].type).toBe('off_new'); + expect(client.events[0].contractId).toBe(MOCK_FINANCING_ID); - it('read-only methods still work under insufficientBalance', async () => { - const client = createMockServerBuilder().withInsufficientBalance().build(); - // getInvoice / getOffer are not affected - const invoice = await client.getInvoice(EXISTING_INVOICE_ID); - expect(invoice.id).toBe(EXISTING_INVOICE_ID); - }); -}); + await client.acceptOffer('off_t1', MOCK_BUSINESS_B); + const acc = client.events[1]; + expect(acc.type).toBe('off_acc'); + if (acc.type === 'off_acc') { + expect(acc.data.invoiceId).toBe('inv_mock_p002'); + expect(acc.data.lender).toBe(MOCK_WALLET_ADDRESS); + expect(acc.data.amount).toBe(25_000n); + } -describe('MockServerBuilder — withAuthError()', () => { - it('registerInvoice rejects with Auth error: unauthorized', async () => { - const client = createMockServerBuilder().withAuthError().build(); - await expect( - client.registerInvoice( - { id: 'inv_auth_test', amount: 10_000_000n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ), - ).rejects.toThrow('Auth error: unauthorized'); + await client.rejectOffer('off_mock_006', MOCK_BUSINESS_B); + expect(client.events[2].type).toBe('off_rej'); + expect(client.events[2].contractId).toBe(MOCK_FINANCING_ID); }); - it('cancelInvoice rejects with Auth error: unauthorized', async () => { - const client = createMockServerBuilder().withAuthError().build(); - await expect(client.cancelInvoice(EXISTING_INVOICE_ID, MOCK_BUSINESS_A)).rejects.toThrow( - 'Auth error: unauthorized', - ); - }); - - it('createOffer rejects with Auth error: unauthorized', async () => { - const client = createMockServerBuilder().withAuthError().build(); - await expect( - client.createOffer( - { - offerId: 'off_auth_test', - invoiceId: EXISTING_INVOICE_ID, - amount: 10_000_000n, - currency: 'XLM', - interestRate: 500, - duration: 86_400, - }, - MOCK_WALLET_ADDRESS, - ), - ).rejects.toThrow('Auth error: unauthorized'); - }); + it('records inv_rep with fullyRepaid, inv_ovd, and off_def', async () => { + const client = createMockClient(); + // Partial repayment → fullyRepaid: false. + await client.repayInvoice('inv_mock_f001', 'off_mock_001', MOCK_BUSINESS_A, 1_000n); + const partial = client.events[0]; + expect(partial.type).toBe('inv_rep'); + expect(partial.contractId).toBe(MOCK_REPAYMENT_ID); + if (partial.type === 'inv_rep') { + expect(partial.data.offerId).toBe('off_mock_001'); + expect(partial.data.amount).toBe(1_000n); + expect(partial.data.fullyRepaid).toBe(false); + } + + // Full repayment → fullyRepaid: true. + const offer = await client.getOffer('off_mock_001'); + const totalDue = offer.amount + (offer.amount * BigInt(offer.interest_rate)) / 10_000n; + const outstanding = totalDue - offer.amount_repaid; + await client.repayInvoice('inv_mock_f001', 'off_mock_001', MOCK_BUSINESS_A, outstanding); + const full = client.events[1]; + if (full.type === 'inv_rep') expect(full.data.fullyRepaid).toBe(true); - it('acceptOffer rejects with Auth error: unauthorized', async () => { - const client = createMockServerBuilder().withAuthError().build(); - await expect(client.acceptOffer('off_mock_003', MOCK_BUSINESS_A)).rejects.toThrow( - 'Auth error: unauthorized', - ); - }); + await client.markOverdue('inv_mock_p001', MOCK_BUSINESS_A); + const ovd = client.events[2]; + expect(ovd.type).toBe('inv_ovd'); + if (ovd.type === 'inv_ovd') expect(ovd.data.dueDate).toBe(BigInt((await client.getInvoice('inv_mock_p001')).due_date)); - it('rejectOffer rejects with Auth error: unauthorized', async () => { - const client = createMockServerBuilder().withAuthError().build(); - await expect(client.rejectOffer('off_mock_003', MOCK_BUSINESS_A)).rejects.toThrow( - 'Auth error: unauthorized', - ); + await client.reclaimInvoice('inv_mock_o001', 'off_mock_004', MOCK_WALLET_ADDRESS); + const def = client.events[3]; + expect(def.type).toBe('off_def'); + if (def.type === 'off_def') { + expect(def.data.invoiceId).toBe('inv_mock_o001'); + expect(def.data.lender).toBe(MOCK_WALLET_ADDRESS); + } }); - it('repayInvoice rejects with Auth error: unauthorized', async () => { - const client = createMockServerBuilder().withAuthError().build(); - await expect( - client.repayInvoice(EXISTING_FINANCED_INVOICE_ID, EXISTING_OFFER_ID, MOCK_BUSINESS_A, 1_000n), - ).rejects.toThrow('Auth error: unauthorized'); - }); + it('does not record events for read-only calls, and clearEvents wipes the log', async () => { + const client = createMockClient(); + await client.getInvoice('inv_mock_p001'); + await client.getOffer('off_mock_001'); + await client.getTokenBalance(MOCK_POSITION_TOKEN_ID, MOCK_WALLET_ADDRESS); + expect(client.events).toHaveLength(0); - it('markOverdue rejects with Auth error: unauthorized', async () => { - const client = createMockServerBuilder().withAuthError().build(); - await expect(client.markOverdue('inv_mock_o001', MOCK_WALLET_ADDRESS)).rejects.toThrow( - 'Auth error: unauthorized', + await client.registerInvoice( + { id: 'inv_evt', amount: 1_000n, currency: 'XLM', dueDate: Math.floor(Date.now() / 1000) + 86_400 }, + MOCK_BUSINESS_A, ); + expect(client.events).toHaveLength(1); + client.clearEvents(); + expect(client.events).toHaveLength(0); }); - it('transferPositionToken rejects with Auth error: unauthorized', async () => { - const client = createMockServerBuilder().withAuthError().build(); - await expect( - client.transferPositionToken( - 'CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35B4AE7LZLLI4VXMCF7', - MOCK_WALLET_ADDRESS, - MOCK_BUSINESS_A, - 1_000n, - ), - ).rejects.toThrow('Auth error: unauthorized'); - }); - - it('read-only methods still work under authError', async () => { - const client = createMockServerBuilder().withAuthError().build(); - const offer = await client.getOffer(EXISTING_OFFER_ID); - expect(offer.id).toBe(EXISTING_OFFER_ID); + it('does not record an event when a call fails', async () => { + const client = createMockClient(); + await expect(client.acceptOffer('off_mock_006', MOCK_WALLET_ADDRESS)).rejects.toMatchObject({ + errorType: ContractErrorType.UNAUTHORIZED, + }); + expect(client.events).toHaveLength(0); }); }); -describe('MockServerBuilder — withNetworkError()', () => { - it('getInvoice rejects with Network error', async () => { - const client = createMockServerBuilder().withNetworkError().build(); - await expect(client.getInvoice(EXISTING_INVOICE_ID)).rejects.toThrow('Network error'); +describe('typed domain failures (#226)', () => { + it('throws ContractError NOT_FOUND for missing resources', async () => { + const client = createMockClient(); + await expect(client.getInvoice('inv_nope')).rejects.toMatchObject({ + errorType: ContractErrorType.NOT_FOUND, + }); + await expect(client.getOffer('off_nope')).rejects.toMatchObject({ + errorType: ContractErrorType.NOT_FOUND, + }); }); - it('getOffer rejects with Network error', async () => { - const client = createMockServerBuilder().withNetworkError().build(); - await expect(client.getOffer(EXISTING_OFFER_ID)).rejects.toThrow('Network error'); + it('throws ContractError UNAUTHORIZED for auth failures', async () => { + const client = createMockClient(); + await expect(client.cancelInvoice('inv_mock_p001', MOCK_WALLET_ADDRESS)).rejects.toMatchObject({ + errorType: ContractErrorType.UNAUTHORIZED, + }); + await expect(client.acceptOffer('off_mock_006', MOCK_WALLET_ADDRESS)).rejects.toMatchObject({ + errorType: ContractErrorType.UNAUTHORIZED, + }); + await expect(client.reclaimInvoice('inv_mock_o001', 'off_mock_004', MOCK_BUSINESS_A)).rejects.toMatchObject({ + errorType: ContractErrorType.UNAUTHORIZED, + }); }); - it('registerInvoice rejects with Network error', async () => { - const client = createMockServerBuilder().withNetworkError().build(); + it('throws ContractError ALREADY_EXISTS for duplicate registrations', async () => { + const client = createMockClient(); await expect( client.registerInvoice( - { id: 'inv_net_test', amount: 10_000_000n, currency: 'XLM', dueDate: FUTURE_TS }, + { id: 'inv_mock_p001', amount: 1_000n, currency: 'XLM', dueDate: Math.floor(Date.now() / 1000) + 86_400 }, MOCK_BUSINESS_A, ), - ).rejects.toThrow('Network error'); - }); - - it('createOffer rejects with Network error', async () => { - const client = createMockServerBuilder().withNetworkError().build(); + ).rejects.toMatchObject({ errorType: ContractErrorType.ALREADY_EXISTS }); await expect( client.createOffer( - { - offerId: 'off_net_test', - invoiceId: EXISTING_INVOICE_ID, - amount: 10_000_000n, - currency: 'XLM', - interestRate: 500, - duration: 86_400, - }, + { offerId: 'off_mock_001', invoiceId: 'inv_mock_p002', amount: 1_000n, currency: 'XLM', interestRate: 500, duration: 86_400 }, MOCK_WALLET_ADDRESS, ), - ).rejects.toThrow('Network error'); - }); - - it('acceptOffer rejects with Network error', async () => { - const client = createMockServerBuilder().withNetworkError().build(); - await expect(client.acceptOffer('off_mock_003', MOCK_BUSINESS_A)).rejects.toThrow('Network error'); - }); - - it('repayInvoice rejects with Network error', async () => { - const client = createMockServerBuilder().withNetworkError().build(); - await expect( - client.repayInvoice(EXISTING_FINANCED_INVOICE_ID, EXISTING_OFFER_ID, MOCK_BUSINESS_A, 1_000n), - ).rejects.toThrow('Network error'); + ).rejects.toMatchObject({ errorType: ContractErrorType.ALREADY_EXISTS }); }); - it('getTokenBalance rejects with Network error', async () => { - const client = createMockServerBuilder().withNetworkError().build(); + it('throws ContractError INSUFFICIENT_BALANCE on overdraft transfers', async () => { + const client = createMockClient(); + client.setBalance(MOCK_BUSINESS_A, 5n); await expect( - client.getTokenBalance('CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35B4AE7LZLLI4VXMCF7', MOCK_WALLET_ADDRESS), - ).rejects.toThrow('Network error'); + client.transferPositionToken(MOCK_POSITION_TOKEN_ID, MOCK_BUSINESS_A, MOCK_BUSINESS_B, 10n), + ).rejects.toMatchObject({ errorType: ContractErrorType.INSUFFICIENT_BALANCE }); }); }); -describe('MockServerBuilder — withRejectedOffer()', () => { - it('acceptOffer rejects with Offer rejected', async () => { - const client = createMockServerBuilder().withRejectedOffer().build(); - await expect(client.acceptOffer('off_mock_003', MOCK_BUSINESS_A)).rejects.toThrow('Offer rejected'); - }); - - it('other methods still work under rejectedOffer', async () => { - const client = createMockServerBuilder().withRejectedOffer().build(); - const invoice = await client.getInvoice(EXISTING_INVOICE_ID); - expect(invoice.id).toBe(EXISTING_INVOICE_ID); - }); - - it('createOffer still works when only rejectedOffer is set', async () => { - const client = createMockServerBuilder().withRejectedOffer().build(); - const offer = await client.createOffer( - { - offerId: 'off_rej_test_001', - invoiceId: EXISTING_INVOICE_ID, - amount: 10_000_000n, - currency: 'XLM', - interestRate: 500, - duration: 86_400, - }, - MOCK_WALLET_ADDRESS, - ); - expect(offer.status).toBe('Pending'); - // But accepting it fails - await expect(client.acceptOffer('off_rej_test_001', MOCK_BUSINESS_A)).rejects.toThrow('Offer rejected'); - }); -}); - -describe('MockServerBuilder — withOverdueInvoice()', () => { - it('built-in fixture inv_mock_o001 is already Overdue without configuration', async () => { +describe('failure injection', () => { + it('failNext rejects exactly once, then the call succeeds', async () => { const client = createMockClient(); - const invoice = await client.getInvoice('inv_mock_o001'); - expect(invoice.status).toBe('Overdue'); - }); - - it('withOverdueInvoice with an existing fixture ID keeps the invoice Overdue', async () => { - const client = createMockServerBuilder().withOverdueInvoice('inv_mock_o001').build(); - // Allow the async seeding to complete - await new Promise(r => setTimeout(r, 50)); - const invoice = await client.getInvoice('inv_mock_o001'); - expect(invoice.status).toBe('Overdue'); - }); -}); - -describe('MockServerBuilder — fluent chaining', () => { - it('returns the same builder instance for chaining', () => { - const builder = createMockServerBuilder(); - expect(builder.withInsufficientBalance()).toBe(builder); - expect(builder.withAuthError()).toBe(builder); - expect(builder.withNetworkError()).toBe(builder); - expect(builder.withRejectedOffer()).toBe(builder); - expect(builder.withOverdueInvoice('inv_mock_o001')).toBe(builder); - }); - - it('networkError takes precedence over authError (all calls fail with Network error)', async () => { - const client = createMockServerBuilder().withAuthError().withNetworkError().build(); - await expect(client.getInvoice(EXISTING_INVOICE_ID)).rejects.toThrow('Network error'); - await expect(client.registerInvoice( - { id: 'x', amount: 1n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - )).rejects.toThrow('Network error'); - }); -}); - -// ── EventTracker ───────────────────────────────────────────────────────────── - -describe('EventTracker — construction', () => { - it('createEventTracker wraps a client and exposes .client', () => { - const base = createMockClient(); - const tracker = createEventTracker(base); - expect(tracker.client).toBeDefined(); - expect(typeof tracker.client.registerInvoice).toBe('function'); - }); - - it('EventTracker.wrap() is equivalent to new EventTracker()', () => { - const base = createMockClient(); - const tracker = EventTracker.wrap(base); - expect(tracker).toBeInstanceOf(EventTracker); - }); - - it('starts with an empty event list', () => { - const tracker = createEventTracker(createMockClient()); - expect(tracker.getEvents()).toHaveLength(0); - }); -}); - -describe('EventTracker — registerInvoice emits inv_reg', () => { - it('records an inv_reg event with correct payload', async () => { - const tracker = createEventTracker(createMockClient()); - - await tracker.client.registerInvoice( - { id: 'inv_track_001', amount: 5_000_000n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - - expect(tracker.getEventCount('inv_reg')).toBe(1); - const events = tracker.getEvents(); - expect(events[0].type).toBe('inv_reg'); - expect(events[0].payload.originator).toBe(MOCK_BUSINESS_A); - expect(events[0].payload.amount).toBe(5_000_000n); - }); - - it('accumulates multiple inv_reg events', async () => { - const tracker = createEventTracker(createMockClient()); - - await tracker.client.registerInvoice( - { id: 'inv_track_a', amount: 1n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - await tracker.client.registerInvoice( - { id: 'inv_track_b', amount: 2n, currency: 'USDC', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - - expect(tracker.getEventCount('inv_reg')).toBe(2); - expect(tracker.getEvents()).toHaveLength(2); - }); -}); - -describe('EventTracker — createOffer emits off_new', () => { - it('records an off_new event', async () => { - const tracker = createEventTracker(createMockClient()); - - await tracker.client.createOffer( - { - offerId: 'off_track_001', - invoiceId: EXISTING_INVOICE_ID, - amount: 10_000_000n, - currency: 'XLM', - interestRate: 500, - duration: 86_400, - }, - MOCK_WALLET_ADDRESS, - ); - - expect(tracker.getEventCount('off_new')).toBe(1); - const event = tracker.getEvents()[0]; - expect(event.type).toBe('off_new'); - expect(event.payload.lender).toBe(MOCK_WALLET_ADDRESS); - expect(event.payload.invoice_id).toBe(EXISTING_INVOICE_ID); + const boom = new ContractError(5, ContractErrorType.INSUFFICIENT_BALANCE, 'lender is broke'); + client.failNext('acceptOffer', boom); + await expect(client.acceptOffer('off_mock_006', MOCK_BUSINESS_B)).rejects.toMatchObject({ + errorType: ContractErrorType.INSUFFICIENT_BALANCE, + }); + await expect(client.acceptOffer('off_mock_006', MOCK_BUSINESS_B)).resolves.toBeDefined(); }); -}); - -describe('EventTracker — acceptOffer emits off_acc', () => { - it('records an off_acc event', async () => { - const tracker = createEventTracker(createMockClient()); - // off_mock_003 is a Pending offer on inv_mock_p001 (originator = MOCK_BUSINESS_A). - await tracker.client.acceptOffer('off_mock_003', MOCK_BUSINESS_A); - - expect(tracker.getEventCount('off_acc')).toBe(1); - const event = tracker.getEvents()[0]; - expect(event.type).toBe('off_acc'); - expect(event.payload.lender).toBe(MOCK_WALLET_ADDRESS); + it('failNext default error is a ContractError UNKNOWN with a readable message', async () => { + const client = createMockClient(); + client.failNext('getInvoice', undefined, 'testnet is down'); + await expect(client.getInvoice('inv_mock_p001')).rejects.toMatchObject({ + errorType: ContractErrorType.UNKNOWN, + message: 'testnet is down', + }); }); -}); -describe('EventTracker — repayInvoice emits inv_rep', () => { - it('records an inv_rep event with correct payload', async () => { - const tracker = createEventTracker(createMockClient()); - - await tracker.client.repayInvoice( - EXISTING_FINANCED_INVOICE_ID, - EXISTING_OFFER_ID, - MOCK_BUSINESS_A, - 1_000n, - ); - - expect(tracker.getEventCount('inv_rep')).toBe(1); - const event = tracker.getEvents()[0]; - expect(event.type).toBe('inv_rep'); - expect(event.payload.offer_id).toBe(EXISTING_OFFER_ID); - expect(event.payload.amount).toBe(1_000n); - expect(event.payload.fully_repaid).toBe(false); + it('options.failures with on: "*" matches every method and respects times', async () => { + const client = createMockClient({ failures: [{ on: '*', message: 'chain down', times: 2 }] }); + await expect(client.getInvoice('inv_mock_p001')).rejects.toThrow(/chain down/); + await expect(client.getOffer('off_mock_001')).rejects.toThrow(/chain down/); + await expect(client.getInvoice('inv_mock_p001')).resolves.toBeDefined(); }); - it('records fully_repaid = true when invoice is fully repaid', async () => { - const base = createMockClient(); - const offer = await base.getOffer(EXISTING_OFFER_ID); - const totalDue = offer.amount + (offer.amount * BigInt(offer.interest_rate)) / 10_000n; - const outstanding = totalDue - offer.amount_repaid; - - const tracker = createEventTracker(base); - await tracker.client.repayInvoice( - EXISTING_FINANCED_INVOICE_ID, - EXISTING_OFFER_ID, - MOCK_BUSINESS_A, - outstanding, - ); - - const event = tracker.getEvents()[0]; - expect(event.payload.fully_repaid).toBe(true); - }); -}); - -describe('EventTracker — read-only methods do not emit events', () => { - it('getInvoice does not add events', async () => { - const tracker = createEventTracker(createMockClient()); - await tracker.client.getInvoice(EXISTING_INVOICE_ID); - expect(tracker.getEvents()).toHaveLength(0); + it('options.failures can target a single method', async () => { + const client = createMockClient({ failures: [{ on: 'transferPositionToken', error: new Error('simulated slop') }] }); + await expect( + client.transferPositionToken(MOCK_POSITION_TOKEN_ID, MOCK_WALLET_ADDRESS, MOCK_BUSINESS_A, 1n), + ).rejects.toThrow(/simulated slop/); + // Reads still work. + await expect(client.getInvoice('inv_mock_p001')).resolves.toBeDefined(); }); - it('getOffer does not add events', async () => { - const tracker = createEventTracker(createMockClient()); - await tracker.client.getOffer(EXISTING_OFFER_ID); - expect(tracker.getEvents()).toHaveLength(0); + it('addFailure installs a sticky rule until reset', async () => { + const client = createMockClient(); + client.addFailure({ on: 'rejectOffer', message: 'reject is disabled' }); + await expect(client.rejectOffer('off_mock_006', MOCK_BUSINESS_B)).rejects.toThrow(/reject is disabled/); + await client.reset(); + await expect(client.rejectOffer('off_mock_006', MOCK_BUSINESS_B)).resolves.toBeDefined(); }); - it('getTokenBalance does not add events', async () => { - const tracker = createEventTracker(createMockClient()); - await tracker.client.getTokenBalance( - 'CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35B4AE7LZLLI4VXMCF7', - MOCK_WALLET_ADDRESS, - ); - expect(tracker.getEvents()).toHaveLength(0); + it('validation still runs before injected failures (SdkValidationError wins)', async () => { + const client = createMockClient(); + client.failNext('registerInvoice'); + await expect( + client.registerInvoice( + { id: '', amount: 1_000n, currency: 'XLM', dueDate: Math.floor(Date.now() / 1000) + 86_400 }, + MOCK_BUSINESS_A, + ), + ).rejects.toBeInstanceOf(SdkValidationError); }); }); -describe('EventTracker — reset()', () => { - it('clears all tracked events', async () => { - const tracker = createEventTracker(createMockClient()); - - await tracker.client.registerInvoice( - { id: 'inv_reset_001', amount: 1n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - expect(tracker.getEventCount('inv_reg')).toBe(1); - - tracker.reset(); - - expect(tracker.getEvents()).toHaveLength(0); - expect(tracker.getEventCount('inv_reg')).toBe(0); - }); - - it('resumes tracking after reset', async () => { - const tracker = createEventTracker(createMockClient()); - - await tracker.client.registerInvoice( - { id: 'inv_reset_002', amount: 1n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - tracker.reset(); +describe('state control', () => { + it('reset restores the seeded state and clears events + one-shot failures', async () => { + const client = createMockClient(); + await client.cancelInvoice('inv_mock_p001', MOCK_BUSINESS_A); + client.failNext('getOffer'); + await expect(client.getOffer('off_mock_001')).rejects.toThrow(/Simulated failure/); - await tracker.client.registerInvoice( - { id: 'inv_reset_003', amount: 1n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); + await client.reset(); - expect(tracker.getEventCount('inv_reg')).toBe(1); - const events = tracker.getEvents(); - expect(events[0].payload.originator).toBe(MOCK_BUSINESS_A); + expect((await client.getInvoice('inv_mock_p001')).status).toBe('Pending'); + expect(client.events).toHaveLength(0); + await expect(client.getOffer('off_mock_001')).resolves.toBeDefined(); }); -}); -describe('EventTracker — getEventCount()', () => { - it('returns 0 for an event type that has not been emitted', () => { - const tracker = createEventTracker(createMockClient()); - expect(tracker.getEventCount('off_acc')).toBe(0); + it('reset restores failures configured via options', async () => { + const client = createMockClient({ failures: [{ on: 'getInvoice', message: 'boom', times: 1 }] }); + await expect(client.getInvoice('inv_mock_p001')).rejects.toThrow(/boom/); + await client.reset(); + await expect(client.getInvoice('inv_mock_p001')).rejects.toThrow(/boom/); }); - it('returns the correct count for mixed event types', async () => { - const tracker = createEventTracker(createMockClient()); - - // Register two invoices - await tracker.client.registerInvoice( - { id: 'inv_count_a', amount: 1n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - await tracker.client.registerInvoice( - { id: 'inv_count_b', amount: 1n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - // Create an offer - await tracker.client.createOffer( - { - offerId: 'off_count_001', - invoiceId: EXISTING_INVOICE_ID, - amount: 10_000_000n, - currency: 'XLM', - interestRate: 500, - duration: 86_400, - }, - MOCK_WALLET_ADDRESS, - ); - - expect(tracker.getEventCount('inv_reg')).toBe(2); - expect(tracker.getEventCount('off_new')).toBe(1); - expect(tracker.getEventCount('off_acc')).toBe(0); - expect(tracker.getEvents()).toHaveLength(3); + it('setBalance/getBalance drive balance-based scenarios', () => { + const client = createMockClient(); + expect(client.getBalance(MOCK_BUSINESS_A)).toBe(0n); + client.setBalance(MOCK_BUSINESS_A, 42n); + expect(client.getBalance(MOCK_BUSINESS_A)).toBe(42n); + // Unrelated to the position token's demo wallet balance. + expect(client.getBalance(MOCK_WALLET_ADDRESS)).toBeGreaterThan(0n); }); -}); - -describe('EventTracker — getEvents() returns a copy', () => { - it('mutating the returned array does not affect internal state', async () => { - const tracker = createEventTracker(createMockClient()); - await tracker.client.registerInvoice( - { id: 'inv_copy_test', amount: 1n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - - const snapshot = tracker.getEvents(); - snapshot.pop(); // mutate the copy - - // Internal state unchanged - expect(tracker.getEventCount('inv_reg')).toBe(1); + it('seededInvoices/seededOffers return fresh copies of the fixtures', async () => { + const client = createMockClient(); + const seeded = client.seededInvoices(); + expect(seeded.length).toBeGreaterThan(0); + seeded[0].status = 'Cancelled'; + // Mutating the returned copy must not affect the client's state. + expect((await client.getInvoice(seeded[0].id)).status).not.toBe('Cancelled'); + expect(client.seededOffers().length).toBeGreaterThan(0); }); }); -// ── Combining MockServerBuilder with EventTracker ──────────────────────────── - -describe('MockServerBuilder + EventTracker — combined usage', () => { - it('EventTracker wrapping a builder client captures events on successful calls', async () => { - // No failure modes → all calls succeed; events should be tracked. - const baseClient = createMockServerBuilder().build(); - const tracker = EventTracker.wrap(baseClient); - - await tracker.client.registerInvoice( - { id: 'inv_combo_001', amount: 10_000_000n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - - expect(tracker.getEventCount('inv_reg')).toBe(1); - }); - - it('EventTracker wrapping an authError builder records no events (call throws)', async () => { - const baseClient = createMockServerBuilder().withAuthError().build(); - const tracker = EventTracker.wrap(baseClient); - - await expect( - tracker.client.registerInvoice( - { id: 'inv_combo_fail', amount: 10_000_000n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ), - ).rejects.toThrow('Auth error: unauthorized'); - - // Failed calls should not produce events. - expect(tracker.getEventCount('inv_reg')).toBe(0); - expect(tracker.getEvents()).toHaveLength(0); - }); - - it('EventTracker wrapping a networkError builder records no events', async () => { - const baseClient = createMockServerBuilder().withNetworkError().build(); - const tracker = EventTracker.wrap(baseClient); - - await expect(tracker.client.getInvoice(EXISTING_INVOICE_ID)).rejects.toThrow('Network error'); - expect(tracker.getEvents()).toHaveLength(0); - }); - - it('EventTracker wrapping a rejectedOffer builder records no off_acc event', async () => { - const baseClient = createMockServerBuilder().withRejectedOffer().build(); - const tracker = EventTracker.wrap(baseClient); - - await expect(tracker.client.acceptOffer('off_mock_003', MOCK_BUSINESS_A)).rejects.toThrow( - 'Offer rejected', - ); - expect(tracker.getEventCount('off_acc')).toBe(0); - }); - - it('full lifecycle: register → createOffer → acceptOffer → repay tracked end-to-end', async () => { - const tracker = createEventTracker(createMockClient()); - - // Register a fresh invoice - await tracker.client.registerInvoice( - { id: 'inv_e2e_001', amount: 50_000_000n, currency: 'XLM', dueDate: FUTURE_TS }, - MOCK_BUSINESS_A, - ); - - // Create an offer - await tracker.client.createOffer( - { - offerId: 'off_e2e_001', - invoiceId: 'inv_e2e_001', - amount: 50_000_000n, - currency: 'XLM', - interestRate: 500, - duration: 30 * 86_400, - }, - MOCK_WALLET_ADDRESS, - ); - - // Accept the offer (originator = MOCK_BUSINESS_A) - await tracker.client.acceptOffer('off_e2e_001', MOCK_BUSINESS_A); - - // Partial repayment - await tracker.client.repayInvoice('inv_e2e_001', 'off_e2e_001', MOCK_BUSINESS_A, 1_000_000n); - - // Assert total events - expect(tracker.getEvents()).toHaveLength(4); - expect(tracker.getEventCount('inv_reg')).toBe(1); - expect(tracker.getEventCount('off_new')).toBe(1); - expect(tracker.getEventCount('off_acc')).toBe(1); - expect(tracker.getEventCount('inv_rep')).toBe(1); - - // Spot-check payloads - const events = tracker.getEvents(); - expect(events[0].type).toBe('inv_reg'); - expect(events[1].type).toBe('off_new'); - expect(events[2].type).toBe('off_acc'); - expect(events[3].type).toBe('inv_rep'); - expect(events[3].payload.fully_repaid).toBe(false); +describe('API surface', () => { + it('exposes the testing framework from the package root', () => { + const client = createMockClient(); + expect(typeof createTestInvoice).toBe('function'); + expect(typeof createTestOffer).toBe('function'); + expect(toStroops(1)).toBe(STROOP_BASE); + expect(MOCK_REGISTRY_ID).toMatch(/^C[A-Z2-7]{55}$/); + expect(MOCK_FINANCING_ID).toMatch(/^C[A-Z2-7]{55}$/); + expect(MOCK_REPAYMENT_ID).toMatch(/^C[A-Z2-7]{55}$/); + expect(typeof client.reset).toBe('function'); + expect(typeof client.failNext).toBe('function'); + expect(typeof client.clearEvents).toBe('function'); + expect(Array.isArray(client.events)).toBe(true); + expect(client.cache).toBeDefined(); }); });