diff --git a/src/__tests__/graphqlPairs.test.ts b/src/__tests__/graphqlPairs.test.ts new file mode 100644 index 00000000..6d31bbb2 --- /dev/null +++ b/src/__tests__/graphqlPairs.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify, { type FastifyInstance } from 'fastify' + +const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' +const AQUA_ISSUER = 'GBNZILSTVQZ4ROOOA3KLGGBKLS5O3UMT7GHIHMXHGOSSSTJRXORMSKE8' + +const mocks = vi.hoisted(() => { + const usdcIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + const aquaIssuer = 'GBNZILSTVQZ4ROOOA3KLGGBKLS5O3UMT7GHIHMXHGOSSSTJRXORMSKE8' + return { + getCachedPrice: vi.fn(), + getAggregatedPrice: vi.fn(), + getBestRoute: vi.fn(), + pgPoolQuery: vi.fn(), + pairs: [ + { + pairKey: `USDC:${usdcIssuer}/XLM`, + assetA: { code: 'USDC', issuer: usdcIssuer }, + assetB: { code: 'XLM', issuer: null }, + }, + { + pairKey: `AQUA:${aquaIssuer}/XLM`, + assetA: { code: 'AQUA', issuer: aquaIssuer }, + assetB: { code: 'XLM', issuer: null }, + }, + ], + } +}) + +vi.mock('../redis', () => ({ getCachedPrice: mocks.getCachedPrice })) +vi.mock('../db', () => ({ pgPool: { query: mocks.pgPoolQuery }, prisma: {} })) +vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: mocks.getAggregatedPrice })) +vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mocks.getBestRoute })) +vi.mock('../config', () => ({ + activeNetwork: 'mainnet', + config: { + pairs: mocks.pairs, + }, +})) + +import { registerGraphQL } from '../api/graphql' + +const PAIRS_QUERY = `query { + pairs { + pairKey + network + assetA { + code + issuer + } + assetB { + code + issuer + } + latestPrice + lastUpdated + } +}` + +const LIST_PAIRS_QUERY = `query { + listPairs +}` + +describe('GraphQL pairs and listPairs queries', () => { + let app: FastifyInstance + + beforeEach(async () => { + vi.clearAllMocks() + mocks.getCachedPrice.mockResolvedValue(null) + mocks.getAggregatedPrice.mockResolvedValue({}) + mocks.getBestRoute.mockResolvedValue({ route: 'SDEX' }) + app = Fastify({ logger: false }) + await registerGraphQL(app) + await app.ready() + }) + + it('keeps listPairs working for backward compatibility', async () => { + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { query: LIST_PAIRS_QUERY }, + }) + const json = res.json() + expect(json.errors).toBeUndefined() + expect(json.data.listPairs).toEqual([ + `USDC:${USDC_ISSUER}/XLM`, + `AQUA:${AQUA_ISSUER}/XLM`, + ]) + }) + + it('resolves pairs query carrying full asset metadata, network, and price info', async () => { + const timestamp = new Date('2026-10-17T12:00:00.000Z') + mocks.pgPoolQuery.mockResolvedValue({ + rows: [ + { + pair_key: `USDC:${USDC_ISSUER}/XLM`, + price: '0.25', + timestamp, + }, + ], + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { query: PAIRS_QUERY }, + }) + + const json = res.json() + expect(json.errors).toBeUndefined() + const pairs = json.data.pairs + expect(pairs).toHaveLength(2) + + // Check USDC/XLM pair + const usdcPair = pairs.find((p: any) => p.pairKey === `USDC:${USDC_ISSUER}/XLM`) + expect(usdcPair).toBeDefined() + expect(usdcPair.network).toBe('mainnet') + expect(usdcPair.assetA).toEqual({ + code: 'USDC', + issuer: USDC_ISSUER, + }) + expect(usdcPair.assetB).toEqual({ + code: 'XLM', + issuer: null, + }) + expect(usdcPair.latestPrice).toBe(0.25) + expect(usdcPair.lastUpdated).toBe(timestamp.toISOString()) + + // Check quiet AQUA/XLM pair (no rows returned from lateral join) + const aquaPair = pairs.find((p: any) => p.pairKey === `AQUA:${AQUA_ISSUER}/XLM`) + expect(aquaPair).toBeDefined() + expect(aquaPair.network).toBe('mainnet') + expect(aquaPair.assetA).toEqual({ + code: 'AQUA', + issuer: AQUA_ISSUER, + }) + expect(aquaPair.assetB).toEqual({ + code: 'XLM', + issuer: null, + }) + expect(aquaPair.latestPrice).toBeNull() + expect(aquaPair.lastUpdated).toBeNull() + + // Verify lateral query parameters passed to pgPool.query + expect(mocks.pgPoolQuery).toHaveBeenCalledWith( + expect.stringContaining('CROSS JOIN LATERAL'), + ['mainnet', [`USDC:${USDC_ISSUER}/XLM`, `AQUA:${AQUA_ISSUER}/XLM`]] + ) + }) +}) diff --git a/src/api/graphql.ts b/src/api/graphql.ts index c8e24e29..5388385f 100644 --- a/src/api/graphql.ts +++ b/src/api/graphql.ts @@ -9,7 +9,7 @@ import { getAggregatedPrice } from '../aggregator/vwap' import { getBestRoute } from '../aggregator/bestRoute' import { pgPool } from '../db' import { config, activeNetwork } from '../config' -import { parseAssetStr } from '../pairsRegistry' +import { parseAssetStr, getActivePairs } from '../pairsRegistry' import { priceEmitter, PRICE_PUBLISHED, type PricePublishedEvent } from '../events' // Mercurius pubsub topic that carries every new price. A single app-level @@ -55,6 +55,20 @@ export function formatGraphQLErrors(execution: ExecutionResult, ctx: MercuriusCo } const schema = ` + type Asset { + code: String! + issuer: String + } + + type Pair { + pairKey: String! + network: String! + assetA: Asset! + assetB: Asset! + latestPrice: Float + lastUpdated: String + } + type AggregatedPrice { assetA: String! assetB: String! @@ -125,7 +139,8 @@ 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] - listPairs: [String]! + listPairs: [String]! @deprecated(reason: "Use 'pairs' instead") + pairs: [Pair!]! } type Subscription { @@ -240,7 +255,48 @@ const resolvers = { }, listPairs() { - return config.pairs.map(p => p.pairKey) + return getActivePairs().map(p => p.pairKey) + }, + + async pairs() { + const activePairs = getActivePairs() + if (activePairs.length === 0) { + return [] + } + + const result = await pgPool.query( + `SELECT p.pair_key, l.price, l.timestamp + FROM unnest($2::text[]) AS p(pair_key) + CROSS JOIN LATERAL ( + SELECT price, timestamp + FROM price_points + WHERE network = $1 + AND pair_key = p.pair_key + ORDER BY timestamp DESC + LIMIT 1 + ) l`, + [activeNetwork, activePairs.map(p => p.pairKey)] + ) + + const latestPrices = new Map() + result.rows.forEach(row => { + latestPrices.set(row.pair_key, { + price: parseFloat(row.price), + timestamp: row.timestamp, + }) + }) + + return activePairs.map(p => { + const latest = latestPrices.get(p.pairKey) + return { + pairKey: p.pairKey, + network: activeNetwork, + assetA: p.assetA, + assetB: p.assetB, + latestPrice: latest?.price ?? null, + lastUpdated: latest?.timestamp ? (latest.timestamp instanceof Date ? latest.timestamp.toISOString() : new Date(latest.timestamp).toISOString()) : null, + } + }) }, },