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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions apps/api/scripts/load-test-pools.js
Original file line number Diff line number Diff line change
@@ -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;
});
25 changes: 13 additions & 12 deletions apps/api/src/app.smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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));
});
8 changes: 8 additions & 0 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()));

Expand Down
199 changes: 199 additions & 0 deletions apps/api/src/pools/pools.contract.spec.ts
Original file line number Diff line number Diff line change
@@ -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<PoolListItem>
* 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);
});
});
9 changes: 3 additions & 6 deletions apps/web/app/pools/page.tsx
Original file line number Diff line number Diff line change
@@ -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 ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<SortKey>('tvl');
const [page, setPage] = useState(1);

const debounceRef = useRef<ReturnType<typeof setTimeout> | 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({
Expand Down
Loading