diff --git a/apps/api/scripts/load-test-pools.js b/apps/api/scripts/load-test-pools.js new file mode 100644 index 0000000..7a7ba8a --- /dev/null +++ b/apps/api/scripts/load-test-pools.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * #424 — Load test: GET /v1/pools at scale + * + * Fires N concurrent requests against GET /v1/pools (default: 100) and + * reports p50 / p95 / p99 latencies, throughput (req/s), and error rate. + * + * Usage: + * node scripts/load-test-pools.js [concurrency] [total_requests] [base_url] + * + * Examples: + * node scripts/load-test-pools.js # defaults + * node scripts/load-test-pools.js 50 500 + * node scripts/load-test-pools.js 20 200 http://localhost:3001 + * + * No external dependencies — uses Node's built-in `fetch` (Node 18+). + */ + +const CONCURRENCY = parseInt(process.argv[2] ?? '10', 10); +const TOTAL = parseInt(process.argv[3] ?? '100', 10); +const BASE_URL = process.argv[4] ?? 'http://localhost:3001'; +const ENDPOINT = `${BASE_URL}/v1/pools`; + +/** Fire a single GET request and return { ok, statusCode, durationMs }. */ +async function singleRequest() { + const start = performance.now(); + try { + const res = await fetch(ENDPOINT); + return { ok: res.ok, statusCode: res.status, durationMs: performance.now() - start }; + } catch { + return { ok: false, statusCode: 0, durationMs: performance.now() - start }; + } +} + +/** Run `count` requests with up to `concurrency` in-flight at once. */ +async function runBatch(total, concurrency) { + const results = []; + let dispatched = 0; + const inFlight = new Set(); + + return new Promise((resolve) => { + function dispatch() { + while (inFlight.size < concurrency && dispatched < total) { + dispatched++; + const p = singleRequest().then((r) => { + inFlight.delete(p); + results.push(r); + if (results.length === total) resolve(results); + else dispatch(); + }); + inFlight.add(p); + } + } + dispatch(); + }); +} + +function percentile(sorted, p) { + const idx = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, idx)]; +} + +async function main() { + console.log(`\nLoad testing GET ${ENDPOINT}`); + console.log(`Concurrency: ${CONCURRENCY} | Total requests: ${TOTAL}\n`); + + const wallStart = performance.now(); + const results = await runBatch(TOTAL, CONCURRENCY); + const wallMs = performance.now() - wallStart; + + const durations = results.map((r) => r.durationMs).sort((a, b) => a - b); + const errors = results.filter((r) => !r.ok); + const throughput = (TOTAL / (wallMs / 1000)).toFixed(2); + + console.log('── Results ─────────────────────────────'); + console.log(` Total requests : ${TOTAL}`); + console.log(` Elapsed : ${wallMs.toFixed(0)} ms`); + console.log(` Throughput : ${throughput} req/s`); + console.log(` Errors : ${errors.length} (${((errors.length / TOTAL) * 100).toFixed(1)}%)`); + console.log(` p50 latency : ${percentile(durations, 50).toFixed(1)} ms`); + console.log(` p95 latency : ${percentile(durations, 95).toFixed(1)} ms`); + console.log(` p99 latency : ${percentile(durations, 99).toFixed(1)} ms`); + console.log(` Min latency : ${durations[0].toFixed(1)} ms`); + console.log(` Max latency : ${durations[durations.length - 1].toFixed(1)} ms`); + console.log('────────────────────────────────────────\n'); + + if (errors.length > 0) { + const codes = errors.reduce((acc, r) => { + acc[r.statusCode] = (acc[r.statusCode] ?? 0) + 1; + return acc; + }, {}); + console.warn('Error breakdown:', codes); + process.exitCode = 1; + } +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/apps/api/src/app.smoke.spec.ts b/apps/api/src/app.smoke.spec.ts index 76c9ebf..e387b13 100644 --- a/apps/api/src/app.smoke.spec.ts +++ b/apps/api/src/app.smoke.spec.ts @@ -104,6 +104,7 @@ describe('AppModule — public routes smoke test', () => { app = module.createNestApplication(); app.useGlobalPipes(new ValidationPipe({ transform: true })); + app.setGlobalPrefix('v1', { exclude: ['health', 'docs', 'docs-json', '/'] }); await app.init(); }); @@ -117,21 +118,21 @@ describe('AppModule — public routes smoke test', () => { it('GET /health returns 200', () => request(app.getHttpServer()).get('/health').expect(200)); - it('GET /pools returns 200', () => - request(app.getHttpServer()).get('/pools').expect(200)); + it('GET /v1/pools returns 200', () => + request(app.getHttpServer()).get('/v1/pools').expect(200)); - it('GET /swaps returns 200', () => - request(app.getHttpServer()).get('/swaps').expect(200)); + it('GET /v1/swaps returns 200', () => + request(app.getHttpServer()).get('/v1/swaps').expect(200)); - it('GET /tokens returns 200', () => - request(app.getHttpServer()).get('/tokens').expect(200)); + it('GET /v1/tokens returns 200', () => + request(app.getHttpServer()).get('/v1/tokens').expect(200)); - it('GET /search returns 200', () => - request(app.getHttpServer()).get('/search').expect(200)); + it('GET /v1/search returns 200', () => + request(app.getHttpServer()).get('/v1/search').expect(200)); - it('GET /indexer/status returns 200', () => - request(app.getHttpServer()).get('/indexer/status').expect(200)); + it('GET /v1/indexer/status returns 200', () => + request(app.getHttpServer()).get('/v1/indexer/status').expect(200)); - it('POST /auth/nonce returns 200 (no body)', () => - request(app.getHttpServer()).post('/auth/nonce').expect(200)); + it('POST /v1/auth/nonce returns 200 (no body)', () => + request(app.getHttpServer()).post('/v1/auth/nonce').expect(200)); }); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 03f6af0..ed679d7 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -28,6 +28,14 @@ async function bootstrap() { app.useWebSocketAdapter(new WsAdapter(app)); app.enableShutdownHooks(); + // Version all public REST routes under /v1 (e.g. GET /v1/pools). + // WebSocket, /health, and /docs remain at root — they are not affected + // because they are registered before the prefix takes effect or are + // excluded via NestJS route exclusion patterns. + app.setGlobalPrefix('v1', { + exclude: ['health', 'docs', 'docs-json', '/'], + }); + // Compression — applied globally, skips WebSocket and /health app.use(new CompressionMiddleware().use.bind(new CompressionMiddleware())); diff --git a/apps/api/src/pools/pools.contract.spec.ts b/apps/api/src/pools/pools.contract.spec.ts new file mode 100644 index 0000000..1351e4a --- /dev/null +++ b/apps/api/src/pools/pools.contract.spec.ts @@ -0,0 +1,199 @@ +/** + * #423 — Contract test: GET /v1/pools list schema + * + * Boots the full NestJS application with all external dependencies stubbed + * (Prisma, Redis, BullMQ) and asserts that GET /v1/pools returns a response + * whose JSON shape exactly matches the documented contract: + * + * { + * items: Array + * page: number + * limit: number + * total: number + * totalPages: number + * orderBy: string + * } + * + * Each PoolListItem must contain: id, token0, token1, feeTier, tvl, + * volume24h, feeApr, currentPrice. + */ + +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; + +// ── Stubs ──────────────────────────────────────────────────────────────────── + +const noop = jest.fn().mockResolvedValue(undefined); +const emptyList = jest.fn().mockResolvedValue([]); + +const poolRow = { + id: 'pool-contract-1', + token0Address: 'GUSDC000000000000000000000000000000000000000000000000', + token1Address: 'GXLM0000000000000000000000000000000000000000000000000', + feeTier: 30, + currentSqrtPrice: '1.118', + currentTick: 100, + liquidity: '500000', + tvl: '100000', + volume24h: '5000', + feeApr: '0.12', + currentPrice: '1.25', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + swaps: [], +}; + +const prismaMock = { + $connect: noop, + $disconnect: noop, + pool: { + findMany: jest.fn().mockResolvedValue([poolRow]), + findUnique: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(1), + update: noop, + }, + token: { findMany: emptyList, findUnique: jest.fn().mockResolvedValue(null) }, + swap: { findMany: emptyList, count: jest.fn().mockResolvedValue(0) }, + position: { findMany: emptyList, count: jest.fn().mockResolvedValue(0) }, + tick: { findMany: emptyList }, + webhook: { findMany: emptyList, create: noop, deleteMany: noop }, + apiKey: { findUnique: jest.fn().mockResolvedValue(null) }, + priceCandle: { findMany: emptyList }, + indexerCursor: { findUnique: jest.fn().mockResolvedValue(null) }, + poolCreated: { findMany: emptyList }, + swapProcessed: { findMany: emptyList }, +}; + +const redisMock = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + setex: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), + publish: jest.fn().mockResolvedValue(1), + subscribe: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + quit: jest.fn().mockResolvedValue(undefined), + duplicate: jest.fn(), +}; +redisMock.duplicate.mockReturnValue(redisMock); + +jest.mock('bullmq', () => ({ + Worker: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + client: Promise.resolve({ llen: jest.fn().mockResolvedValue(0) }), + })), + Queue: jest.fn().mockImplementation(() => ({ + add: jest.fn().mockResolvedValue({ id: '1' }), + close: jest.fn().mockResolvedValue(undefined), + })), + QueueEvents: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + })), + Job: jest.fn(), +})); + +jest.mock('@prisma/client', () => ({ + PrismaClient: jest.fn().mockImplementation(() => prismaMock), +})); + +jest.mock('ioredis', () => jest.fn().mockImplementation(() => redisMock)); + +// ── Imports (after mocks) ──────────────────────────────────────────────────── + +import { AppModule } from '../app.module'; +import { PrismaService } from '../prisma/prisma.service'; +import { CacheService } from '../cache/cache.service'; +import { REDIS_CLIENT } from '../redis/redis.constants'; + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe('GET /v1/pools — contract schema', () => { + let app: INestApplication; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(PrismaService) + .useValue(prismaMock) + .overrideProvider(CacheService) + .useValue({ + get: jest.fn().mockResolvedValue(null), + set: noop, + del: noop, + publish: noop, + setMaxNumber: jest.fn().mockResolvedValue(true), + invalidate: noop, + invalidatePattern: noop, + subscribe: jest.fn(), + }) + .overrideProvider(REDIS_CLIENT) + .useValue(redisMock) + .compile(); + + app = module.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ transform: true })); + app.setGlobalPrefix('v1', { exclude: ['health', 'docs', 'docs-json', '/'] }); + await app.init(); + }); + + afterAll(() => app.close()); + + it('returns 200 with the documented envelope shape', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/pools') + .expect(200); + + // Top-level envelope + expect(res.body).toMatchObject({ + items: expect.any(Array), + page: expect.any(Number), + limit: expect.any(Number), + total: expect.any(Number), + totalPages: expect.any(Number), + orderBy: expect.any(String), + }); + }); + + it('each item in items has the required PoolListItem fields', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/pools') + .expect(200); + + for (const item of res.body.items as unknown[]) { + expect(item).toMatchObject({ + id: expect.any(String), + token0: expect.any(String), + token1: expect.any(String), + feeTier: expect.any(String), + tvl: expect.any(Number), + volume24h: expect.any(Number), + feeApr: expect.any(Number), + currentPrice: expect.any(Number), + }); + } + }); + + it('respects the ?search query param without breaking the schema', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/pools?search=USDC') + .expect(200); + + expect(res.body).toHaveProperty('items'); + expect(Array.isArray(res.body.items)).toBe(true); + }); + + it('returns an empty items array (not null) when no pools match', async () => { + prismaMock.pool.findMany.mockResolvedValueOnce([]); + + const res = await request(app.getHttpServer()) + .get('/v1/pools?search=NOMATCH') + .expect(200); + + expect(res.body.items).toEqual([]); + expect(res.body.total).toBe(0); + }); +}); diff --git a/apps/web/app/pools/page.tsx b/apps/web/app/pools/page.tsx index eecc442..bc4cde0 100644 --- a/apps/web/app/pools/page.tsx +++ b/apps/web/app/pools/page.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { TokenLogo } from '@swyft/ui'; import { usePools, PoolOrderBy, PoolListItem } from '@/hooks/usePools'; +import { useSearchDebounce } from '@/hooks/useSearchDebounce'; import type { Token } from '@swyft/ui'; // ─── Formatters ─────────────────────────────────────────────────────────────── @@ -167,17 +168,13 @@ function PoolRow({ pool, onNavigate }: { pool: PoolListItem; onNavigate: (path: export default function PoolsPage() { const router = useRouter(); const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); + const debouncedSearch = useSearchDebounce(search); const [sortKey, setSortKey] = useState('tvl'); const [page, setPage] = useState(1); - const debounceRef = useRef | null>(null); - const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => setDebouncedSearch(value), 300); }, []); const { data, isLoading, isError } = usePools({ diff --git a/apps/web/hooks/useSearchDebounce.ts b/apps/web/hooks/useSearchDebounce.ts new file mode 100644 index 0000000..66ac831 --- /dev/null +++ b/apps/web/hooks/useSearchDebounce.ts @@ -0,0 +1,23 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +/** + * Debounces a search string so that rapid keystrokes do not trigger an API + * call on every character. The returned `debouncedValue` only updates after + * the caller has stopped typing for `delay` milliseconds (default 300 ms). + * + * Usage: + * const debouncedSearch = useSearchDebounce(rawInput); + * // pass debouncedSearch to usePools / useTokens instead of rawInput + */ +export function useSearchDebounce(value: string, delay = 300): string { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index f958ebf..4d24f7b 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -6,7 +6,7 @@ export const SWYFT_NETWORK_PASSPHRASE = ? 'Public Global Stellar Network ; September 2015' : 'Test SDF Network ; September 2015'; export const WALLET_STORAGE_KEY = 'swyft_wallet_address'; -export const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'; +export const API_BASE = `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'}/v1`; export function explorerTxUrl(hash: string): string { return SWYFT_NETWORK === 'PUBLIC'