diff --git a/src/__tests__/graphqlPriceHistory.test.ts b/src/__tests__/graphqlPriceHistory.test.ts new file mode 100644 index 00000000..733c27ac --- /dev/null +++ b/src/__tests__/graphqlPriceHistory.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify, { type FastifyInstance } from 'fastify' + +const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) + +vi.mock('../redis', () => ({ getCachedPrice: vi.fn(), setCachedPrice: vi.fn() })) +vi.mock('../db', () => ({ + prisma: {}, + pgPool: { query: mockQuery }, +})) +vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() })) +vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() })) +vi.mock('../config', () => ({ + config: { pairs: [], cache: { priceTtl: 60 } }, + activeNetwork: 'testnet', +})) + +import { registerGraphQL, schema } from '../api/graphql' + +async function buildApp(): Promise { + const app = Fastify({ logger: false }) + await registerGraphQL(app) + await app.ready() + return app +} + +function makeRow(overrides: Record = {}) { + return { + bucket: new Date('2025-01-01T12:00:00Z'), + window: '1h', + vwap: '1.25', + sdex_vwap: '1.24', + amm_vwap: '1.26', + volume: '1000.5', + trade_count: 42, + open_price: '1.20', + close_price: '1.28', + high_price: '1.30', + low_price: '1.18', + ...overrides, + } +} + +describe('GraphQL getPriceHistory with time range (Issue #370)', () => { + beforeEach(() => { + mockQuery.mockReset() + }) + + it('documents boundary inclusivity in the schema description', () => { + expect(schema).toContain('The time range is inclusive at both ends: a bucket is included if bucket >= from and bucket <= to') + expect(schema).toContain('adjacent windows') + expect(schema).toMatch(/from:\s*String/) + expect(schema).toMatch(/to:\s*String/) + }) + + it('rejects an invalid from date', async () => { + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { + query: ` + query { + getPriceHistory(assetA: "XLM", assetB: "USDC", window: "1h", from: "not-a-valid-date") { + bucket + } + } + `, + }, + }) + + expect(res.statusCode).toBe(200) + const json = res.json() + expect(json.errors).toBeDefined() + expect(json.errors[0].message).toMatch(/from must be a valid ISO 8601 date/) + expect(mockQuery).not.toHaveBeenCalled() + }) + + it('rejects an invalid to date', async () => { + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { + query: ` + query { + getPriceHistory(assetA: "XLM", assetB: "USDC", window: "1h", to: "garbage-value") { + bucket + } + } + `, + }, + }) + + expect(res.statusCode).toBe(200) + const json = res.json() + expect(json.errors).toBeDefined() + expect(json.errors[0].message).toMatch(/to must be a valid ISO 8601 date/) + expect(mockQuery).not.toHaveBeenCalled() + }) + + it('rejects from after to', async () => { + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { + query: ` + query { + getPriceHistory( + assetA: "XLM" + assetB: "USDC" + window: "1h" + from: "2025-01-02T00:00:00Z" + to: "2025-01-01T00:00:00Z" + ) { + bucket + } + } + `, + }, + }) + + expect(res.statusCode).toBe(200) + const json = res.json() + expect(json.errors).toBeDefined() + expect(json.errors[0].message).toMatch(/from must be before to/) + expect(mockQuery).not.toHaveBeenCalled() + }) + + it('rejects an over-large range that exceeds MAX_HISTORY_POINTS', async () => { + const app = await buildApp() + + // 1m window over 31 days = ~44640 points > MAX_HISTORY_POINTS (10,000) + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { + query: ` + query { + getPriceHistory( + assetA: "XLM" + assetB: "USDC" + window: "1m" + from: "2025-01-01T00:00:00Z" + to: "2025-02-01T00:00:00Z" + ) { + bucket + } + } + `, + }, + }) + + expect(res.statusCode).toBe(200) + const json = res.json() + expect(json.errors).toBeDefined() + expect(json.errors[0].message).toMatch(/too large/) + expect(mockQuery).not.toHaveBeenCalled() + }) + + it('allows a bounded range at coarser window and queries database inclusively', async () => { + const app = await buildApp() + mockQuery.mockResolvedValue({ + rows: [ + makeRow({ bucket: new Date('2025-01-15T00:00:00Z'), vwap: '1.25' }), + makeRow({ bucket: new Date('2025-01-15T01:00:00Z'), vwap: '1.27' }), + ], + }) + + const from = '2025-01-01T00:00:00.000Z' + const to = '2025-01-20T00:00:00.000Z' + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { + query: ` + query { + getPriceHistory( + assetA: "USDC" + assetB: "XLM" + window: "1h" + limit: 50 + from: "${from}" + to: "${to}" + ) { + bucket + window + vwap + volume + tradeCount + open + close + high + low + } + } + `, + }, + }) + + expect(res.statusCode).toBe(200) + const json = res.json() + expect(json.errors).toBeUndefined() + expect(json.data.getPriceHistory).toHaveLength(2) + expect(json.data.getPriceHistory[0].vwap).toBe(1.25) + expect(json.data.getPriceHistory[0].tradeCount).toBe(42) + + expect(mockQuery).toHaveBeenCalledOnce() + const [sql, params] = mockQuery.mock.calls[0] + expect(sql).toContain('bucket >= $3') + expect(sql).toContain('bucket <= $4') + expect(params[0]).toBe('USDC/XLM') // sorted pairKey + expect(params[1]).toBe('1h') + expect((params[2] as Date).toISOString()).toBe(from) + expect((params[3] as Date).toISOString()).toBe(to) + expect(params[4]).toBe(50) // limit + }) + + it('supports querying with only from argument', async () => { + const app = await buildApp() + mockQuery.mockResolvedValue({ rows: [] }) + + const from = new Date(Date.now() - 3600 * 1000).toISOString() + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { + query: ` + query { + getPriceHistory( + assetA: "XLM" + assetB: "USDC" + window: "1h" + from: "${from}" + ) { + bucket + } + } + `, + }, + }) + + expect(res.statusCode).toBe(200) + const json = res.json() + expect(json.errors).toBeUndefined() + + expect(mockQuery).toHaveBeenCalledOnce() + const [sql, params] = mockQuery.mock.calls[0] + expect(sql).toContain('bucket >= $3') + expect(sql).not.toContain('bucket <=') + expect((params[2] as Date).toISOString()).toBe(from) + }) + + it('supports legacy calls without from or to', async () => { + const app = await buildApp() + mockQuery.mockResolvedValue({ + rows: [makeRow()], + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { + query: ` + query { + getPriceHistory(assetA: "XLM", assetB: "USDC", window: "5m") { + bucket + window + vwap + } + } + `, + }, + }) + + expect(res.statusCode).toBe(200) + const json = res.json() + expect(json.errors).toBeUndefined() + expect(json.data.getPriceHistory).toHaveLength(1) + + const [sql, params] = mockQuery.mock.calls[0] + expect(sql).not.toContain('bucket >=') + expect(sql).not.toContain('bucket <=') + expect(params).toEqual(['USDC/XLM', '5m', 100]) + }) +}) diff --git a/src/api/graphql.ts b/src/api/graphql.ts index c8e24e29..c0a26b33 100644 --- a/src/api/graphql.ts +++ b/src/api/graphql.ts @@ -11,12 +11,17 @@ import { pgPool } from '../db' import { config, activeNetwork } from '../config' import { parseAssetStr } from '../pairsRegistry' import { priceEmitter, PRICE_PUBLISHED, type PricePublishedEvent } from '../events' +import { HISTORY_INTERVAL_SECONDS, MAX_HISTORY_POINTS } from './history' + +const WINDOW_SECONDS: Record = { + ...HISTORY_INTERVAL_SECONDS, + '24h': 86400, +} // Mercurius pubsub topic that carries every new price. A single app-level // listener bridges the ingesters' in-process `priceEmitter` onto this topic; // each subscriber then filters it down to the pair they asked for. const PRICE_TOPIC = 'PRICE_UPDATED' - // Error codes that resolvers raise on purpose; their messages are written for // the caller and are safe to return as-is. Anything else is treated as an // unexpected internal failure and masked. @@ -54,7 +59,7 @@ export function formatGraphQLErrors(execution: ExecutionResult, ctx: MercuriusCo return defaultErrorFormatter({ ...execution, errors }, ctx) } -const schema = ` +export const schema = ` type AggregatedPrice { assetA: String! assetB: String! @@ -124,7 +129,21 @@ const schema = ` """ getPrice(assetA: String!, assetB: String!): AggregatedPrice getBestRoute(assetA: String!, assetB: String!, amount: Float!): RouteInfo - getPriceHistory(assetA: String!, assetB: String!, window: String!, limit: Int): [PriceBucket] + """ + Returns historical price aggregate buckets for a pair over an optional [from, to] time range. + + The time range is inclusive at both ends: a bucket is included if bucket >= from and bucket <= to. + Because both endpoints are inclusive, querying adjacent windows (e.g. [t0, t1] and [t1, t2]) will + return the boundary bucket at t1 in both results. + """ + getPriceHistory( + assetA: String! + assetB: String! + window: String! + limit: Int + from: String + to: String + ): [PriceBucket] listPairs: [String]! } @@ -211,21 +230,86 @@ const resolvers = { async getPriceHistory( _: unknown, - { assetA, assetB, window, limit = 100 }: { assetA: string; assetB: string; window: string; limit?: number } + { + assetA, + assetB, + window, + limit = 100, + from, + to, + }: { + assetA: string + assetB: string + window: string + limit?: number + from?: string + to?: string + } ) { + let fromDate: Date | undefined + let toDate: Date | undefined + + if (from !== undefined && from !== null) { + fromDate = new Date(from) + if (isNaN(fromDate.getTime())) { + throw new ErrorWithProps('from must be a valid ISO 8601 date', { code: 'INVALID_INPUT' }) + } + } + + if (to !== undefined && to !== null) { + toDate = new Date(to) + if (isNaN(toDate.getTime())) { + throw new ErrorWithProps('to must be a valid ISO 8601 date', { code: 'INVALID_INPUT' }) + } + } + + if (fromDate && toDate && fromDate.getTime() > toDate.getTime()) { + throw new ErrorWithProps('from must be before to', { code: 'INVALID_INPUT' }) + } + + const intervalSecs = WINDOW_SECONDS[window] ?? HISTORY_INTERVAL_SECONDS[window] ?? 60 + + if (fromDate || toDate) { + const effectiveTo = toDate ?? new Date() + const effectiveFrom = fromDate ?? new Date(effectiveTo.getTime() - 24 * 60 * 60 * 1000) + const spanSecs = (effectiveTo.getTime() - effectiveFrom.getTime()) / 1000 + if (spanSecs / intervalSecs > MAX_HISTORY_POINTS) { + throw new ErrorWithProps( + `requested window is too large for interval=${window} (max ${MAX_HISTORY_POINTS} points); narrow the range or use a coarser interval`, + { code: 'INVALID_INPUT' } + ) + } + } + const pairKey = makePairKey(assetA, assetB) + const conditions: string[] = ['pair_key = $1', 'window = $2'] + const params: any[] = [pairKey, window] + + if (fromDate) { + params.push(fromDate) + conditions.push(`bucket >= $${params.length}`) + } + + if (toDate) { + params.push(toDate) + conditions.push(`bucket <= $${params.length}`) + } + + params.push(Math.min(limit, 1000)) + const limitParam = `$${params.length}` + const result = await pgPool.query( `SELECT bucket, window, vwap::float, sdex_vwap::float, amm_vwap::float, volume::float, trade_count, open_price::float, close_price::float, high_price::float, low_price::float FROM price_aggregates - WHERE pair_key = $1 AND window = $2 + WHERE ${conditions.join(' AND ')} ORDER BY bucket DESC - LIMIT $3`, - [pairKey, window, Math.min(limit, 1000)] + LIMIT ${limitParam}`, + params ) return result.rows.map(r => ({ - bucket: r.bucket.toISOString(), + bucket: (r.bucket instanceof Date ? r.bucket : new Date(r.bucket)).toISOString(), window: r.window, vwap: r.vwap, sdexVwap: r.sdex_vwap,