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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ const client = new ConduitClient(config: ConduitConfig);
| `rpcUrl` | `string` | No | Override default Soroban RPC URL |
| `factoryAddress` | `string` | No | Override deployed factory contract ID |
| `governorAddress` | `string` | No | Override deployed governor contract ID |
| `fee` | `string` | No | Explicit inclusion fee in stroops (overrides feeMultiplier) |
| `feeMultiplier` | `number` | No | Multiplier applied to BASE_FEE when fee is not set |

### Fee Precedence

When both `fee` and `feeMultiplier` are present, `fee` wins. The precedence is:

1. **`fee`** — explicit inclusion fee in stroops (e.g. `"5000"`)
2. **`feeMultiplier`** — multiplied by `BASE_FEE` when `fee` is absent
3. **`BASE_FEE`** — hardcoded fallback when neither is set

Use `resolveFee(config)` to compute the effective fee for a given `ConduitConfig`.

### WalletConnect v2 Integration (Mobile & Browser Wallets)

Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type {
BatchSubmitOptions,
} from './batch-tx.js';
export { GraphQLIndexer, DEFAULT_INDEXER_TIMEOUT_MS } from './indexer.js';
export { MockGraphQLIndexer, createMockIndexer } from './mock-indexer.js';
export type { MockQueryMap, MockSubscriptionMap, MockIndexerOptions } from './mock-indexer.js';
export type {
GraphQLQueryOptions,
GraphQLSubscriptionOptions,
Expand Down Expand Up @@ -69,7 +71,7 @@ export {
} from './utils.js';

// RPC server lifecycle
export { getServer, clearServerCache } from './soroban.js';
export { getServer, clearServerCache, resolveFee } from './soroban.js';
export { getTokenDecimals, clearTokenDecimalsCache } from './soroban.js';

export {
Expand Down
117 changes: 117 additions & 0 deletions src/mock-indexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* In-memory mock transport for {@link GraphQLIndexer}.
*
* Lets consumers unit-test indexer-backed code without stubbing the global
* `fetch` or running a real GraphQL server. Query results and subscription
* events are injected up-front and returned deterministically.
*
* @example
* ```ts
* const indexer = createMockIndexer({
* 'query GetStreams { streams { id } }': { streams: [{ id: '1' }] },
* });
*
* const result = await indexer.query({ query: 'query GetStreams { streams { id } }' });
* // result === { streams: [{ id: '1' }] }
* ```
*/

import type { GraphQLQueryOptions, GraphQLSubscriptionOptions, IndexerSubscription } from './indexer.js';

/** Preset response map: query string → resolved data. */
export type MockQueryMap = Record<string, unknown>;

/** Preset subscription event map: query string → array of events to emit. */
export type MockSubscriptionMap = Record<string, unknown[]>;

export interface MockIndexerOptions {
/** Pre-baked responses for `query()` calls. */
queries?: MockQueryMap;
/** Pre-baked event arrays for `subscribe()` calls. */
subscriptions?: MockSubscriptionMap;
/** When true, unknown queries throw instead of returning undefined. Default false. */
strict?: boolean;
}

/**
* A drop-in replacement for {@link GraphQLIndexer} that serves injected
* responses from memory. Useful in unit tests and offline demos.
*/
export class MockGraphQLIndexer {
private readonly queries: MockQueryMap;
private readonly subscriptions: MockSubscriptionMap;
private readonly strict: boolean;
private _destroyed = false;

constructor(options: MockIndexerOptions = {}) {
this.queries = options.queries ?? {};
this.subscriptions = options.subscriptions ?? {};
this.strict = options.strict ?? false;
}

async query(options: GraphQLQueryOptions): Promise<unknown> {
if (this._destroyed) {
throw new Error('MockGraphQLIndexer has been destroyed');
}
const q = options.query.trim();
if (q in this.queries) {
return this.queries[q];
}
if (this.strict) {
throw new Error(`MockGraphQLIndexer: no preset response for query "${q}"`);
}
return undefined;
}

subscribe(options: GraphQLSubscriptionOptions): IndexerSubscription {
if (this._destroyed) {
throw new Error('MockGraphQLIndexer has been destroyed');
}
const q = options.query.trim();
const events = this.subscriptions[q] ?? [];

// Emit events on next tick so the caller can attach listeners first.
const timers = events.map((evt, i) =>
setTimeout(() => {
if (!unsubscribed) {
options.onData(evt);
}
}, i * 10),
);

let unsubscribed = false;
return {
unsubscribe: () => {
if (unsubscribed) return;
unsubscribed = true;
for (const timer of timers) {
clearTimeout(timer);
}
},
};
}

getSubscriptionCount(): number {
return 0;
}

cleanup(): void {
this._destroyed = true;
}
}

/**
* Convenience factory for {@link MockGraphQLIndexer}.
*
* @example
* ```ts
* const indexer = createMockIndexer({
* queries: {
* 'query { streams }': { streams: [] },
* },
* });
* ```
*/
export function createMockIndexer(options?: MockIndexerOptions): MockGraphQLIndexer {
return new MockGraphQLIndexer(options);
}
75 changes: 75 additions & 0 deletions src/tests/mock-indexer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { MockGraphQLIndexer, createMockIndexer } from '../mock-indexer.js';

describe('MockGraphQLIndexer', () => {
it('returns preset query responses', async () => {
const indexer = new MockGraphQLIndexer({
queries: {
'query GetStreams { streams { id } }': { streams: [{ id: '1' }] },
},
});

const result = await indexer.query({ query: 'query GetStreams { streams { id } }' });
expect(result).toEqual({ streams: [{ id: '1' }] });
});

it('returns undefined for unknown queries in non-strict mode', async () => {
const indexer = new MockGraphQLIndexer();
const result = await indexer.query({ query: 'query Unknown { unknown }' });
expect(result).toBeUndefined();
});

it('throws for unknown queries in strict mode', async () => {
const indexer = new MockGraphQLIndexer({ strict: true });
await expect(indexer.query({ query: 'query Unknown { unknown }' })).rejects.toThrow(
'MockGraphQLIndexer: no preset response',
);
});

it('emits subscription events in order', async () => {
const indexer = new MockGraphQLIndexer({
subscriptions: {
'subscription OnStream { onStream { id } }': [{ id: '1' }, { id: '2' }],
},
});

const events: unknown[] = [];
const sub = indexer.subscribe({
query: 'subscription OnStream { onStream { id } }',
onData: (data) => events.push(data),
});

await new Promise((r) => setTimeout(r, 50));
expect(events).toEqual([{ id: '1' }, { id: '2' }]);
sub.unsubscribe();
});

it('stops emitting after unsubscribe', async () => {
const indexer = new MockGraphQLIndexer({
subscriptions: {
'sub': [{ id: '1' }, { id: '2' }],
},
});

const events: unknown[] = [];
const sub = indexer.subscribe({
query: 'sub',
onData: (data) => events.push(data),
});

sub.unsubscribe();
await new Promise((r) => setTimeout(r, 50));
expect(events).toEqual([]);
});

it('createMockIndexer is a convenience factory', () => {
const indexer = createMockIndexer({ queries: { 'q': 'data' } });
expect(indexer).toBeInstanceOf(MockGraphQLIndexer);
});

it('throws when destroyed', async () => {
const indexer = new MockGraphQLIndexer();
indexer.cleanup();
await expect(indexer.query({ query: 'q' })).rejects.toThrow('destroyed');
});
});
Loading