diff --git a/src/escrow/client.ts b/src/escrow/client.ts index b9ed34b..fa116e5 100644 --- a/src/escrow/client.ts +++ b/src/escrow/client.ts @@ -1,5 +1,5 @@ import { ContractConfig } from '../types/contract'; -import { EscrowParams, EscrowState, SDKResult } from '../types/index'; +import { EscrowParams, EscrowState, SDKResult, GetGigsParams, GigsPage } from '../types/index'; import { assertStellarAddress, xlmToStroops } from '../utils/validation'; export class TrustFlowEscrowClient { @@ -34,4 +34,75 @@ export class TrustFlowEscrowClient { async getEscrow(_escrowId: string): Promise> { return { ok: true, data: null }; // Fetch from contract storage } + + /** + * Returns a paginated list of gigs (escrows) from the TrustFlow backend. + * + * Pagination is cursor-based: each page includes a `nextCursor` value that + * you pass back as `cursor` on the next call to advance through results. + * When `nextCursor` is `null` (or `hasMore` is `false`) you have reached + * the last page. + * + * @param params - Optional filter and pagination parameters + * @param params.cursor - Opaque cursor from a previous response; omit to start from the first page + * @param params.limit - Records per page (default 20, max 100) + * @param params.status - Filter by escrow status + * @param params.depositor:2Filter by depositor address + * @param params.beneficiary - Filter by beneficiary address + * + * @returns `{ ok: true, data: GigsPage }` on success, `{ ok: false, error }` on failure + * + * @example + * ```typescript + * let cursor: string | undefined; + * do { + * const result = await client.getGigs({ cursor, limit: 20, status: 'active' }); + * if (!result.ok) { console.error(result.error); break; } + * console.log(result.data.data); + * cursor = result.data.nextCursor ?? undefined; + * } while (cursor); + * ``` + */ + async getGigs(params: GetGigsParams = {}): Promise> { + if (!this.contractConfig.apiBaseUrl) { + return { ok: false, error: 'apiBaseUrl is required to call getGigs' }; + } + + const query = new URLSearchParams(); + if (params.cursor) { + query.set('cursor', params.cursor); + } + if (params.limit) { + query.set('limit', String(Math.min(params.limit, 100))); + } + if (params.status) { + query.set('status', params.status); + } + if (params.depositor) { + query.set('depositor', params.depositor); + } + if (params.beneficiary) { + query.set('beneficiary', params.beneficiary); + } + + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.contractConfig.apiKey) { + headers['Authorization'] = `Bearer ${this.contractConfig.apiKey}`; + } + + let res: Response; + try { + res = await fetch(`${this.contractConfig.apiBaseUrl}/gigs?${query}`, { headers }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: `Network error: ${message}` }; + } + + if (!res.ok) { + return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` }; + } + + const json = await res.json(); + return { ok: true, data: json as GigsPage }; + } } diff --git a/src/types/contract.ts b/src/types/contract.ts index 5f81925..f798896 100644 --- a/src/types/contract.ts +++ b/src/types/contract.ts @@ -3,6 +3,8 @@ export interface ContractConfig { network: 'TESTNET' | 'MAINNET'; rpcUrl: string; networkPassphrase: string; + apiBaseUrl?: string; + apiKey?: string; } export interface InvokeContractParams { diff --git a/src/types/index.ts b/src/types/index.ts index f12e417..55870db 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,3 +26,35 @@ export interface DisputeParams { } export type SDKResult = { ok: true; data: T } | { ok: false; error: string }; + +/** + * Parameters for paginated gig listing. All fields are optional — + * omitting `cursor` starts from the most recent page. + */ +export interface GetGigsParams { + /** Opaque cursor returned by a previous `getGigs` call to fetch the next page. */ + cursor?: string; + /** Maximum records to return per page. Capped at 100 by the backend. Defaults to 20. */ + limit?: number; + /** Filter by escrow status. */ + status?: EscrowState['status']; + /** Filter by depositor Stellar address. */ + depositor?: StellarAddress; + /** Filter by beneficiary Stellar address. */ + beneficiary?: StellarAddress; +} + +/** + * A single page of gigs returned by `getGigs`. + */ +export interface GigsPage { + /** Gig records for this page. */ + data: EscrowState[]; + /** + * Cursor to pass as `cursor` on the next `getGigs` call. + * `null` when this is the last page. + */ + nextCursor: string | null; + /** Whether another page exists after this one. */ + hasMore: boolean; +} diff --git a/tests/gigs.test.ts b/tests/gigs.test.ts new file mode 100644 index 0000000..7eaf11d --- /dev/null +++ b/tests/gigs.test.ts @@ -0,0 +1,152 @@ +import { TrustFlowEscrowClient } from '../src/escrow/client'; +import type { GigsPage } from '../src/types/index'; + +const BASE_CONTRACT_CONFIG = { + contractId: 'C' + 'A'.repeat(55), + network: 'TESTNET' as const, + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', +}; + +const API_BASE = 'https://api.trustflow.xyz'; +const API_KEY = 'test-api-key'; + +const makePage = (overrides: Partial = {}): GigsPage => ({ + data: [], + nextCursor: null, + hasMore: false, + ...overrides, +}); + +describe('TrustFlowEscrowClient.getGigs', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('returns an error when apiBaseUrl is not configured', async () => { + const client = new TrustFlowEscrowClient(BASE_CONTRACT_CONFIG); + const result = await client.getGigs(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/apiBaseUrl/); + } + }); + + it('returns an empty first page when no gigs exist', async () => { + const page = makePage(); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(page), { status: 200 })); + + const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE, apiKey: API_KEY }); + const result = await client.getGigs({ limit: 20 }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data).toHaveLength(0); + expect(result.data.nextCursor).toBeNull(); + expect(result.data.hasMore).toBe(false); + } + }); + + it('sends cursor, limit, status, depositor and beneficiary as query params', async () => { + const page = makePage(); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(page), { status: 200 })); + + const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE }); + await client.getGigs({ + cursor: 'abc123', + limit: 10, + status: 'active', + depositor: 'G' + 'A'.repeat(55), + beneficiary: 'G' + 'B'.repeat(55), + }); + + const calledUrl = new URL((fetchSpy.mock.calls[0][0] as string)); + expect(calledUrl.searchParams.get('cursor')).toBe('abc123'); + expect(calledUrl.searchParams.get('limit')).toBe('10'); + expect(calledUrl.searchParams.get('status')).toBe('active'); + expect(calledUrl.searchParams.get('depositor')).toBe('G' + 'A'.repeat(55)); + expect(calledUrl.searchParams.get('beneficiary')).toBe('G' + 'B'.repeat(55)); + }); + + it('caps limit at 100 regardless of what the caller passes', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(makePage()), { status: 200 })); + + const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE }); + await client.getGigs({ limit: 500 }); + + const calledUrl = new URL((fetchSpy.mock.calls[0][0] as string)); + expect(calledUrl.searchParams.get('limit')).toBe('100'); + }); + + it('sends Authorization header when apiKey is configured', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(makePage()), { status: 200 })); + + const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE, apiKey: API_KEY }); + await client.getGigs(); + + const headers = fetchSpy.mock.calls[0][1]?.headers as Record; + expect(headers['Authorization']).toBe(`Bearer ${API_KEY}`); + }); + + it('traverses multiple pages using nextCursor', async () => { + const ADDR_A = 'G' + 'A'.repeat(55); + const page1: GigsPage = { + data: [{ id: 'esc-1', params: { depositor: ADDR_A, beneficiary: ADDR_A, amountXLM: '10' }, status: 'active', createdAt: 1 }], + nextCursor: 'cursor-page-2', + hasMore: true, + }; + const page2: GigsPage = { + data: [{ id: 'esc-2', params: { depositor: ADDR_A, beneficiary: ADDR_A, amountXLM: '20' }, status: 'pending', createdAt: 2 }], + nextCursor: null, + hasMore: false, + }; + + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify(page1), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(page2), { status: 200 })); + + const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE }); + + const result1 = await client.getGigs(); + expect(result1.ok).toBe(true); + if (!result1.ok) return; + expect(result1.data.nextCursor).toBe('cursor-page-2'); + + const result2 = await client.getGigs({ cursor: result1.data.nextCursor! }); + expect(result2.ok).toBe(true); + if (!result2.ok) return; + expect(result2.data.nextCursor).toBeNull(); + expect(result2.data.hasMore).toBe(false); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('returns an error on non-2xx HTTP response', async () => { + fetchSpy.mockResolvedValueOnce(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' })); + + const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE }); + const result = await client.getGigs(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/401/); + } + }); + + it('returns a network error when fetch throws', async () => { + fetchSpy.mockRejectedValueOnce(new TypeError('Failed to fetch')); + + const client = new TrustFlowEscrowClient({ ...BASE_CONTRACT_CONFIG, apiBaseUrl: API_BASE }); + const result = await client.getGigs(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/Network error/); + } + }); +});