import { describe, expect, it } from 'vitest'; import { HfmdClient, HfmdError, errorFromResponse, rateFromHeaders } from '../src/client.js'; import { RATE_HEADERS, mockFetch } from './helpers.js'; describe('HfmdClient', () => { it('builds URLs with format=json and joins array params', () => { const c = new HfmdClient({ baseUrl: 'https://api.test/', apiKey: '', fetchImpl: fetch }); const u = c.buildUrl('/v1/bars/stock', { tickers: ['AAPL', 'MSFT'], limit: 10, start: undefined, end: '' }); expect(u).toBe('https://api.test/v1/bars/stock?tickers=AAPL%2CMSFT&limit=10&format=json'); expect(c.wsUrl).toBe('wss://api.test/v1/stream'); }); it('sends the bearer header only when a key is set', async () => { let seen: Headers | undefined; const f = (async (_u: any, init?: RequestInit) => { seen = new Headers(init?.headers as any); return new Response('{"ok":true}', { headers: { 'content-type': 'application/json' } }); }) as typeof fetch; await new HfmdClient({ baseUrl: 'https://api.test', apiKey: 'hfmd_live_x', fetchImpl: f }).get('/v1/status'); expect(seen?.get('authorization')).toBe('Bearer hfmd_live_x'); await new HfmdClient({ baseUrl: 'https://api.test', apiKey: '', fetchImpl: f }).get('/v1/status'); expect(seen?.get('authorization')).toBeNull(); expect(seen?.get('user-agent')).toContain('hfmarketdata-mcp'); }); it('parses rate-limit headers', async () => { const m = mockFetch([{ match: '/v1/status', body: { ok: 1 }, headers: RATE_HEADERS }]); const r = await new HfmdClient({ baseUrl: 'https://api.test', apiKey: '', fetchImpl: m.fetch }).get('/v1/status'); expect(r.rate.remainingRequests).toBe(29); expect(r.rate.limitRows).toBe(100000); expect(r.rate.rowCount).toBe(5); expect(rateFromHeaders(new Headers()).remainingRequests).toBeNull(); }); it('maps v2 error envelopes', async () => { const m = mockFetch([{ match: '/v1/futures/XX/chain', status: 404, body: { error: { code: 'ROOT_NOT_FOUND', message: 'Unknown futures root XX', docs: 'https://d/errors#root_not_found' }, detail: 'Unknown futures root XX' } }]); const c = new HfmdClient({ baseUrl: 'https://api.test', apiKey: '', fetchImpl: m.fetch }); const err = await c.get('/v1/futures/XX/chain').catch((e) => e); expect(err).toBeInstanceOf(HfmdError); expect(err.status).toBe(404); expect(err.code).toBe('ROOT_NOT_FOUND'); expect(err.describe()).toContain('ROOT_NOT_FOUND'); expect(err.describe()).toContain('https://d/errors#root_not_found'); }); it('maps legacy v1 {detail} errors and validation arrays', () => { const e1 = errorFromResponse(404, { detail: 'Unknown ticker' }, rateFromHeaders(new Headers()), 'u'); expect(e1.code).toBe('NOT_FOUND'); expect(e1.message).toBe('Unknown ticker'); const e2 = errorFromResponse(422, { detail: [{ loc: ['query', 'limit'], msg: 'must be > 0' }] }, rateFromHeaders(new Headers()), 'u'); expect(e2.code).toBe('INVALID_PARAMETER'); expect(e2.message).toContain('query.limit: must be > 0'); const e3 = errorFromResponse(503, 'Service Unavailable', rateFromHeaders(new Headers()), 'u'); expect(e3.code).toBe('INTERNAL_ERROR'); }); it('explains 429 with Retry-After and the keyless hint', async () => { const m = mockFetch([{ match: '/v1/bars', status: 429, body: { error: { code: 'RATE_LIMIT_EXCEEDED', message: 'quota exhausted' } }, headers: { 'retry-after': '41', 'x-ratelimit-remaining-requests': '0' } }]); const c = new HfmdClient({ baseUrl: 'https://api.test', apiKey: '', fetchImpl: m.fetch }); const err: HfmdError = await c.get('/v1/bars/stock/AAPL').catch((e) => e); expect(err.status).toBe(429); const text = err.describe(); expect(text).toContain('Retry after 41s'); expect(text).toContain('HFMD_API_KEY'); }); it('wraps network failures', async () => { const f = (async () => { throw new TypeError('fetch failed'); }) as unknown as typeof fetch; const c = new HfmdClient({ baseUrl: 'https://api.test', apiKey: '', fetchImpl: f }); const err: HfmdError = await c.get('/v1/status').catch((e) => e); expect(err.code).toBe('NETWORK_ERROR'); expect(err.status).toBe(0); }); });