diff --git a/README.md b/README.md index 834cc7b..82cb60f 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,30 @@ Requires Node.js 20+. After merge to `main`, the generated reference can be published to GitHub Pages once Pages is set to deploy from GitHub Actions. +## Testing with the mock server + +Tests that touch Horizon or Soroban RPC can use the mock server instead of +hand-rolling `fetch` mocks. It serves realistic Horizon account records and +Soroban JSON-RPC responses, and supports scripted failures (timeouts, 5xx, +malformed bodies): + +```ts +import { createMockServer } from '@vero-protocol/sdk/testing'; + +const server = createMockServer(); +server.failNext('https://primary.example', { type: 'http', status: 503 }); + +const rpc = new RpcClient({ + endpoints: ['https://primary.example', 'https://backup.example'], + fetchImpl: server.fetch, +}); + +const account = await rpc.request('/accounts/GABC...'); // served from backup +``` + +The mock lives behind the `@vero-protocol/sdk/testing` subpath and is never +part of the main bundle. + ## Bundle-size budget This SDK is intended for browser applications, so consumer bundle size is a diff --git a/package.json b/package.json index bd64e54..717b4e0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,12 @@ "require": "./dist/cjs/index.js", "default": "./dist/cjs/index.js" }, + "./testing": { + "types": "./dist/cjs/testing/index.d.ts", + "import": "./dist/esm/testing/index.js", + "require": "./dist/cjs/testing/index.js", + "default": "./dist/cjs/testing/index.js" + }, "./package.json": "./package.json" }, "sideEffects": false, diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 51981b4..cb06452 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -86,4 +86,18 @@ const viaMapImport = await import('@vero-protocol/sdk'); assert(viaMapImport.RpcClient !== undefined, 'exports map: import("@vero-protocol/sdk") did not resolve to the ESM build'); -console.log(`smoke-test: OK — ${REQUIRED_EXPORTS.length} exports verified in both CJS and ESM (direct + exports map), declarations present`); +// The testing subpath must resolve independently of the main entry. +const testingCjs = require('@vero-protocol/sdk/testing'); +assert(typeof testingCjs.createMockServer === 'function', + 'exports map: require("@vero-protocol/sdk/testing") did not resolve to the CJS build'); + +const testingEsm = await import('@vero-protocol/sdk/testing'); +assert(typeof testingEsm.createMockServer === 'function', + 'exports map: import("@vero-protocol/sdk/testing") did not resolve to the ESM build'); + +for (const format of ['cjs', 'esm']) { + const dts = join(root, 'dist', format, 'testing', 'index.d.ts'); + accessSync(dts, constants.R_OK); +} + +console.log(`smoke-test: OK — ${REQUIRED_EXPORTS.length} exports verified in both CJS and ESM (direct + exports map), declarations present; testing subpath resolves in both formats`); diff --git a/src/__tests__/rpc.test.ts b/src/__tests__/rpc.test.ts index 2c4af3b..6640d3d 100644 --- a/src/__tests__/rpc.test.ts +++ b/src/__tests__/rpc.test.ts @@ -1,15 +1,14 @@ import { RpcClient } from '../rpc'; import { VeroError, VeroErrorCode } from '../errors'; - -/** Minimal Response stand-in — avoids depending on a DOM/undici Response. */ -const res = (status: number, body: unknown = {}): Response => - ({ - ok: status >= 200 && status < 300, - status, - json: async () => body, - }) as Response; +import { createMockServer, type MockServer } from '../testing'; describe('RpcClient', () => { + let server: MockServer; + + beforeEach(() => { + server = createMockServer(); + }); + it('requires at least one endpoint', () => { expect(() => new RpcClient({ endpoints: [], fetchImpl: jest.fn() })).toThrow(VeroError); }); @@ -21,46 +20,46 @@ describe('RpcClient', () => { }); it('returns the parsed body on success', async () => { - const fetchImpl = jest.fn().mockResolvedValue(res(200, { ok: true })); - const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl }); - await expect(client.request('/accounts/GABC')).resolves.toEqual({ ok: true }); + const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl: server.fetch }); + await expect(client.request('/accounts/GABC')).resolves.toMatchObject({ + account_id: 'GABC', + }); }); it('falls over to the next endpoint on transport failure', async () => { - const fetchImpl = jest - .fn() - .mockRejectedValueOnce(new Error('ECONNREFUSED')) - .mockResolvedValueOnce(res(200, { via: 'second' })); + server.failNext('a.example', { type: 'network', error: new Error('ECONNREFUSED') }); const client = new RpcClient({ endpoints: ['https://a.example', 'https://b.example'], - fetchImpl, + fetchImpl: server.fetch, }); - await expect(client.request('/x')).resolves.toEqual({ via: 'second' }); - expect(fetchImpl).toHaveBeenCalledTimes(2); + await expect(client.request('/accounts/GABC')).resolves.toMatchObject({ + account_id: 'GABC', + }); + expect(server.requests).toHaveLength(2); }); it('treats 5xx as a transport failure and fails over', async () => { - const fetchImpl = jest - .fn() - .mockResolvedValueOnce(res(503)) - .mockResolvedValueOnce(res(200, { via: 'second' })); + server.failNext('a.example', { type: 'http', status: 503 }); const client = new RpcClient({ endpoints: ['https://a.example', 'https://b.example'], - fetchImpl, + fetchImpl: server.fetch, }); - await expect(client.request('/x')).resolves.toEqual({ via: 'second' }); + await expect(client.request('/accounts/GABC')).resolves.toMatchObject({ + account_id: 'GABC', + }); }); // Regression guard for vero-core-engine#182. it('does NOT penalise an endpoint for an application-level 4xx', async () => { - const fetchImpl = jest.fn().mockResolvedValue(res(404)); + server.failNext('a.example', { type: 'http', status: 404 }); + const client = new RpcClient({ endpoints: ['https://a.example', 'https://b.example'], - fetchImpl, + fetchImpl: server.fetch, failureThreshold: 1, }); @@ -69,83 +68,89 @@ describe('RpcClient', () => { }); // Only the first endpoint was tried, and it stays healthy. - expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); expect(client.health().every((h) => h.healthy)).toBe(true); }); it('quarantines an endpoint after the failure threshold', async () => { - const fetchImpl = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); + server.handle('a.example', () => { + throw new Error('ECONNREFUSED'); + }); + const client = new RpcClient({ endpoints: ['https://a.example'], - fetchImpl, + fetchImpl: server.fetch, failureThreshold: 2, }); - await expect(client.request('/x')).rejects.toThrow(VeroError); + await expect(client.request('/accounts/GABC')).rejects.toThrow(VeroError); expect(client.health()[0]?.healthy).toBe(true); // 1 failure, below threshold - await expect(client.request('/x')).rejects.toThrow(VeroError); + await expect(client.request('/accounts/GABC')).rejects.toThrow(VeroError); expect(client.health()[0]?.healthy).toBe(false); }); it('throws ALL_ENDPOINTS_FAILED when nothing succeeds', async () => { - const fetchImpl = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); + server.handle(() => true, () => { + throw new Error('ECONNREFUSED'); + }); + const client = new RpcClient({ endpoints: ['https://a.example', 'https://b.example'], - fetchImpl, + fetchImpl: server.fetch, }); - await expect(client.request('/x')).rejects.toMatchObject({ + await expect(client.request('/accounts/GABC')).rejects.toMatchObject({ code: VeroErrorCode.AllEndpointsFailed, }); }); it('resets a consecutive-failure count after a success', async () => { - const fetchImpl = jest - .fn() - .mockRejectedValueOnce(new Error('ECONNREFUSED')) - .mockResolvedValueOnce(res(200, {})); - - const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl }); - await expect(client.request('/x')).rejects.toThrow(); - await expect(client.request('/x')).resolves.toEqual({}); + server.failNext('a.example', { type: 'network', error: new Error('ECONNREFUSED') }); + + const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl: server.fetch }); + await expect(client.request('/accounts/GABC')).rejects.toThrow(); + await expect(client.request('/accounts/GABC')).resolves.toMatchObject({ + account_id: 'GABC', + }); expect(client.health()[0]?.consecutiveFailures).toBe(0); }); // Regression guard for the SSRF pattern in vero-audit-guard#302. it('refuses a path that would escape the endpoint origin', async () => { - const fetchImpl = jest.fn().mockResolvedValue(res(200, {})); - const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl }); + const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl: server.fetch }); await expect(client.request('https://evil.example/steal')).rejects.toMatchObject({ code: VeroErrorCode.InvalidUrl, }); - expect(fetchImpl).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(0); }); it('honours endpoint priority', async () => { - const fetchImpl = jest.fn().mockResolvedValue(res(200, {})); const client = new RpcClient({ endpoints: [ { url: 'https://low.example', priority: 10 }, { url: 'https://high.example', priority: 1 }, ], - fetchImpl, + fetchImpl: server.fetch, }); - await client.request('/x'); - expect(fetchImpl.mock.calls[0][0]).toContain('high.example'); + await client.request('/accounts/GABC'); + expect(server.requests[0]?.url).toContain('high.example'); }); it('resetHealth clears quarantines', async () => { - const fetchImpl = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); + server.handle('a.example', () => { + throw new Error('ECONNREFUSED'); + }); + const client = new RpcClient({ endpoints: ['https://a.example'], - fetchImpl, + fetchImpl: server.fetch, failureThreshold: 1, }); - await expect(client.request('/x')).rejects.toThrow(); + await expect(client.request('/accounts/GABC')).rejects.toThrow(); expect(client.health()[0]?.healthy).toBe(false); client.resetHealth(); diff --git a/src/account/__tests__/loader.test.ts b/src/account/__tests__/loader.test.ts index c32754b..2510c35 100644 --- a/src/account/__tests__/loader.test.ts +++ b/src/account/__tests__/loader.test.ts @@ -1,104 +1,66 @@ /** - * Tests for the account loader module + * Tests for the account loader module. + * + * The loader calls global `fetch` directly (it has no injectable fetchImpl), + * so these tests point `global.fetch` at the mock Horizon server from + * `src/testing` and restore it afterwards. */ import { AccountLoader } from '../loader'; -import { VeroError, VeroErrorCode } from '../../errors'; +import { AccountNotFoundError } from '../types'; +import { createMockServer, horizonAccountFixture, type MockServer } from '../../testing'; -// Mock the fetch function -const mockFetch = jest.fn(); -global.fetch = mockFetch; +const originalFetch = global.fetch; describe('AccountLoader', () => { let loader: AccountLoader; + let server: MockServer; const mockHorizonUrl = 'https://horizon-testnet.stellar.org'; beforeEach(() => { + server = createMockServer(); + global.fetch = server.fetch; loader = new AccountLoader(); loader.clearCache(); - jest.clearAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; }); describe('loadAccount', () => { it('should skip cache when skipCache is true', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - account_id: 'test123', - sequence: '123', - flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, - signers: [], - thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, - balances: [], - data: {}, - last_modified_ledger: 100, - }), - }); - await loader.loadAccount(mockHorizonUrl, 'test123', { cache: true, skipCache: true, }); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); }); it('should use cache when enabled and skipCache is false', async () => { - const mockAccount = { - account_id: 'test456', - sequence: '456', - flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, - signers: [], - thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, - balances: [], - data: {}, - last_modified_ledger: 100, - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => mockAccount, - }); - // First call should fetch await loader.loadAccount(mockHorizonUrl, 'test456', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); // Second call should use cache await loader.loadAccount(mockHorizonUrl, 'test456', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); }); it('should not use cache when cache is disabled', async () => { - const mockAccount = { - account_id: 'test789', - sequence: '789', - flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, - signers: [], - thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, - balances: [], - data: {}, - last_modified_ledger: 100, - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => mockAccount, - }); - await loader.loadAccount(mockHorizonUrl, 'test789', { cache: false }); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); await loader.loadAccount(mockHorizonUrl, 'test789', { cache: false }); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(server.requests).toHaveLength(2); }); - it('should throw VeroError with AccountNotFound code for missing account', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: 'Not Found', - }); + it('should throw AccountNotFoundError for missing account', async () => { + server.failNext( + (url) => url.endsWith('/accounts/missing123'), + { type: 'http', status: 404, body: { title: 'Resource Missing', status: 404 } }, + ); const promise = loader.loadAccount(mockHorizonUrl, 'missing123'); await expect(promise).rejects.toThrow(VeroError); @@ -107,8 +69,11 @@ describe('AccountLoader', () => { }); }); - it('should handle network errors and normalize them', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); + it('should handle network errors', async () => { + server.failNext( + (url) => url.endsWith('/accounts/test456'), + { type: 'network', error: new Error('Network error') }, + ); const promise = loader.loadAccount(mockHorizonUrl, 'test456'); await expect(promise).rejects.toThrow(VeroError); @@ -117,12 +82,11 @@ describe('AccountLoader', () => { }); }); - it('should handle Horizon 500 error with RpcRequestFailed code', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }); + it('should handle Horizon 500 error', async () => { + server.failNext( + (url) => url.endsWith('/accounts/test789'), + { type: 'http', status: 500 }, + ); const promise = loader.loadAccount(mockHorizonUrl, 'test789'); await expect(promise).rejects.toThrow(VeroError); @@ -184,171 +148,59 @@ describe('AccountLoader', () => { describe('cache management', () => { it('should evict specific account from cache', async () => { - const mockAccount = { - account_id: 'test999', - sequence: '999', - flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, - signers: [], - thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, - balances: [], - data: {}, - last_modified_ledger: 100, - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => mockAccount, - }); - await loader.loadAccount(mockHorizonUrl, 'test999', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); loader.evict('test999'); await loader.loadAccount(mockHorizonUrl, 'test999', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(server.requests).toHaveLength(2); }); it('should clear all cache', async () => { - const mockAccount = { - account_id: 'test111', - sequence: '111', - flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, - signers: [], - thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, - balances: [], - data: {}, - last_modified_ledger: 100, - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => mockAccount, - }); - await loader.loadAccount(mockHorizonUrl, 'test111', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); loader.clearCache(); await loader.loadAccount(mockHorizonUrl, 'test111', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(server.requests).toHaveLength(2); }); - it('should refresh cache and repopulate it so subsequent loadAccount calls hit cache', async () => { - const mockAccount = { - account_id: 'test222', - sequence: '222', - flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, - signers: [], - thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, - balances: [], - data: {}, - last_modified_ledger: 100, - }; - const updatedAccount = { - ...mockAccount, - sequence: '223', - }; - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => mockAccount, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => updatedAccount, - }); - - await loader.loadAccount(mockHorizonUrl, 'test222', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(1); + it('should refresh cache', async () => { + const first = await loader.loadAccount(mockHorizonUrl, 'test222', { cache: true }); + expect(server.requests).toHaveLength(1); const result = await loader.refreshCache(mockHorizonUrl, 'test222'); - expect(result.sequence).toBe('223'); - expect(mockFetch).toHaveBeenCalledTimes(2); - - // Subsequent loadAccount with cache enabled should hit cache without network fetch - const cachedAccount = await loader.loadAccount(mockHorizonUrl, 'test222', { cache: true }); - expect(cachedAccount.sequence).toBe('223'); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('should respect custom cacheTTL parameter in refreshCache', async () => { - jest.useFakeTimers(); - const mockAccount = { - account_id: 'test555', - sequence: '555', - flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, - signers: [], - thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, - balances: [], - data: {}, - last_modified_ledger: 100, - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => mockAccount, - }); - - await loader.refreshCache(mockHorizonUrl, 'test555', 500); - expect(mockFetch).toHaveBeenCalledTimes(1); - - // Subsequent load before TTL should hit cache - await loader.loadAccount(mockHorizonUrl, 'test555', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(1); - - // Advance time past custom TTL - jest.advanceTimersByTime(600); - - // Subsequent load after TTL should fetch from network - await loader.loadAccount(mockHorizonUrl, 'test555', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(2); - - jest.useRealTimers(); + // The mock server bumps the on-chain sequence on every fetch, so the + // refresh must observe a newer sequence than the first load. + expect(BigInt(result.sequence)).toBe(BigInt(first.sequence) + 1n); + expect(server.requests).toHaveLength(2); }); it('should expire cache after TTL', async () => { jest.useFakeTimers(); - const mockAccount = { - account_id: 'test333', - sequence: '333', - flags: { auth_required: false, auth_revocable: false, auth_immutable: false }, - signers: [], - thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, - balances: [], - data: {}, - last_modified_ledger: 100, - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => mockAccount, - }); - - await loader.loadAccount(mockHorizonUrl, 'test333', { cache: true, cacheTTL: 100 }); - expect(mockFetch).toHaveBeenCalledTimes(1); - - // Advance time past TTL - jest.advanceTimersByTime(150); - - await loader.loadAccount(mockHorizonUrl, 'test333', { cache: true }); - expect(mockFetch).toHaveBeenCalledTimes(2); - - jest.useRealTimers(); + try { + await loader.loadAccount(mockHorizonUrl, 'test333', { cache: true, cacheTTL: 100 }); + expect(server.requests).toHaveLength(1); + + // Advance time past TTL + jest.advanceTimersByTime(150); + + await loader.loadAccount(mockHorizonUrl, 'test333', { cache: true }); + expect(server.requests).toHaveLength(2); + } finally { + jest.useRealTimers(); + } }); }); describe('normalizeAccount', () => { it('should handle missing fields gracefully', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - account_id: 'test444', - // Missing sequence, flags, signers, thresholds, balances, data - }), - }); + server.handle( + (url) => url.endsWith('/accounts/test444'), + { account_id: 'test444' }, + ); const result = await loader.loadAccount(mockHorizonUrl, 'test444'); expect(result.account_id).toBe('test444'); @@ -358,5 +210,17 @@ describe('AccountLoader', () => { expect(result.balances).toEqual([]); expect(result.data).toEqual({}); }); + + it('should normalize a realistic Horizon account response', async () => { + const fixture = horizonAccountFixture({ account_id: 'test555' }); + server.handle((url) => url.endsWith('/accounts/test555'), fixture); + + const result = await loader.loadAccount(mockHorizonUrl, 'test555'); + expect(result.account_id).toBe('test555'); + expect(result.sequence).toBe(fixture.sequence); + expect(result.flags.auth_revocable).toBe(false); + expect(result.balances.some((b) => b.asset_type === 'native')).toBe(true); + expect(result.signers).toHaveLength(1); + }); }); }); diff --git a/src/testing/__tests__/mock-server.test.ts b/src/testing/__tests__/mock-server.test.ts new file mode 100644 index 0000000..9542a25 --- /dev/null +++ b/src/testing/__tests__/mock-server.test.ts @@ -0,0 +1,351 @@ +/** + * Tests for the mock Horizon + Soroban RPC server. + * + * Two things are pinned here: + * 1. The built-in routes return *realistic* shapes — the Horizon account + * record the loader consumes, and JSON-RPC results that match Soroban + * RPC (echoed `id`, `jsonrpc: "2.0"`). + * 2. The fixtures align with the SDK itself — a fixture event's `topic[0]` + * must decode via `decodeEvent` to the event type its symbol names. + */ + +import { + createMockServer, + horizonAccountFixture, + sorobanContractEvent, + sorobanEventsResult, + sorobanRpcResponse, + scValSymbol, + DEFAULT_ACCOUNT_ID, + type MockServer, + type ScriptedFailure, +} from '../mock-server'; +import { decodeEvent, normalizeTopic, type TaskRegisteredEvent } from '../../events'; + +describe('fixtures', () => { + it('horizonAccountFixture matches the real Horizon account shape', () => { + const account = horizonAccountFixture(); + + expect(account.account_id).toBe(DEFAULT_ACCOUNT_ID); + expect(typeof account.sequence).toBe('string'); + expect(account.thresholds).toEqual({ + low_threshold: 0, + med_threshold: 0, + high_threshold: 0, + }); + expect(account.flags).toMatchObject({ auth_required: false }); + // Real accounts always carry a native balance line. + const native = (account.balances as { asset_type: string }[]).find( + (b) => b.asset_type === 'native', + ); + expect(native).toBeDefined(); + expect(account.signers).toHaveLength(1); + }); + + it('sorobanContractEvent topics decode through the SDK event decoder', () => { + const event = sorobanContractEvent({ + topic: [scValSymbol('reg'), scValSymbol('admin-address')], + }); + const topics = event.topic as string[]; + + // The raw RPC event carries `value.xdr` (opaque to this SDK), so topic[0] + // is what the decoder consumes; give it an already-decoded payload to + // exercise the full pipeline. + expect(normalizeTopic(topics[0] ?? '')).toBe('task_registered'); + const decoded = decodeEvent({ + topic: [topics[0] ?? ''], + data: ['GADMIN', '42'], + }) as TaskRegisteredEvent; + expect(decoded.type).toBe('task_registered'); + expect(decoded.taskId).toBe(42n); + }); + + it('sorobanRpcResponse wraps results in the JSON-RPC envelope', () => { + expect(sorobanRpcResponse('getHealth', { status: 'healthy' }, 7)).toEqual({ + jsonrpc: '2.0', + id: 7, + result: { status: 'healthy' }, + }); + }); + + it('sorobanEventsResult carries the three Vero contract events', () => { + const result = sorobanEventsResult(); + const events = result.events as { topic: string[] }[]; + const names = events.map((e) => e.topic[0]).map((t) => t ?? ''); + expect(normalizeTopic(names[0] ?? '')).toBe('task_registered'); + expect(normalizeTopic(names[1] ?? '')).toBe('vote_cast'); + expect(normalizeTopic(names[2] ?? '')).toBe('consensus_resolved'); + expect(result.latestLedger).toBeGreaterThan(0); + expect(result.cursor).toBeDefined(); + }); +}); + +describe('MockServer — Horizon routes', () => { + let server: MockServer; + + beforeEach(() => { + server = createMockServer(); + }); + + it('serves a realistic account for GET /accounts/:id', async () => { + const res = await server.fetch('https://horizon.example/accounts/GABC'); + expect(res.status).toBe(200); + expect(res.ok).toBe(true); + + const body = (await res.json()) as { account_id: string; sequence: string }; + expect(body.account_id).toBe('GABC'); + expect(typeof body.sequence).toBe('string'); + }); + + it('bumps the account sequence on each fetch, like the real ledger', async () => { + const first = (await ( + await server.fetch('https://horizon.example/accounts/GABC') + ).json()) as { sequence: string }; + const second = (await ( + await server.fetch('https://horizon.example/accounts/GABC') + ).json()) as { sequence: string }; + + expect(BigInt(second.sequence)).toBe(BigInt(first.sequence) + 1n); + }); + + it('returns a Horizon problem-details 404 for unknown paths', async () => { + const res = await server.fetch('https://horizon.example/ledgers/1'); + expect(res.status).toBe(404); + const body = (await res.json()) as { title: string; status: number }; + expect(body.title).toBe('Resource Missing'); + expect(body.status).toBe(404); + }); +}); + +describe('MockServer — Soroban RPC routes', () => { + let server: MockServer; + + beforeEach(() => { + server = createMockServer(); + }); + + const rpc = (method: string, id = 1) => + server.fetch('https://soroban.example/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id, method }), + }); + + it('answers getHealth', async () => { + const body = (await (await rpc('getHealth')).json()) as { + jsonrpc: string; + id: number; + result: { status: string }; + }; + expect(body.jsonrpc).toBe('2.0'); + expect(body.id).toBe(1); + expect(body.result.status).toBe('healthy'); + }); + + it('answers getNetwork with the testnet passphrase', async () => { + const body = (await (await rpc('getNetwork', 2)).json()) as { + id: number; + result: { passphrase: string }; + }; + expect(body.id).toBe(2); + expect(body.result.passphrase).toBe('Test SDF Network ; September 2015'); + }); + + it('answers getLatestLedger', async () => { + const body = (await (await rpc('getLatestLedger')).json()) as { + result: { sequence: number }; + }; + expect(body.result.sequence).toBeGreaterThan(0); + }); + + it('answers getEvents with decodable Vero events', async () => { + const body = (await (await rpc('getEvents')).json()) as { + result: { events: { topic: string[] }[] }; + }; + const topics = body.result.events.map((e) => e.topic[0] ?? ''); + expect(normalizeTopic(topics[0] ?? '')).toBe('task_registered'); + }); + + it('reports JSON-RPC errors for unknown methods and bad bodies', async () => { + const unknown = (await (await rpc('bogusMethod')).json()) as { + error: { code: number }; + }; + expect(unknown.error.code).toBe(-32601); + + const badBody = (await ( + await server.fetch('https://soroban.example/', { + method: 'POST', + body: 'not json', + }) + ).json()) as { error: { code: number } }; + expect(badBody.error.code).toBe(-32700); + }); +}); + +describe('MockServer — scripted failures', () => { + let server: MockServer; + + beforeEach(() => { + server = createMockServer(); + }); + + const scenarios: [string, ScriptedFailure, (res: Response) => Promise | void][] = [ + [ + 'http status', + { type: 'http', status: 503 }, + (res) => { + expect(res.status).toBe(503); + expect(res.ok).toBe(false); + }, + ], + ]; + + for (const [label, failure, assert] of scenarios) { + it(`applies a scripted ${label} failure`, async () => { + server.failNext('a.example', failure); + await assert(await server.fetch('https://a.example/accounts/GABC')); + }); + } + + it('scripts a malformed body (200 with invalid JSON)', async () => { + server.failNext('a.example', { type: 'malformed' }); + const res = await server.fetch('https://a.example/accounts/GABC'); + expect(res.status).toBe(200); + // Match on the message: undici rejects with a cross-realm SyntaxError, so + // `instanceof SyntaxError` (what toThrow uses) is unreliable. + await expect(res.json()).rejects.toThrow(/not valid JSON/); + }); + + it('scripts a network rejection', async () => { + server.failNext('a.example', { type: 'network', error: new Error('ECONNREFUSED') }); + await expect(server.fetch('https://a.example/accounts/GABC')).rejects.toThrow( + 'ECONNREFUSED', + ); + + server.failNext('a.example', { type: 'network' }); + await expect(server.fetch('https://a.example/accounts/GABC')).rejects.toThrow( + TypeError, + ); + }); + + it('scripts a timeout that rejects with AbortError on abort', async () => { + server.failNext('a.example', { type: 'timeout' }); + + const controller = new AbortController(); + const pending = server.fetch('https://a.example/accounts/GABC', { + signal: controller.signal, + }); + + let settled = false; + void pending.catch(() => { + settled = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(settled).toBe(false); // still hanging before the abort + + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('applies a scripted failure only to matching URLs, once', async () => { + server.failNext('a.example', { type: 'http', status: 500 }); + + const primary = await server.fetch('https://a.example/accounts/GABC'); + expect(primary.status).toBe(500); + + // Non-matching URL is unaffected, and the script is now consumed. + const backup = await server.fetch('https://b.example/accounts/GABC'); + expect(backup.status).toBe(200); + + const retry = await server.fetch('https://a.example/accounts/GABC'); + expect(retry.status).toBe(200); + }); +}); + +describe('MockServer — custom handlers', () => { + let server: MockServer; + + beforeEach(() => { + server = createMockServer(); + }); + + it('serves a plain-value body as 200 JSON', async () => { + server.handle((url) => url.includes('custom'), { ok: 'custom' }); + const body = (await (await server.fetch('https://a.example/custom')).json()) as { + ok: string; + }; + expect(body.ok).toBe('custom'); + }); + + it('passes through a Response for full control', async () => { + server.handle('teapot', () => new Response('short and stout', { status: 418 })); + const res = await server.fetch('https://a.example/teapot'); + expect(res.status).toBe(418); + await expect(res.text()).resolves.toBe('short and stout'); + }); + + it('lets a handler throw to simulate a persistently-down endpoint', async () => { + server.handle('down', () => { + throw new Error('ECONNREFUSED'); + }); + await expect(server.fetch('https://a.example/down')).rejects.toThrow( + 'ECONNREFUSED', + ); + }); +}); + +describe('MockServer — bookkeeping', () => { + it('records every request for assertions', async () => { + const server = createMockServer(); + await server.fetch('https://a.example/accounts/GABC'); + await server.fetch('https://soroban.example/', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getHealth' }), + }); + + expect(server.requests).toHaveLength(2); + expect(server.requests[0]?.method).toBe('GET'); + expect(server.requests[0]?.url).toContain('/accounts/GABC'); + expect(server.requests[1]?.body).toMatchObject({ method: 'getHealth' }); + }); + + it('reset() clears scripts, handlers, and the request log', async () => { + const server = createMockServer(); + server.failNext('a.example', { type: 'http', status: 500 }); + server.handle('x', { ok: true }); + + await server.fetch('https://a.example/x'); + expect(server.requests).toHaveLength(1); + + server.reset(); + await server.fetch('https://a.example/x'); + expect(server.requests).toHaveLength(1); + + // The previously-scripted failure is gone — this returned 200 (default route). + const res = await server.fetch('https://a.example/accounts/GABC'); + expect(res.status).toBe(200); + }); + + it('applies the configured latency to responses', async () => { + jest.useFakeTimers(); + try { + const server = createMockServer({ latencyMs: 500 }); + const pending = server.fetch('https://a.example/accounts/GABC'); + let settled = false; + void pending.then(() => { + settled = true; + }); + + await jest.advanceTimersByTimeAsync(499); + expect(settled).toBe(false); + + await jest.advanceTimersByTimeAsync(1); + const res = await pending; + expect(res.status).toBe(200); + expect(settled).toBe(true); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/testing/index.ts b/src/testing/index.ts new file mode 100644 index 0000000..58b221c --- /dev/null +++ b/src/testing/index.ts @@ -0,0 +1,8 @@ +/** + * Test-only helpers, exported via the `@vero-protocol/sdk/testing` subpath. + * + * Import from `@vero-protocol/sdk/testing`, never from the main entry point: + * the main bundle deliberately does not include this module, so consumers' + * production builds never pay for test fixtures. + */ +export * from './mock-server.js'; diff --git a/src/testing/mock-server.ts b/src/testing/mock-server.ts new file mode 100644 index 0000000..1276051 --- /dev/null +++ b/src/testing/mock-server.ts @@ -0,0 +1,570 @@ +/** + * Mock Horizon + Soroban RPC server for tests. + * + * Tests used to hand-roll `fetch` mocks per file. Each module reinvented the + * same Horizon account body and Soroban JSON-RPC envelope, and those + * hand-rolled shapes drifted from what the real servers return — a mock that + * passes while the real integration fails is worse than no mock at all. + * + * This module is the single place that produces realistic responses: + * + * - `GET /accounts/:id` → a real-shaped Horizon account record + * - `POST /` (JSON-RPC) → `getHealth`, `getNetwork`, + * `getLatestLedger`, `getEvents` results + * - anything else → a Horizon problem-details 404 + * + * Failures are scripted per-request with {@link MockServer.failNext}: + * timeouts (never settles until the caller's `AbortSignal` fires — which is + * what an `RpcClient` timeout does), network rejections, 5xx HTTP statuses, + * and 200s with bodies that are not valid JSON. + * + * Deliberately NOT re-exported from `src/index.ts`. It exists only for tests + * and is reachable via the `@vero-protocol/sdk/testing` subpath export, so + * consumers' test suites can use it without the mock (or its fixtures) ever + * entering the main bundle. + */ + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Matches a request URL: substring, RegExp, or a predicate. */ +export type UrlMatcher = string | RegExp | ((url: string) => boolean); + +/** + * A one-shot scripted failure, applied to the next matching request. + * + * - `timeout` — never settles; rejects with `AbortError` when the caller's + * `AbortSignal` fires. Pass no signal and the request hangs + * forever (an `RpcClient` always passes its timeout signal). + * - `network` — rejects the fetch promise like a failed connection. + * - `http` — resolves to a response with the given status. + * - `malformed` — resolves to a 200 whose body is not valid JSON, so + * `response.json()` rejects. + */ +export type ScriptedFailure = + | { type: 'timeout' } + | { type: 'network'; error?: Error } + | { type: 'http'; status: number; body?: unknown } + | { type: 'malformed'; body?: string }; + +/** A request as observed by the mock server (for assertions and handlers). */ +export interface MockRequest { + /** Full request URL, e.g. `https://a.example/accounts/GABC`. */ + url: string; + /** HTTP method, uppercased. */ + method: string; + /** Parsed JSON body for requests that carried one. */ + body?: unknown; +} + +/** + * A dynamic handler. Return a plain object/array for a 200 JSON response, a + * `Response` to control status/headers, or throw to reject the fetch promise + * (useful for simulating repeated network failures). + */ +export type Responder = ( + req: MockRequest, +) => unknown | Response | Promise; + +export interface MockServerOptions { + /** + * Base latency applied to every response, in milliseconds. + * @default 0 + */ + latencyMs?: number; +} + +// --------------------------------------------------------------------------- +// Fixtures — based on real Horizon / Soroban RPC response shapes +// --------------------------------------------------------------------------- + +/** The zero-account address (valid Stellar format, clearly a fixture). */ +export const DEFAULT_ACCOUNT_ID = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +/** + * Base sequence for newly-seen accounts. Realistic Horizon sequence numbers + * are large monotonically increasing integers. + */ +export const ACCOUNT_BASE_SEQUENCE = 22305791749488643n; + +/** + * A realistic Horizon `/accounts/:id` record, mirroring the fields Horizon + * actually returns: `_links`, `id`, `sequence`, `subentry_count`, + * `last_modified_ledger`, `thresholds`, `flags`, `balances` (native + a + * credit asset), `signers`, `data`, and paging metadata. The loader's + * `normalizeAccount` consumes this shape directly. + */ +export function horizonAccountFixture( + overrides: Record = {}, +): Record { + const accountId = (overrides.account_id as string | undefined) ?? DEFAULT_ACCOUNT_ID; + return { + _links: { + self: { href: `https://horizon-testnet.stellar.org/accounts/${accountId}` }, + }, + id: accountId, + account_id: accountId, + sequence: '22305791749488643', + subentry_count: 2, + last_modified_ledger: 3541234, + last_modified_time: '2024-06-01T12:34:56Z', + thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, + flags: { + auth_required: false, + auth_revocable: false, + auth_immutable: false, + auth_clawback_enabled: false, + }, + balances: [ + { + balance: '9999.9999900', + limit: '922337203685.4775807', + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + last_modified_ledger: 3541234, + is_authorized: true, + is_authorized_to_maintain_liabilities: true, + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + { + balance: '100.0000000', + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + asset_type: 'native', + }, + ], + signers: [{ weight: 1, key: accountId, type: 'ed25519_public_key' }], + data: {}, + num_sponsored: 0, + num_sponsoring: 0, + paging_token: '3541234-3', + ...overrides, + }; +} + +/** + * Base64 XDR `ScVal` symbol — what Soroban RPC returns for event `topic` + * entries (int32 tag 15, u32 length, UTF-8 bytes). The event decoder + * (`src/events/decoder.ts`) recognises these, so fixtures built with this + * helper decode to real event types instead of `unknown`. + */ +export function scValSymbol(name: string): string { + const bytes = Buffer.byteLength(name, 'utf8'); + const buf = Buffer.alloc(8 + bytes); + buf.writeInt32BE(15, 0); // SCV_SYMBOL + buf.writeUInt32BE(bytes, 4); + buf.write(name, 8, 'utf8'); + return buf.toString('base64'); +} + +/** Base64 XDR `ScVal` u64 (int32 tag 5). Useful as a non-symbol topic entry. */ +export function scValU64(value: bigint): string { + const buf = Buffer.alloc(12); + buf.writeInt32BE(5, 0); // SCV_U64 + buf.writeBigUInt64BE(value, 4); + return buf.toString('base64'); +} + +/** A single `ScVal` vec (int32 tag 10) from element XDR buffers. */ +function scValVec(...elements: Buffer[]): Buffer { + const head = Buffer.alloc(8); + head.writeInt32BE(10, 0); // SCV_VEC + head.writeUInt32BE(elements.length, 4); + return Buffer.concat([head, ...elements]); +} + +/** Realistic but opaque `value.xdr` — a vec of two u64s, valid ScVal XDR. */ +function eventValueXdr(): string { + const u64 = (v: bigint): Buffer => { + const buf = Buffer.alloc(12); + buf.writeInt32BE(5, 0); // SCV_U64 + buf.writeBigUInt64BE(v, 4); + return buf; + }; + return scValVec(u64(42n), u64(7n)).toString('base64'); +} + +/** + * A realistic Soroban RPC contract event record, mirroring `getEvents` event + * objects: `type`, `ledger`, `ledgerClosedAt`, `contractId`, `id`, + * `pagingToken`, a `topic` array of base64 ScVal XDR (topic[0] is the event + * name symbol — the only field the SDK decodes), an opaque `value.xdr`, and + * `inSuccessfulContractCall`. + */ +export function sorobanContractEvent( + overrides: Record = {}, +): Record { + return { + type: 'contract', + ledger: 3541234, + ledgerClosedAt: '2024-06-01T12:34:56Z', + contractId: + 'CDLZFC3SYJYDZT7K3V6KZ4Y3XK4XK4XK4XK4XK4XK4XK4XK4XK4XK4XK4X', + id: '0000000000000000-0000000000', + pagingToken: '3541234-0000000000', + topic: [scValSymbol('reg'), scValU64(42n), scValU64(7n)], + value: { xdr: eventValueXdr() }, + inSuccessfulContractCall: true, + ...overrides, + }; +} + +/** + * The three Vero contract events (`reg`, `wt_vote`, `resolved` — see + * `vero-core-contracts/src/events.rs`), so `getEvents` fixtures exercise the + * full decoder surface. + */ +export function defaultSorobanEvents(): Record[] { + return [ + sorobanContractEvent({ topic: [scValSymbol('reg'), scValU64(42n)] }), + sorobanContractEvent({ + id: '0000000000000001-0000000000', + pagingToken: '3541234-0000000001', + topic: [scValSymbol('wt_vote'), scValU64(42n), scValU64(250n)], + }), + sorobanContractEvent({ + id: '0000000000000002-0000000000', + pagingToken: '3541234-0000000002', + topic: [scValSymbol('resolved'), scValU64(42n), scValU64(250n)], + }), + ]; +} + +/** Realistic `getEvents` result body (events + latest/oldest ledger info). */ +export function sorobanEventsResult( + events: Record[] = defaultSorobanEvents(), +): Record { + return { + events, + latestLedger: 3541234, + latestLedgerCloseTime: '2024-06-01T12:34:56Z', + oldestLedger: 3540000, + oldestLedgerCloseTime: '2024-06-01T00:00:00Z', + cursor: '3541234-0000000000', + }; +} + +/** Realistic `getHealth` result body. */ +export function sorobanHealthResult( + overrides: Record = {}, +): Record { + return { + status: 'healthy', + latestLedger: 3541234, + oldestLedger: 3540000, + ledgerRetentionWindow: 129600, + ...overrides, + }; +} + +/** Realistic `getNetwork` result body. */ +export function sorobanNetworkResult( + overrides: Record = {}, +): Record { + return { + friendbotUrl: 'https://friendbot.stellar.org', + passphrase: 'Test SDF Network ; September 2015', + protocolVersion: 22, + ...overrides, + }; +} + +/** Realistic `getLatestLedger` result body. */ +export function sorobanLatestLedgerResult( + overrides: Record = {}, +): Record { + return { + id: '7ec0be6a4a014d9f9f1e34f9e1b0a4f5c3d2e1b0a9f8e7d6c5b4a39281706', + protocolVersion: 22, + sequence: 3541234, + ...overrides, + }; +} + +/** Wrap a method result in the Soroban RPC JSON-RPC envelope, echoing `id`. */ +export function sorobanRpcResponse( + method: string, + result: unknown, + id: unknown = 1, +): Record { + void method; // documented for clarity; the envelope does not echo it + return { jsonrpc: '2.0', id, result }; +} + +/** Horizon problem-details body for a 404, matching Horizon's error shape. */ +export function horizonNotFound(): Record { + return { + type: 'https://horizon-testnet.stellar.org/problem/not_found', + title: 'Resource Missing', + status: 404, + detail: 'The resource at the url requested was not found.', + }; +} + +// --------------------------------------------------------------------------- +// The mock server +// --------------------------------------------------------------------------- + +interface ScriptEntry { + matcher: UrlMatcher; + failure: ScriptedFailure; +} + +interface HandlerEntry { + matcher: UrlMatcher; + responder: Responder | unknown; +} + +/** + * A fake Horizon + Soroban RPC server. + * + * Create one per test (or per suite), hand {@link MockServer.fetch} to an + * `RpcClient` as `fetchImpl` or assign it to `global.fetch`, and script + * failures with {@link MockServer.failNext}. + */ +export class MockServer { + private readonly latencyMs: number; + private readonly scripts: ScriptEntry[] = []; + private readonly handlers: HandlerEntry[] = []; + private readonly log: MockRequest[] = []; + /** Per-account sequence, bumped on every fetch like the real ledger. */ + private readonly sequences = new Map(); + + constructor(opts: MockServerOptions = {}) { + this.latencyMs = opts.latencyMs ?? 0; + } + + /** + * Drop-in `fetch` implementation. Use as `fetchImpl` on `RpcClient`, or + * assign to `global.fetch` for code that calls `fetch` directly. + */ + get fetch(): typeof fetch { + return (input, init) => this.respond(input, init); + } + + /** Requests received so far, oldest first. For call-count assertions. */ + get requests(): readonly MockRequest[] { + return [...this.log]; + } + + /** + * Script a one-shot failure for the next request whose URL matches + * `matcher`. The script is consumed by the first matching request and does + * not affect non-matching URLs, so a failover test can script a failure on + * the primary endpoint while the backup responds normally. + */ + failNext(matcher: UrlMatcher, failure: ScriptedFailure): this { + this.scripts.push({ matcher, failure }); + return this; + } + + /** + * Register a custom response for requests whose URL matches `matcher`. + * `responder` may be: + * + * - a function returning a plain value → 200 JSON; + * - a function returning a `Response` → used as-is (custom status/body); + * - a function that throws → the fetch promise rejects (handy for + * simulating a persistently-down endpoint); + * - a plain value → 200 JSON for every matching request. + * + * Handlers run after scripted failures and before the built-in routes, in + * registration order. + */ + handle(matcher: UrlMatcher, responder: Responder | unknown): this { + this.handlers.push({ matcher, responder }); + return this; + } + + /** Clear scripted failures, custom handlers, the request log, and + * per-account sequence state. */ + reset(): void { + this.scripts.length = 0; + this.handlers.length = 0; + this.log.length = 0; + this.sequences.clear(); + } + + private async respond( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + const req: MockRequest = { + url: toUrl(input), + method: (init?.method ?? 'GET').toUpperCase(), + }; + if (typeof init?.body === 'string') { + try { + req.body = JSON.parse(init.body); + } catch { + // Leave body unset — the JSON-RPC route reports a parse error. + } + } + + this.log.push(req); + + if (this.latencyMs > 0) { + await delay(this.latencyMs); + } + + const scriptIdx = this.scripts.findIndex((s) => matches(s.matcher, req.url)); + if (scriptIdx !== -1) { + const [script] = this.scripts.splice(scriptIdx, 1); + return this.respondFailure( + script?.failure ?? { type: 'network' }, + init?.signal ?? undefined, + ); + } + + for (const { matcher, responder } of this.handlers) { + if (matches(matcher, req.url)) { + return toResponse(await invokeResponder(responder, req)); + } + } + + return this.routeDefault(req); + } + + private async respondFailure( + failure: ScriptedFailure, + signal: AbortSignal | undefined, + ): Promise { + switch (failure.type) { + case 'timeout': + return new Promise((_resolve, reject) => { + if (signal?.aborted) { + reject(abortError()); + return; + } + signal?.addEventListener('abort', () => reject(abortError()), { + once: true, + }); + // No signal: never settle — the request simply hangs. + }); + case 'network': + throw failure.error ?? new TypeError('fetch failed'); + case 'http': + return jsonResponse(failure.body ?? null, failure.status); + case 'malformed': + return new Response(failure.body ?? 'not json', { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + } + } + + /** Built-in routes: Horizon accounts, Soroban JSON-RPC, else 404. */ + private routeDefault(req: MockRequest): Response { + const { pathname } = new URL(req.url); + + // Horizon: GET /accounts/:id + const accountMatch = /^\/accounts\/([^/]+)$/.exec(pathname); + if (accountMatch?.[1] && req.method === 'GET') { + return jsonResponse(this.accountBody(decodeURIComponent(accountMatch[1]))); + } + + // Soroban RPC: JSON-RPC POST to the endpoint root. + if (req.method === 'POST' && (pathname === '/' || pathname === '')) { + return this.rpcResponse(req); + } + + return jsonResponse(horizonNotFound(), 404); + } + + private accountBody(id: string): Record { + const seq = this.sequences.get(id) ?? ACCOUNT_BASE_SEQUENCE; + // Sequence advances on every fetch, mirroring the real ledger after a + // transaction — a second fetch of the same account returns a bumped value. + this.sequences.set(id, seq + 1n); + return horizonAccountFixture({ account_id: id, sequence: seq.toString() }); + } + + private rpcResponse(req: MockRequest): Response { + const body = req.body; + if (body === null || typeof body !== 'object') { + return jsonResponse({ + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }); + } + + const { id, method, params } = body as Record; + const rpcId = id ?? null; + + switch (method) { + case 'getHealth': + return jsonResponse(sorobanRpcResponse('getHealth', sorobanHealthResult(), rpcId)); + case 'getNetwork': + return jsonResponse(sorobanRpcResponse('getNetwork', sorobanNetworkResult(), rpcId)); + case 'getLatestLedger': + return jsonResponse( + sorobanRpcResponse('getLatestLedger', sorobanLatestLedgerResult(), rpcId), + ); + case 'getEvents': + return jsonResponse(sorobanRpcResponse('getEvents', sorobanEventsResult(), rpcId)); + default: + void params; + return jsonResponse({ + jsonrpc: '2.0', + id: rpcId, + error: { code: -32601, message: `Method not found: ${String(method)}` }, + }); + } + } +} + +/** Create a {@link MockServer}. */ +export function createMockServer(opts: MockServerOptions = {}): MockServer { + return new MockServer(opts); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function toUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function matches(matcher: UrlMatcher, url: string): boolean { + if (typeof matcher === 'function') return matcher(url); + if (matcher instanceof RegExp) return matcher.test(url); + return url.includes(matcher); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body ?? null), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +async function invokeResponder( + responder: Responder | unknown, + req: MockRequest, +): Promise { + return typeof responder === 'function' + ? (responder as Responder)(req) + : responder; +} + +async function toResponse(result: unknown | Response): Promise { + if (result instanceof Response) return result; + return jsonResponse(result); +} + +function abortError(): DOMException { + return new DOMException('The operation was aborted due to timeout', 'AbortError'); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +}