From d397e5efe2f8edbb5289a5c5966e83f2cad5cba3 Mon Sep 17 00:00:00 2001 From: Naajih siraj Date: Sat, 17 Oct 2026 15:37:59 +0100 Subject: [PATCH 1/5] Add PoolFee interface and getPoolFee function --- src/pricing/pool-fee.ts | 80 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/pricing/pool-fee.ts diff --git a/src/pricing/pool-fee.ts b/src/pricing/pool-fee.ts new file mode 100644 index 00000000..4cf9c06f --- /dev/null +++ b/src/pricing/pool-fee.ts @@ -0,0 +1,80 @@ +export interface PoolFee { + poolId: string + feeBp: number + /** ISO timestamp of when the venue was read. */ + feeReadAt: string +} + +export type PoolFeeResult = + | { ok: true; fee: PoolFee; cached: boolean } + | { ok: false; poolId: string; reason: string } + +export interface PoolFeeOptions { + /** Horizon base URL for the network being answered, e.g. https://horizon-testnet.stellar.org */ + horizonUrl: string + /** Cache lifetime in milliseconds. Placeholder default; confirm before merge. */ + ttlMs?: number + /** Injectable clock for tests. */ + now?: () => number + /** Injectable fetch for tests. */ + fetchImpl?: typeof fetch +} + +const DEFAULT_TTL_MS = 10 * 60 * 1000 + +interface CacheEntry { + fee: PoolFee + expiresAt: number +} + +// Keyed by network and pool, so testnet and mainnet never share a fee. +const cache = new Map() + +export function clearPoolFeeCache(): void { + cache.clear() +} + +/** + * Reads a pool's fee from the venue (Horizon), with a TTL cache. + * + * Failures are never cached, so a transient outage is retried on the next call. + * A caller that receives { ok: false } must exclude the pool from execution + * estimates and report the reason. It must not fall back to a default fee. + */ +export async function getPoolFee( + network: string, + poolId: string, + opts: PoolFeeOptions, +): Promise { + const now = opts.now ?? Date.now + const ttl = opts.ttlMs ?? DEFAULT_TTL_MS + const key = `${network}:${poolId}` + + const hit = cache.get(key) + if (hit && hit.expiresAt > now()) { + return { ok: true, fee: hit.fee, cached: true } + } + + const fetchImpl = opts.fetchImpl ?? fetch + let body: unknown + try { + const res = await fetchImpl( + `${opts.horizonUrl}/liquidity_pools/${encodeURIComponent(poolId)}`, + ) + if (!res.ok) { + return { ok: false, poolId, reason: `venue responded ${res.status}` } + } + body = await res.json() + } catch (err) { + return { ok: false, poolId, reason: `venue unreachable: ${(err as Error).message}` } + } + + const feeBp = (body as { fee_bp?: unknown } | null)?.fee_bp + if (typeof feeBp !== 'number' || !Number.isInteger(feeBp) || feeBp < 0 || feeBp >= 10000) { + return { ok: false, poolId, reason: 'venue did not return a valid fee_bp' } + } + + const fee: PoolFee = { poolId, feeBp, feeReadAt: new Date(now()).toISOString() } + cache.set(key, { fee, expiresAt: now() + ttl }) + return { ok: true, fee, cached: false } +} From ffd04b99bbfa8666bc7e35f55b5639f78321694a Mon Sep 17 00:00:00 2001 From: Naajih siraj Date: Sat, 17 Oct 2026 15:38:38 +0100 Subject: [PATCH 2/5] Enhance depth.ts with excluded pools and depth outcomes Added interfaces for excluded pools and depth outcomes, updated depth result structure, and modified getDepth function to handle excluded pools. --- src/pricing/depth.ts | 175 ++++++++++++++++++++++++++++++++----------- 1 file changed, 131 insertions(+), 44 deletions(-) diff --git a/src/pricing/depth.ts b/src/pricing/depth.ts index ca35f8d7..eb397ed0 100644 --- a/src/pricing/depth.ts +++ b/src/pricing/depth.ts @@ -1,19 +1,45 @@ import { pgPool } from '../db' +import { getPoolFee, PoolFeeOptions } from './pool-fee' export interface DepthLevel { price: number size: number } +export interface ExcludedPool { + poolId: string + reason: string +} + export interface DepthResult { + status: 'ok' + network: string + pairKey: string spotPrice: number executionPrice: number slippagePct: number asks: DepthLevel[] bids: DepthLevel[] - source: 'AMM' | 'SDEX' | 'BOTH' + source: 'AMM' + pool: { + poolId: string + feeBp: number + feeReadAt: string + reservesObservedAt: string + } + excludedPools: ExcludedPool[] +} + +export interface DepthUnavailable { + status: 'unavailable' + network: string + pairKey: string + reason: string + excludedPools: ExcludedPool[] } +export type DepthOutcome = DepthResult | DepthUnavailable + export function calculateAMMOutput(reserveA: number, reserveB: number, amount: number, feeBp: number): number { const fee = 1 - feeBp / 10000 const effectiveInput = amount * fee @@ -34,27 +60,24 @@ export function generateAMMDepthLevels( reserveB: number, feeBp: number, numLevels: number = 10, - stepSize: number = 0.01 + stepSize: number = 0.01, ): { asks: DepthLevel[]; bids: DepthLevel[] } { const asks: DepthLevel[] = [] const bids: DepthLevel[] = [] - // Generate asks (selling asset A for B) — price is B per A + // Asks: selling asset A for B. Price is B per A. for (let i = 1; i <= numLevels; i++) { const amountA = reserveA * stepSize * i const price = calculateAMMPrice(reserveA, reserveB, amountA, feeBp) asks.push({ price, size: amountA }) } - // Generate bids (buying asset A with B) — price is A per B, but we want B per A (inverse) + // Bids: buying asset A with B. Price is B paid per A received. for (let i = 1; i <= numLevels; i++) { const amountB = reserveB * stepSize * i const fee = 1 - feeBp / 10000 const effectiveInput = amountB * fee const amountA = (reserveA * effectiveInput) / (reserveB + effectiveInput) - // Price is amount of A we get per B → but we usually display bids as amount of B per A (inverse) - // Wait no: actually for pair A/B, bids are orders to buy A with B, so the price is how much B you pay per A - // So price = amountB / amountA const price = amountB / amountA bids.push({ price, size: amountA }) } @@ -62,53 +85,117 @@ export function generateAMMDepthLevels( return { asks, bids } } -export async function getAMMDepth(pairKey: string, amount: number): Promise { +interface AmmPoolRow { + poolId: string + reserveA: number + reserveB: number + observedAt: Date +} + +/** Latest reserve snapshot per AMM pool for the pair, on one network only. */ +async function loadLatestAmmPools(network: string, pairKey: string): Promise { const result = await pgPool.query( - `SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp + `SELECT DISTINCT ON (ps.pool_id) ps.pool_id, ps.reserve_a, ps.reserve_b, ps.timestamp FROM pool_snapshots ps - WHERE ps.pool_id IN ( - SELECT DISTINCT pool_id FROM price_points - WHERE pair_key = $1 AND source = 'AMM' AND pool_id IS NOT NULL - ) - ORDER BY ps.pool_id, ps.timestamp DESC - LIMIT 1`, - [pairKey] + WHERE ps.network = $2 + AND ps.pool_id IN ( + SELECT DISTINCT pool_id FROM price_points + WHERE pair_key = $1 AND network = $2 AND source = 'AMM' AND pool_id IS NOT NULL + ) + ORDER BY ps.pool_id, ps.timestamp DESC`, + [pairKey, network], ) - if (!result.rows[0]) return null + return result.rows + .map((r: { pool_id: string; reserve_a: string; reserve_b: string; timestamp: Date }) => ({ + poolId: r.pool_id, + reserveA: parseFloat(r.reserve_a), + reserveB: parseFloat(r.reserve_b), + observedAt: r.timestamp, + })) + .sort((a, b) => (a.poolId < b.poolId ? -1 : a.poolId > b.poolId ? 1 : 0)) +} - const { reserve_a, reserve_b, fee_bp } = result.rows[0] - const rA = parseFloat(reserve_a) - const rB = parseFloat(reserve_b) - const feeBp = parseInt(fee_bp) +/** + * Prices an AMM swap using each pool's own fee, read from the venue. + * + * Pools whose fee cannot be read, or whose reserves are empty, are excluded and + * listed with the reason. If no pool survives, the result is "unavailable" with + * no numbers, never zeros. + */ +export async function getDepth( + network: string, + pairKey: string, + amount: number, + feeOpts: PoolFeeOptions, +): Promise { + if (!(amount > 0)) { + throw new RangeError('amount must be greater than 0') + } - const spotPrice = calculateAMMSpotPrice(rA, rB) - const executionPrice = calculateAMMPrice(rA, rB, amount, feeBp) - const slippagePct = spotPrice > 0 ? Math.abs((executionPrice - spotPrice) / spotPrice * 100) : 0 - const { asks, bids } = generateAMMDepthLevels(rA, rB, feeBp) + const pools = await loadLatestAmmPools(network, pairKey) + const excludedPools: ExcludedPool[] = [] - return { - spotPrice, - executionPrice, - slippagePct, - asks, - bids, - source: 'AMM', + if (pools.length === 0) { + return { + status: 'unavailable', + network, + pairKey, + reason: 'no AMM pool snapshots for this pair on this network', + excludedPools, + } + } + + let chosen: DepthResult | null = null + + for (const pool of pools) { + const feeRes = await getPoolFee(network, pool.poolId, feeOpts) + if (!feeRes.ok) { + excludedPools.push({ poolId: pool.poolId, reason: feeRes.reason }) + continue + } + if (!(pool.reserveA > 0 && pool.reserveB > 0)) { + excludedPools.push({ poolId: pool.poolId, reason: 'pool has no reserves in its latest snapshot' }) + continue + } + if (chosen) continue // keep checking fees so every excluded pool is reported + + const { feeBp, feeReadAt } = feeRes.fee + const spotPrice = calculateAMMSpotPrice(pool.reserveA, pool.reserveB) + const executionPrice = calculateAMMPrice(pool.reserveA, pool.reserveB, amount, feeBp) + const slippagePct = + spotPrice > 0 ? Math.abs(((executionPrice - spotPrice) / spotPrice) * 100) : 0 + const { asks, bids } = generateAMMDepthLevels(pool.reserveA, pool.reserveB, feeBp) + + chosen = { + status: 'ok', + network, + pairKey, + spotPrice, + executionPrice, + slippagePct, + asks, + bids, + source: 'AMM', + pool: { + poolId: pool.poolId, + feeBp, + feeReadAt, + reservesObservedAt: pool.observedAt.toISOString(), + }, + excludedPools, + } } -} -export async function getDepth(pairKey: string, amount: number): Promise { - const ammDepth = await getAMMDepth(pairKey, amount) - - if (ammDepth) { - return ammDepth + if (chosen) { + chosen.excludedPools = excludedPools + return chosen } return { - spotPrice: 0, - executionPrice: 0, - slippagePct: 0, - asks: [], - bids: [], - source: 'AMM', + status: 'unavailable', + network, + pairKey, + reason: 'every AMM pool for this pair was excluded', + excludedPools, } } From 2802760de2575bce5b7fd60e52496df4a723e0bf Mon Sep 17 00:00:00 2001 From: Naajih siraj Date: Sat, 17 Oct 2026 15:39:37 +0100 Subject: [PATCH 3/5] Add tests for getPoolFee function --- tests/pool-fee.test.ts | 85 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/pool-fee.test.ts diff --git a/tests/pool-fee.test.ts b/tests/pool-fee.test.ts new file mode 100644 index 00000000..0bc7c66e --- /dev/null +++ b/tests/pool-fee.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { getPoolFee, clearPoolFeeCache } from '../src/pricing/pool-fee' + +const HORIZON = 'https://horizon.example.test' + +function fakeFetch(body: unknown, status = 200) { + const calls: string[] = [] + const fn = (async (url: string) => { + calls.push(url) + return new Response(JSON.stringify(body), { status }) + }) as unknown as typeof fetch + return { fn, calls } +} + +describe('getPoolFee', () => { + beforeEach(() => clearPoolFeeCache()) + + it('reads fee_bp from the venue and caches it', async () => { + let t = 0 + const { fn, calls } = fakeFetch({ id: 'p1', fee_bp: 50 }) + const opts = { horizonUrl: HORIZON, now: () => t, fetchImpl: fn } + + const first = await getPoolFee('testnet', 'p1', opts) + const second = await getPoolFee('testnet', 'p1', opts) + + expect(first.ok && first.fee.feeBp).toBe(50) + expect(first.ok && first.cached).toBe(false) + expect(second.ok && second.cached).toBe(true) + expect(calls).toHaveLength(1) + }) + + it('refetches after the TTL expires', async () => { + let t = 0 + const { fn, calls } = fakeFetch({ id: 'p1', fee_bp: 30 }) + const opts = { horizonUrl: HORIZON, ttlMs: 1000, now: () => t, fetchImpl: fn } + + await getPoolFee('testnet', 'p1', opts) + t = 1001 + await getPoolFee('testnet', 'p1', opts) + + expect(calls).toHaveLength(2) + }) + + it('does not cache a failed read, so the next call retries', async () => { + const bad = fakeFetch({ id: 'p1' }) // no fee_bp + const good = fakeFetch({ id: 'p1', fee_bp: 30 }) + const base = { horizonUrl: HORIZON, now: () => 0 } + + const first = await getPoolFee('testnet', 'p1', { ...base, fetchImpl: bad.fn }) + expect(first.ok).toBe(false) + + const second = await getPoolFee('testnet', 'p1', { ...base, fetchImpl: good.fn }) + expect(second.ok && second.fee.feeBp).toBe(30) + }) + + it('rejects a missing, non-integer or out-of-range fee', async () => { + const base = { horizonUrl: HORIZON, now: () => 0 } + for (const body of [{}, { fee_bp: 30.5 }, { fee_bp: -1 }, { fee_bp: 10000 }, { fee_bp: '30' }]) { + clearPoolFeeCache() + const r = await getPoolFee('testnet', 'p1', { ...base, fetchImpl: fakeFetch(body).fn }) + expect(r.ok, JSON.stringify(body)).toBe(false) + } + }) + + it('reports venue HTTP errors as a reason, not a throw', async () => { + const r = await getPoolFee('testnet', 'p1', { + horizonUrl: HORIZON, + now: () => 0, + fetchImpl: fakeFetch({}, 404).fn, + }) + expect(r).toEqual({ ok: false, poolId: 'p1', reason: 'venue responded 404' }) + }) + + it('keeps networks apart in the cache', async () => { + const testnet = fakeFetch({ fee_bp: 30 }) + const mainnet = fakeFetch({ fee_bp: 50 }) + const base = { horizonUrl: HORIZON, now: () => 0 } + + await getPoolFee('testnet', 'p1', { ...base, fetchImpl: testnet.fn }) + const m = await getPoolFee('mainnet', 'p1', { ...base, fetchImpl: mainnet.fn }) + + expect(m.ok && m.fee.feeBp).toBe(50) + expect(mainnet.calls).toHaveLength(1) + }) +}) From ea8056fbd547e41630f87ad67028558d58173c83 Mon Sep 17 00:00:00 2001 From: Naajih siraj Date: Sat, 17 Oct 2026 15:40:11 +0100 Subject: [PATCH 4/5] Create depth.test.ts --- tests/depth.test.ts | 108 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/depth.test.ts diff --git a/tests/depth.test.ts b/tests/depth.test.ts new file mode 100644 index 00000000..64c9739c --- /dev/null +++ b/tests/depth.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/db', () => ({ pgPool: { query: vi.fn() } })) + +import { pgPool } from '../src/db' +import { getDepth, calculateAMMPrice } from '../src/pricing/depth' +import { clearPoolFeeCache } from '../src/pricing/pool-fee' + +const query = pgPool.query as unknown as ReturnType +const HORIZON = 'https://horizon.example.test' +const ts = new Date('2026-10-17T10:00:00Z') +const PAIR = 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM' + +/** Venue stub: fee per pool id, or 'error' to simulate an unreadable fee. */ +function venue(fees: Record) { + return (async (url: string) => { + const poolId = url.split('/').pop()! + const fee = fees[poolId] + if (fee === undefined || fee === 'error') { + return new Response('not found', { status: 404 }) + } + return new Response(JSON.stringify({ id: poolId, fee_bp: fee }), { status: 200 }) + }) as unknown as typeof fetch +} + +function poolRows(...ids: string[]) { + return { rows: ids.map((id) => ({ pool_id: id, reserve_a: '1000', reserve_b: '120', timestamp: ts })) } +} + +beforeEach(() => { + clearPoolFeeCache() + query.mockReset() +}) + +describe('getDepth', () => { + it('prices with the venue fee, not a constant', async () => { + query.mockResolvedValue(poolRows('p1')) + + const r = await getDepth('testnet', PAIR, 10, { + horizonUrl: HORIZON, + fetchImpl: venue({ p1: 50 }), + }) + + expect(r.status).toBe('ok') + if (r.status !== 'ok') return + expect(r.pool.feeBp).toBe(50) + expect(r.executionPrice).toBeCloseTo(calculateAMMPrice(1000, 120, 10, 50), 12) + expect(r.excludedPools).toEqual([]) + }) + + it('excludes a pool with an unreadable fee and uses the next readable one', async () => { + query.mockResolvedValue(poolRows('p1', 'p2')) + + const r = await getDepth('testnet', PAIR, 10, { + horizonUrl: HORIZON, + fetchImpl: venue({ p1: 'error', p2: 30 }), + }) + + expect(r.status).toBe('ok') + if (r.status !== 'ok') return + expect(r.pool.poolId).toBe('p2') + expect(r.excludedPools).toHaveLength(1) + expect(r.excludedPools[0].poolId).toBe('p1') + expect(r.excludedPools[0].reason).toMatch(/venue responded 404/) + }) + + it('returns unavailable with no numbers when every pool is excluded', async () => { + query.mockResolvedValue(poolRows('p1')) + + const r = await getDepth('testnet', PAIR, 10, { + horizonUrl: HORIZON, + fetchImpl: venue({ p1: 'error' }), + }) + + expect(r.status).toBe('unavailable') + expect(r).not.toHaveProperty('spotPrice') + expect(r).not.toHaveProperty('executionPrice') + expect(r.excludedPools).toHaveLength(1) + }) + + it('reports unavailable when the pair has no AMM pools on this network', async () => { + query.mockResolvedValue({ rows: [] }) + + const r = await getDepth('mainnet', PAIR, 10, { + horizonUrl: HORIZON, + fetchImpl: venue({}), + }) + + expect(r.status).toBe('unavailable') + expect(r.network).toBe('mainnet') + }) + + it('scopes the pool query to the requested network', async () => { + query.mockResolvedValue({ rows: [] }) + + await getDepth('mainnet', PAIR, 10, { horizonUrl: HORIZON, fetchImpl: venue({}) }) + + const params = query.mock.calls[0][1] as unknown[] + expect(params).toContain('mainnet') + expect(params).toContain(PAIR) + }) + + it('rejects a non-positive amount', async () => { + await expect( + getDepth('testnet', PAIR, 0, { horizonUrl: HORIZON, fetchImpl: venue({}) }), + ).rejects.toThrow(RangeError) + }) +}) From 4591086138a2fee3af516eb06455f8180b08761d Mon Sep 17 00:00:00 2001 From: Naajih siraj Date: Mon, 19 Oct 2026 19:47:36 +0100 Subject: [PATCH 5/5] Refactor tests for depth functionality with new structure --- tests/depth.test.ts | 168 +++++++++++++++++++++++++++++--------------- 1 file changed, 110 insertions(+), 58 deletions(-) diff --git a/tests/depth.test.ts b/tests/depth.test.ts index 64c9739c..b118bee3 100644 --- a/tests/depth.test.ts +++ b/tests/depth.test.ts @@ -1,108 +1,160 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest' + +// ── Mocks ──────────────────────────────────────────────────────────────────── +// Path guesses: confirm these match your repo. vi.mock('../src/db', () => ({ pgPool: { query: vi.fn() } })) +vi.mock('../src/config', () => ({ + activeNetwork: 'testnet', + getNetworkConfig: () => ({ horizon: { url: 'https://horizon.example.test' } }), +})) + +vi.mock('../src/pairs', () => ({ + findPair: (assetA: string, assetB: string) => { + if (assetA === 'XLM' && assetB === 'USDC') { + return { + pairKey: 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM', + assetA: { code: 'XLM' }, + assetB: { code: 'USDC' }, + } + } + return undefined + }, +})) + +// ── Imports (after mocks) ──────────────────────────────────────────────────── + import { pgPool } from '../src/db' -import { getDepth, calculateAMMPrice } from '../src/pricing/depth' +import { buildApp } from '../src/app' +import { calculateAMMPrice } from '../src/pricing/depth' import { clearPoolFeeCache } from '../src/pricing/pool-fee' const query = pgPool.query as unknown as ReturnType -const HORIZON = 'https://horizon.example.test' const ts = new Date('2026-10-17T10:00:00Z') -const PAIR = 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM' /** Venue stub: fee per pool id, or 'error' to simulate an unreadable fee. */ function venue(fees: Record) { - return (async (url: string) => { - const poolId = url.split('/').pop()! + return async (url: string | URL | Request) => { + const poolId = String(url).split('/').pop()! const fee = fees[poolId] if (fee === undefined || fee === 'error') { return new Response('not found', { status: 404 }) } return new Response(JSON.stringify({ id: poolId, fee_bp: fee }), { status: 200 }) - }) as unknown as typeof fetch + } } function poolRows(...ids: string[]) { return { rows: ids.map((id) => ({ pool_id: id, reserve_a: '1000', reserve_b: '120', timestamp: ts })) } } -beforeEach(() => { +// ── Tests ──────────────────────────────────────────────────────────────────── + +let app: Awaited> + +beforeEach(async () => { clearPoolFeeCache() query.mockReset() + app = await buildApp() }) -describe('getDepth', () => { - it('prices with the venue fee, not a constant', async () => { - query.mockResolvedValue(poolRows('p1')) +afterEach(() => { + vi.unstubAllGlobals() +}) - const r = await getDepth('testnet', PAIR, 10, { - horizonUrl: HORIZON, - fetchImpl: venue({ p1: 50 }), - }) +afterAll(async () => { + await app?.close() +}) - expect(r.status).toBe('ok') - if (r.status !== 'ok') return - expect(r.pool.feeBp).toBe(50) - expect(r.executionPrice).toBeCloseTo(calculateAMMPrice(1000, 120, 10, 50), 12) - expect(r.excludedPools).toEqual([]) +describe('GET /price/:assetA/:assetB/depth', () => { + it('200: prices with the venue fee and reports the pool that was used', async () => { + query.mockResolvedValue(poolRows('p1')) + vi.stubGlobal('fetch', venue({ p1: 50 })) + + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) + const body = res.json() + + expect(res.statusCode).toBe(200) + expect(body.status).toBe('ok') + expect(body.network).toBe('testnet') + expect(body.assetA).toBe('XLM') + expect(body.assetB).toBe('USDC') + expect(body.pool.poolId).toBe('p1') + expect(body.pool.feeBp).toBe(50) + expect(body.executionPrice).toBeCloseTo(calculateAMMPrice(1000, 120, 10, 50), 12) + expect(body.excludedPools).toEqual([]) }) - it('excludes a pool with an unreadable fee and uses the next readable one', async () => { + it('200: skips a pool with an unreadable fee and lists it as excluded', async () => { query.mockResolvedValue(poolRows('p1', 'p2')) + vi.stubGlobal('fetch', venue({ p1: 'error', p2: 30 })) + + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) + const body = res.json() - const r = await getDepth('testnet', PAIR, 10, { - horizonUrl: HORIZON, - fetchImpl: venue({ p1: 'error', p2: 30 }), - }) - - expect(r.status).toBe('ok') - if (r.status !== 'ok') return - expect(r.pool.poolId).toBe('p2') - expect(r.excludedPools).toHaveLength(1) - expect(r.excludedPools[0].poolId).toBe('p1') - expect(r.excludedPools[0].reason).toMatch(/venue responded 404/) + expect(res.statusCode).toBe(200) + expect(body.pool.poolId).toBe('p2') + expect(body.excludedPools).toHaveLength(1) + expect(body.excludedPools[0].poolId).toBe('p1') + expect(body.excludedPools[0].reason).toMatch(/venue responded 404/) }) - it('returns unavailable with no numbers when every pool is excluded', async () => { + it('503: returns unavailable with no numbers when every pool is excluded', async () => { query.mockResolvedValue(poolRows('p1')) + vi.stubGlobal('fetch', venue({ p1: 'error' })) - const r = await getDepth('testnet', PAIR, 10, { - horizonUrl: HORIZON, - fetchImpl: venue({ p1: 'error' }), - }) + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) + const body = res.json() - expect(r.status).toBe('unavailable') - expect(r).not.toHaveProperty('spotPrice') - expect(r).not.toHaveProperty('executionPrice') - expect(r.excludedPools).toHaveLength(1) + expect(res.statusCode).toBe(503) + expect(body.status).toBe('unavailable') + expect(body.reason).toBe('every AMM pool for this pair was excluded') + expect(body).not.toHaveProperty('spotPrice') + expect(body).not.toHaveProperty('executionPrice') + expect(body.excludedPools).toHaveLength(1) }) - it('reports unavailable when the pair has no AMM pools on this network', async () => { + it('503: returns unavailable when the pair has no AMM pool snapshots on this network', async () => { query.mockResolvedValue({ rows: [] }) + vi.stubGlobal('fetch', venue({})) - const r = await getDepth('mainnet', PAIR, 10, { - horizonUrl: HORIZON, - fetchImpl: venue({}), - }) + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) - expect(r.status).toBe('unavailable') - expect(r.network).toBe('mainnet') + expect(res.statusCode).toBe(503) + expect(res.json().status).toBe('unavailable') + expect(res.json().excludedPools).toEqual([]) }) - it('scopes the pool query to the requested network', async () => { - query.mockResolvedValue({ rows: [] }) + it('404: returns not found for a pair that is not watched', async () => { + const res = await app.inject({ method: 'GET', url: '/price/ABC/XYZ/depth?amount=10' }) - await getDepth('mainnet', PAIR, 10, { horizonUrl: HORIZON, fetchImpl: venue({}) }) + expect(res.statusCode).toBe(404) + expect(res.json().error).toMatch(/not watched/) + expect(query).not.toHaveBeenCalled() + }) - const params = query.mock.calls[0][1] as unknown[] - expect(params).toContain('mainnet') - expect(params).toContain(PAIR) + it.each([ + ['zero', '0'], + ['negative', '-5'], + ['non-numeric', 'abc'], + ['trailing junk', '1000abc'], + ['empty', ''], + ])('400: rejects an amount that is %s', async (_label, amount) => { + const res = await app.inject({ method: 'GET', url: `/price/XLM/USDC/depth?amount=${amount}` }) + + expect(res.statusCode).toBe(400) + expect(res.json().error).toBe('amount must be a positive number') + expect(query).not.toHaveBeenCalled() }) - it('rejects a non-positive amount', async () => { - await expect( - getDepth('testnet', PAIR, 0, { horizonUrl: HORIZON, fetchImpl: venue({}) }), - ).rejects.toThrow(RangeError) + it('defaults to the active network when none is given', async () => { + query.mockResolvedValue({ rows: [] }) + vi.stubGlobal('fetch', venue({})) + + await app.inject({ method: 'GET', url: '/price/XLM/USDC/depth?amount=10' }) + + const params = query.mock.calls[0][1] as unknown[] + expect(params).toContain('testnet') }) })