/**
* API Integration Tests
*
* These test the API validation and response format logic without
* requiring a live Binance connection. We spin up a minimal Fastify
* instance that replicates the key route validation patterns.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const Fastify = require('../server/node_modules/fastify')
const VALID_INTERVALS = ['1m','3m','5m','15m','30m','1h','2h','4h','6h','8h','12h','1d','3d','1w','1M']
let app
beforeAll(async () => {
app = Fastify()
// Replicate /depth/:symbol validation
app.get('/depth/:symbol', async (req, reply) => {
const symbol = String(req.params.symbol || '').toUpperCase()
if (!/^[A-Z0-9]{2,20}$/.test(symbol)) {
reply.code(400)
return { error: 'Invalid symbol format' }
}
return { symbol, bids: [], asks: [] }
})
// Replicate /api/klines validation
app.get('/api/klines', async (req, reply) => {
const symbol = String(req.query.symbol || '').toUpperCase()
const interval = String(req.query.interval || '15m')
const limit = Math.min(Number(req.query.limit || 200), 1500)
if (!symbol || !/^[A-Z0-9]{2,20}$/.test(symbol)) {
reply.code(400)
return { error: 'Invalid or missing symbol' }
}
if (!VALID_INTERVALS.includes(interval)) {
reply.code(400)
return { error: 'Invalid interval' }
}
return { success: true, data: { symbol, interval, limit, candles: [] } }
})
// Replicate /api/klines-batch validation
app.post('/api/klines-batch', async (req, reply) => {
const symbols = req.body?.symbols
const interval = String(req.body?.interval || '15m')
const limit = Math.min(Number(req.body?.limit || 200), 1500)
if (!Array.isArray(symbols) || symbols.length === 0) {
reply.code(400)
return { error: 'symbols[] required' }
}
if (!VALID_INTERVALS.includes(interval)) {
reply.code(400)
return { error: 'Invalid interval' }
}
const syms = symbols.slice(0, 30)
.map(s => String(s).toUpperCase())
.filter(s => /^[A-Z0-9]{2,20}$/.test(s))
return { success: true, data: { symbols: syms, interval, limit } }
})
// Replicate /api/signals/history validation
app.get('/api/signals/history', async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit || 100), 1), 500)
const offset = Math.max(Number(req.query.offset || 0), 0)
const type = req.query.type || null
const symbol = req.query.symbol ? String(req.query.symbol).toUpperCase() : null
return { success: true, data: { limit, offset, type, symbol, signals: [] } }
})
// Replicate /api/rate-limiter (always works)
app.get('/api/rate-limiter', async () => ({
usedWeight: 100,
softCap: 1800,
hardCap: 2200,
status: 'OK',
}))
await app.ready()
})
afterAll(async () => {
await app.close()
})
// ---- /depth/:symbol ----
describe('GET /depth/:symbol', () => {
it('validates symbol format', async () => {
const res = await app.inject({ method: 'GET', url: '/depth/valid_symbol!' })
expect(res.statusCode).toBe(400)
expect(res.json().error).toBe('Invalid symbol format')
})
it('rejects too-short symbol', async () => {
const res = await app.inject({ method: 'GET', url: '/depth/X' })
expect(res.statusCode).toBe(400)
})
it('rejects too-long symbol', async () => {
const res = await app.inject({ method: 'GET', url: '/depth/ABCDEFGHIJKLMNOPQRSTUVWXYZ' })
expect(res.statusCode).toBe(400)
})
it('accepts valid symbol', async () => {
const res = await app.inject({ method: 'GET', url: '/depth/BTCUSDT' })
expect(res.statusCode).toBe(200)
expect(res.json().symbol).toBe('BTCUSDT')
})
it('uppercases symbol', async () => {
const res = await app.inject({ method: 'GET', url: '/depth/btcusdt' })
expect(res.statusCode).toBe(200)
expect(res.json().symbol).toBe('BTCUSDT')
})
})
// ---- /api/klines ----
describe('GET /api/klines', () => {
it('rejects missing symbol', async () => {
const res = await app.inject({ method: 'GET', url: '/api/klines' })
expect(res.statusCode).toBe(400)
expect(res.json().error).toContain('symbol')
})
it('rejects invalid symbol format', async () => {
const res = await app.inject({ method: 'GET', url: '/api/klines?symbol=BTC/USDT' })
expect(res.statusCode).toBe(400)
})
it('rejects invalid interval', async () => {
const res = await app.inject({ method: 'GET', url: '/api/klines?symbol=BTCUSDT&interval=7m' })
expect(res.statusCode).toBe(400)
expect(res.json().error).toContain('interval')
})
it('accepts valid request', async () => {
const res = await app.inject({ method: 'GET', url: '/api/klines?symbol=BTCUSDT&interval=5m&limit=100' })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.success).toBe(true)
expect(body.data.symbol).toBe('BTCUSDT')
expect(body.data.interval).toBe('5m')
expect(body.data.limit).toBe(100)
})
it('clamps limit to max 1500', async () => {
const res = await app.inject({ method: 'GET', url: '/api/klines?symbol=BTCUSDT&limit=9999' })
expect(res.statusCode).toBe(200)
expect(res.json().data.limit).toBe(1500)
})
it('defaults interval to 15m', async () => {
const res = await app.inject({ method: 'GET', url: '/api/klines?symbol=ETHUSDT' })
expect(res.statusCode).toBe(200)
expect(res.json().data.interval).toBe('15m')
})
it('accepts all valid Binance intervals', async () => {
for (const int of VALID_INTERVALS) {
const res = await app.inject({ method: 'GET', url: `/api/klines?symbol=BTCUSDT&interval=${int}` })
expect(res.statusCode).toBe(200)
}
})
})
// ---- /api/klines-batch ----
describe('POST /api/klines-batch', () => {
it('rejects missing symbols array', async () => {
const res = await app.inject({
method: 'POST', url: '/api/klines-batch',
payload: { interval: '5m' },
})
expect(res.statusCode).toBe(400)
expect(res.json().error).toContain('symbols[]')
})
it('rejects empty symbols array', async () => {
const res = await app.inject({
method: 'POST', url: '/api/klines-batch',
payload: { symbols: [], interval: '5m' },
})
expect(res.statusCode).toBe(400)
})
it('rejects invalid interval', async () => {
const res = await app.inject({
method: 'POST', url: '/api/klines-batch',
payload: { symbols: ['BTCUSDT'], interval: '99m' },
})
expect(res.statusCode).toBe(400)
expect(res.json().error).toContain('interval')
})
it('accepts valid request', async () => {
const res = await app.inject({
method: 'POST', url: '/api/klines-batch',
payload: { symbols: ['BTCUSDT', 'ETHUSDT'], interval: '1h', limit: 300 },
})
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.success).toBe(true)
expect(body.data.symbols).toEqual(['BTCUSDT', 'ETHUSDT'])
expect(body.data.interval).toBe('1h')
expect(body.data.limit).toBe(300)
})
it('caps at 30 symbols', async () => {
const syms = Array.from({ length: 50 }, (_, i) => `SYM${i}USDT`)
const res = await app.inject({
method: 'POST', url: '/api/klines-batch',
payload: { symbols: syms, interval: '5m' },
})
expect(res.statusCode).toBe(200)
expect(res.json().data.symbols.length).toBeLessThanOrEqual(30)
})
it('filters out invalid symbols', async () => {
const res = await app.inject({
method: 'POST', url: '/api/klines-batch',
payload: { symbols: ['BTCUSDT', 'INVALID!@#', 'ETHUSDT'], interval: '5m' },
})
expect(res.statusCode).toBe(200)
const syms = res.json().data.symbols
expect(syms).toContain('BTCUSDT')
expect(syms).toContain('ETHUSDT')
expect(syms).not.toContain('INVALID!@#')
})
})
// ---- /api/signals/history ----
describe('GET /api/signals/history', () => {
it('returns correct default params', async () => {
const res = await app.inject({ method: 'GET', url: '/api/signals/history' })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.data.limit).toBe(100)
expect(body.data.offset).toBe(0)
})
it('clamps limit to max 500', async () => {
const res = await app.inject({ method: 'GET', url: '/api/signals/history?limit=9999' })
expect(res.json().data.limit).toBe(500)
})
it('clamps limit to min 1', async () => {
const res = await app.inject({ method: 'GET', url: '/api/signals/history?limit=-5' })
expect(res.json().data.limit).toBe(1)
})
it('uppercases symbol filter', async () => {
const res = await app.inject({ method: 'GET', url: '/api/signals/history?symbol=btcusdt' })
expect(res.json().data.symbol).toBe('BTCUSDT')
})
})
// ---- /api/rate-limiter ----
describe('GET /api/rate-limiter', () => {
it('returns rate limiter status', async () => {
const res = await app.inject({ method: 'GET', url: '/api/rate-limiter' })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.usedWeight).toBeDefined()
expect(body.softCap).toBe(1800)
expect(body.hardCap).toBe(2200)
expect(body.status).toBe('OK')
})
})
📜 Git History
4492574fix+test: comprehensive code audit — 11 bugfixes + 148 new tests8 weeks ago
Show last diff
Loading...