diff --git a/apps/api/src/indexer/indexer.worker.integration.spec.ts b/apps/api/src/indexer/indexer.worker.integration.spec.ts new file mode 100644 index 0000000..d37205c --- /dev/null +++ b/apps/api/src/indexer/indexer.worker.integration.spec.ts @@ -0,0 +1,499 @@ +/** + * Indexer worker integration tests using an in-memory Prisma mock that + * simulates the shape of a real test database. These tests verify that the + * worker correctly persists events, updates pool/swap state, and advances + * ledger checkpoints — without requiring a live PostgreSQL instance. + */ + +// ─── BullMQ mocks ───────────────────────────────────────────────────────────── + +const mockWorkerOn = jest.fn(); +const mockWorkerClose = jest.fn().mockResolvedValue(undefined); +const mockWorkerClient = Promise.resolve({ + llen: jest.fn().mockResolvedValue(0), +}); + +const MockWorker = jest + .fn() + .mockImplementation((_name: string, _handler: unknown) => ({ + name: _name, + on: mockWorkerOn, + close: mockWorkerClose, + client: mockWorkerClient, + })); + +const mockQueueEventsOn = jest.fn(); +const mockQueueEventsClose = jest.fn().mockResolvedValue(undefined); + +const MockQueueEvents = jest.fn().mockImplementation(() => ({ + on: mockQueueEventsOn, + close: mockQueueEventsClose, +})); + +jest.mock('bullmq', () => ({ + Worker: MockWorker, + QueueEvents: MockQueueEvents, + Queue: jest.fn().mockImplementation((name: string) => ({ + name, + add: jest.fn().mockResolvedValue({ id: 'job-1' }), + close: jest.fn().mockResolvedValue(undefined), + })), + Job: jest.fn(), +})); + +// ─── In-memory test DB (Prisma mock) ────────────────────────────────────────── + +interface PoolRow { + id: string; + token0Address: string; + token1Address: string; + feeTier: number; + currentSqrtPrice: string; + currentTick: number; + liquidity: string; + tvl: string; + volume24h: string; + feeApr: string; + updatedAt?: Date; + createdAt?: Date; +} + +interface SwapRow { + id?: string; + eventId: string; + poolId: string; + senderAddress: string; + recipientAddress: string; + amount0: string; + amount1: string; + sqrtPriceAfter: string; + tickAfter: number; + transactionHash: string; + feeAmount?: string; + timestamp?: Date; +} + +interface TokenRow { + address: string; + symbol: string; + name: string; + decimals: number; +} + +const db: { + pools: Map; + swaps: Map; + tokens: Map; + poolCreated: Map; + swapProcessed: Map; + positionMinted: Map; + positionBurned: Map; + feesCollected: Map; + positions: Map; +} = { + pools: new Map(), + swaps: new Map(), + tokens: new Map(), + poolCreated: new Map(), + swapProcessed: new Map(), + positionMinted: new Map(), + positionBurned: new Map(), + feesCollected: new Map(), + positions: new Map(), +}; + +function makeUpsert>( + store: Map, + keyFn: (where: Record) => string, +) { + return jest.fn().mockImplementation(async ({ where, create, update }: { + where: Record; + create: T; + update: Partial; + }) => { + const key = keyFn(where); + if (store.has(key)) { + const existing = store.get(key)!; + const merged = { ...existing, ...update } as T; + store.set(key, merged); + return merged; + } + store.set(key, create); + return create; + }); +} + +const mockPrismaClient = { + token: { + upsert: makeUpsert(db.tokens as Map>, (w) => w.address as string), + }, + pool: { + upsert: makeUpsert(db.pools as Map>, (w) => w.id as string), + findUnique: jest.fn().mockImplementation(async ({ where }: { where: { id: string } }) => { + return db.pools.get(where.id) ?? null; + }), + }, + swap: { + upsert: makeUpsert(db.swaps as Map>, (w) => w.eventId as string), + }, + position: { + upsert: makeUpsert(db.positions as Map>, (w) => { + const pk = w.poolId_tokenId as { poolId: string; tokenId: string }; + return `${pk.poolId}:${pk.tokenId}`; + }), + update: jest.fn().mockResolvedValue({}), + }, + poolCreated: { + upsert: makeUpsert(db.poolCreated as Map>, (w) => w.eventId as string), + }, + swapProcessed: { + upsert: makeUpsert(db.swapProcessed as Map>, (w) => w.eventId as string), + }, + positionMinted: { + upsert: makeUpsert(db.positionMinted as Map>, (w) => w.eventId as string), + }, + positionBurned: { + upsert: makeUpsert(db.positionBurned as Map>, (w) => w.eventId as string), + }, + feesCollected: { + upsert: makeUpsert(db.feesCollected as Map>, (w) => w.eventId as string), + }, + $disconnect: jest.fn().mockResolvedValue(undefined), +}; + +jest.mock('@prisma/client', () => ({ + PrismaClient: jest.fn().mockImplementation(() => mockPrismaClient), +})); + +// ─── Imports ────────────────────────────────────────────────────────────────── + +import { Test, TestingModule } from '@nestjs/testing'; +import { Job } from 'bullmq'; +import { IndexerWorker } from './indexer.worker'; +import { CacheService } from '../cache/cache.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; +import { TokenEnrichmentService } from '../tokens/token-enrichment.service'; +import { LAST_INDEXED_LEDGER_KEY } from '../metrics/indexer-monitor.service'; +import { QUEUE_NAMES } from './queues'; +import type { + PoolCreatedJobData, + SwapProcessedJobData, + FeesCollectedJobData, + PositionMintedJobData, + PositionBurnedJobData, +} from './queues'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeJob(data: T): Job { + return { data, id: 'integration-job', attemptsMade: 1 } as unknown as Job; +} + +function getHandler(queueName: string): (job: Job) => Promise { + const call = MockWorker.mock.calls.find((c) => c[0] === queueName); + if (!call) throw new Error(`No worker for queue: ${queueName}`); + return call[1] as (job: Job) => Promise; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('IndexerWorker Integration (test-db)', () => { + let worker: IndexerWorker; + let module: TestingModule; + const mockSetMaxNumber = jest.fn().mockResolvedValue(true); + + beforeEach(async () => { + jest.clearAllMocks(); + db.pools.clear(); + db.swaps.clear(); + db.tokens.clear(); + db.poolCreated.clear(); + db.swapProcessed.clear(); + db.positionMinted.clear(); + db.positionBurned.clear(); + db.feesCollected.clear(); + db.positions.clear(); + + module = await Test.createTestingModule({ + providers: [ + IndexerWorker, + { + provide: CacheService, + useValue: { setMaxNumber: mockSetMaxNumber }, + }, + { + provide: WebhooksService, + useValue: { dispatch: jest.fn().mockResolvedValue(undefined) }, + }, + { + provide: TokenEnrichmentService, + useValue: { enrichToken: jest.fn().mockResolvedValue(undefined) }, + }, + ], + }).compile(); + + worker = module.get(IndexerWorker); + worker.onModuleInit(); + }); + + afterEach(async () => { + await module.close(); + }); + + describe('pool.created flow', () => { + const poolData: PoolCreatedJobData = { + eventId: 'int-pool-1', + poolId: 'pool-integration-1', + tokenA: 'XLM', + tokenB: 'USDC', + fee: '3000', + sqrtPriceX96: '79228162514264337593543950336', + ledger: 1000, + }; + + it('persists PoolCreated event to test db', async () => { + const handler = getHandler(QUEUE_NAMES.POOL_CREATED); + await handler(makeJob(poolData)); + + expect(db.poolCreated.has('int-pool-1')).toBe(true); + }); + + it('creates token rows for both pool tokens', async () => { + const handler = getHandler(QUEUE_NAMES.POOL_CREATED); + await handler(makeJob(poolData)); + + expect(db.tokens.has('XLM')).toBe(true); + expect(db.tokens.has('USDC')).toBe(true); + }); + + it('creates a pool row with correct fee tier', async () => { + const handler = getHandler(QUEUE_NAMES.POOL_CREATED); + await handler(makeJob(poolData)); + + const pool = db.pools.get('pool-integration-1') as PoolRow; + expect(pool).toBeDefined(); + expect(pool.feeTier).toBe(3000); + expect(pool.token0Address).toBe('XLM'); + expect(pool.token1Address).toBe('USDC'); + }); + + it('advances ledger checkpoint after successful write', async () => { + const handler = getHandler(QUEUE_NAMES.POOL_CREATED); + await handler(makeJob(poolData)); + + expect(mockSetMaxNumber).toHaveBeenCalledWith(LAST_INDEXED_LEDGER_KEY, 1000); + }); + + it('is idempotent: duplicate event does not create duplicate rows', async () => { + const handler = getHandler(QUEUE_NAMES.POOL_CREATED); + await handler(makeJob(poolData)); + await handler(makeJob(poolData)); + + expect(db.poolCreated.size).toBe(1); + }); + }); + + describe('swap.processed flow', () => { + const swapData: SwapProcessedJobData = { + eventId: 'int-swap-1', + poolId: 'pool-integration-1', + sender: '0xSender', + recipient: '0xRecipient', + amount0: '1000000', + amount1: '-500000', + sqrtPriceX96: '79228162514264337593543950336', + liquidity: '1000000000000000000', + tick: 100, + ledger: 1001, + }; + + it('persists SwapProcessed event to test db', async () => { + const handler = getHandler(QUEUE_NAMES.SWAP_PROCESSED); + await handler(makeJob(swapData)); + + expect(db.swapProcessed.has('int-swap-1')).toBe(true); + }); + + it('creates a Swap row in test db', async () => { + const handler = getHandler(QUEUE_NAMES.SWAP_PROCESSED); + await handler(makeJob(swapData)); + + expect(db.swaps.has('int-swap-1')).toBe(true); + const swap = db.swaps.get('int-swap-1') as SwapRow; + expect(swap.poolId).toBe('pool-integration-1'); + expect(swap.tickAfter).toBe(100); + }); + + it('creates a placeholder pool when it does not exist yet', async () => { + const handler = getHandler(QUEUE_NAMES.SWAP_PROCESSED); + await handler(makeJob(swapData)); + + const pool = db.pools.get('pool-integration-1') as PoolRow; + expect(pool).toBeDefined(); + expect(pool.currentTick).toBe(100); + }); + + it('updates existing pool state on swap', async () => { + db.pools.set('pool-integration-1', { + id: 'pool-integration-1', + token0Address: 'XLM', + token1Address: 'USDC', + feeTier: 3000, + currentSqrtPrice: '1', + currentTick: 0, + liquidity: '0', + tvl: '0', + volume24h: '0', + feeApr: '0', + }); + + const handler = getHandler(QUEUE_NAMES.SWAP_PROCESSED); + await handler(makeJob(swapData)); + + const pool = db.pools.get('pool-integration-1') as PoolRow; + expect(pool.currentTick).toBe(100); + expect(pool.liquidity).toBe('1000000000000000000'); + }); + + it('advances ledger checkpoint after swap write', async () => { + const handler = getHandler(QUEUE_NAMES.SWAP_PROCESSED); + await handler(makeJob(swapData)); + + expect(mockSetMaxNumber).toHaveBeenCalledWith(LAST_INDEXED_LEDGER_KEY, 1001); + }); + + it('skips invalid sqrtPrice but still persists swap row', async () => { + const handler = getHandler(QUEUE_NAMES.SWAP_PROCESSED); + await handler(makeJob({ ...swapData, sqrtPriceX96: '0', eventId: 'int-swap-invalid' })); + + expect(db.swaps.has('int-swap-invalid')).toBe(true); + }); + }); + + describe('fees.collected flow', () => { + const feesData: FeesCollectedJobData = { + eventId: 'int-fees-1', + poolId: 'pool-integration-1', + recipient: '0xRecipient', + amount0: '5000', + amount1: '2500', + ledger: 1002, + }; + + it('persists FeesCollected event to test db', async () => { + const handler = getHandler(QUEUE_NAMES.FEES_COLLECTED); + await handler(makeJob(feesData)); + + expect(db.feesCollected.has('int-fees-1')).toBe(true); + }); + + it('advances ledger checkpoint after fees write', async () => { + const handler = getHandler(QUEUE_NAMES.FEES_COLLECTED); + await handler(makeJob(feesData)); + + expect(mockSetMaxNumber).toHaveBeenCalledWith(LAST_INDEXED_LEDGER_KEY, 1002); + }); + }); + + describe('position.minted flow', () => { + const mintData: PositionMintedJobData = { + eventId: 'int-mint-1', + poolId: 'pool-integration-1', + tokenId: 'tok-1', + owner: '0xOwner', + tickLower: -1000, + tickUpper: 1000, + liquidity: '500000000', + amount0: '100000', + amount1: '200000', + ledger: 1003, + }; + + it('persists PositionMinted event to test db', async () => { + const handler = getHandler(QUEUE_NAMES.POSITION_MINTED); + await handler(makeJob(mintData)); + + expect(db.positionMinted.has('int-mint-1')).toBe(true); + }); + + it('upserts Position row by pool and token id', async () => { + const handler = getHandler(QUEUE_NAMES.POSITION_MINTED); + await handler(makeJob(mintData)); + + expect(db.positions.has('pool-integration-1:tok-1')).toBe(true); + }); + }); + + describe('position.burned flow', () => { + const burnData: PositionBurnedJobData = { + eventId: 'int-burn-1', + poolId: 'pool-integration-1', + tokenId: 'tok-1', + owner: '0xOwner', + tickLower: -1000, + tickUpper: 1000, + liquidity: '0', + amount0: '100000', + amount1: '200000', + ledger: 1004, + }; + + it('persists PositionBurned event to test db', async () => { + const handler = getHandler(QUEUE_NAMES.POSITION_BURNED); + await handler(makeJob(burnData)); + + expect(db.positionBurned.has('int-burn-1')).toBe(true); + }); + + it('marks zero-liquidity position as closed in test db', async () => { + const handler = getHandler(QUEUE_NAMES.POSITION_BURNED); + await handler(makeJob(burnData)); + + expect(mockPrismaClient.position.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ closedAt: expect.any(Date) }), + }), + ); + }); + }); + + describe('graceful error handling', () => { + it('does not advance ledger when Prisma write fails', async () => { + mockPrismaClient.poolCreated.upsert.mockRejectedValueOnce( + new Error('integration test db connection lost'), + ); + + const handler = getHandler(QUEUE_NAMES.POOL_CREATED); + await expect( + handler( + makeJob({ + eventId: 'int-err-1', + poolId: 'pool-err', + tokenA: 'A', + tokenB: 'B', + fee: '30', + sqrtPriceX96: '1', + ledger: 9999, + }), + ), + ).rejects.toThrow('integration test db connection lost'); + + expect(mockSetMaxNumber).not.toHaveBeenCalled(); + }); + + it('skips write and warns on empty eventId', async () => { + const handler = getHandler(QUEUE_NAMES.FEES_COLLECTED); + await handler( + makeJob({ + eventId: '', + poolId: 'pool-integration-1', + recipient: '0xR', + amount0: '1', + amount1: '1', + }), + ); + + expect(db.feesCollected.size).toBe(0); + }); + }); +}); diff --git a/apps/api/src/metrics/db-metrics.service.ts b/apps/api/src/metrics/db-metrics.service.ts index 9343210..f020bc9 100644 --- a/apps/api/src/metrics/db-metrics.service.ts +++ b/apps/api/src/metrics/db-metrics.service.ts @@ -7,6 +7,8 @@ export interface DbMetricsSnapshot { p95QueryTimeMs: number; slowQueryCount: number; cacheHitRate: number | null; + poolCount: number; + swapRatePerMinute: number; } @Injectable() @@ -16,9 +18,23 @@ export class DbMetricsService { private readonly slowThreshold = Number( process.env.DB_SLOW_QUERY_THRESHOLD_MS ?? 100, ); + private _poolCount = 0; + private swapTimestamps: number[] = []; constructor(private readonly cache: CacheService) {} + setPoolCount(count: number) { + this._poolCount = count; + } + + recordSwap() { + const now = Date.now(); + this.swapTimestamps.push(now); + // Keep only timestamps within the last 60 seconds + const cutoff = now - 60_000; + this.swapTimestamps = this.swapTimestamps.filter((t) => t >= cutoff); + } + record(durationMs: number) { this.durations.push(durationMs); if (durationMs >= this.slowThreshold) this.slowCount++; @@ -52,6 +68,8 @@ export class DbMetricsService { p95QueryTimeMs: p95 ?? 0, slowQueryCount: this.slowCount, cacheHitRate, + poolCount: this._poolCount, + swapRatePerMinute: this.swapTimestamps.length, }; } } diff --git a/apps/api/src/metrics/indexer-monitor.service.spec.ts b/apps/api/src/metrics/indexer-monitor.service.spec.ts new file mode 100644 index 0000000..e161deb --- /dev/null +++ b/apps/api/src/metrics/indexer-monitor.service.spec.ts @@ -0,0 +1,134 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + IndexerMonitorService, + LAST_INDEXED_LEDGER_KEY, +} from './indexer-monitor.service'; +import { CacheService } from '../cache/cache.service'; + +const mockLedgers = { + order: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ records: [{ sequence: 1000 }] }), +}; + +jest.mock('@stellar/stellar-sdk', () => ({ + Horizon: { + Server: jest.fn().mockImplementation(() => ({ + ledgers: jest.fn().mockReturnValue(mockLedgers), + })), + }, +})); + +describe('IndexerMonitorService', () => { + let service: IndexerMonitorService; + let mockCache: jest.Mocked>; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockCache = { get: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + IndexerMonitorService, + { provide: CacheService, useValue: mockCache }, + ], + }).compile(); + + service = module.get(IndexerMonitorService); + }); + + afterEach(async () => { + service.onModuleDestroy(); + }); + + describe('getMetrics()', () => { + it('returns healthy when no checkpoint exists yet (prevents false critical alert)', async () => { + mockCache.get.mockResolvedValue(null); + mockLedgers.call.mockResolvedValue({ records: [{ sequence: 5_000_000 }] }); + + const metrics = await service.getMetrics(); + + expect(metrics.status).toBe('healthy'); + expect(metrics.lagLedgers).toBe(0); + }); + + it('returns healthy when lag is below 10 ledgers', async () => { + mockCache.get.mockResolvedValue(995); + mockLedgers.call.mockResolvedValue({ records: [{ sequence: 1000 }] }); + + const metrics = await service.getMetrics(); + + expect(metrics.status).toBe('healthy'); + expect(metrics.lagLedgers).toBe(5); + }); + + it('returns degraded when lag is between 10 and 50 ledgers', async () => { + mockCache.get.mockResolvedValue(960); + mockLedgers.call.mockResolvedValue({ records: [{ sequence: 1000 }] }); + + const metrics = await service.getMetrics(); + + expect(metrics.status).toBe('degraded'); + expect(metrics.lagLedgers).toBe(40); + }); + + it('returns critical when lag exceeds 50 ledgers', async () => { + mockCache.get.mockResolvedValue(900); + mockLedgers.call.mockResolvedValue({ records: [{ sequence: 1000 }] }); + + const metrics = await service.getMetrics(); + + expect(metrics.status).toBe('critical'); + expect(metrics.lagLedgers).toBe(100); + }); + + it('reports correct lagSeconds based on 5-second ledger close time', async () => { + mockCache.get.mockResolvedValue(980); + mockLedgers.call.mockResolvedValue({ records: [{ sequence: 1000 }] }); + + const metrics = await service.getMetrics(); + + expect(metrics.lagSeconds).toBe(100); // 20 ledgers * 5s + }); + + it('does not return negative lagLedgers when checkpoint is ahead of latest', async () => { + mockCache.get.mockResolvedValue(1010); + mockLedgers.call.mockResolvedValue({ records: [{ sequence: 1000 }] }); + + const metrics = await service.getMetrics(); + + expect(metrics.lagLedgers).toBe(0); + }); + + it('returns lastIndexedLedger = 0 when no checkpoint', async () => { + mockCache.get.mockResolvedValue(null); + + const metrics = await service.getMetrics(); + + expect(metrics.lastIndexedLedger).toBe(0); + }); + }); + + describe('onModuleInit()', () => { + it('starts the periodic check timer', () => { + const spy = jest.spyOn(global, 'setInterval'); + service.onModuleInit(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + }); + + describe('onModuleDestroy()', () => { + it('clears the timer without throwing', () => { + service.onModuleInit(); + expect(() => service.onModuleDestroy()).not.toThrow(); + }); + }); + + describe('cache key', () => { + it('exports the correct LAST_INDEXED_LEDGER_KEY constant', () => { + expect(LAST_INDEXED_LEDGER_KEY).toBe('indexer:last_ledger'); + }); + }); +}); diff --git a/apps/api/src/metrics/indexer-monitor.service.ts b/apps/api/src/metrics/indexer-monitor.service.ts index 5032b29..a3ce629 100644 --- a/apps/api/src/metrics/indexer-monitor.service.ts +++ b/apps/api/src/metrics/indexer-monitor.service.ts @@ -49,10 +49,13 @@ export class IndexerMonitorService implements OnModuleInit, OnModuleDestroy { ]); const latestLedger = latestLedgerStr ?? 0; + const hasCheckpoint = lastIndexedStr !== null && lastIndexedStr !== undefined; const lastIndexedLedger = lastIndexedStr ?? 0; - const lagLedgers = Math.max(0, latestLedger - lastIndexedLedger); + const lagLedgers = hasCheckpoint + ? Math.max(0, latestLedger - lastIndexedLedger) + : 0; const lagSeconds = lagLedgers * LEDGER_CLOSE_SECONDS; - const status = this.computeStatus(lagLedgers); + const status = this.computeStatus(lagLedgers, hasCheckpoint); return { lastIndexedLedger, latestLedger, lagLedgers, lagSeconds, status }; } @@ -76,7 +79,8 @@ export class IndexerMonitorService implements OnModuleInit, OnModuleDestroy { } } - private computeStatus(lagLedgers: number): IndexerStatus { + private computeStatus(lagLedgers: number, hasCheckpoint: boolean): IndexerStatus { + if (!hasCheckpoint) return 'healthy'; if (lagLedgers < 10) return 'healthy'; if (lagLedgers <= 50) return 'degraded'; return 'critical'; diff --git a/apps/api/src/metrics/metrics.controller.ts b/apps/api/src/metrics/metrics.controller.ts index 5777e4d..c368fd4 100644 --- a/apps/api/src/metrics/metrics.controller.ts +++ b/apps/api/src/metrics/metrics.controller.ts @@ -5,10 +5,14 @@ import { UnauthorizedException, } from '@nestjs/common'; import { DbMetricsService } from './db-metrics.service'; +import { IndexerMonitorService } from './indexer-monitor.service'; @Controller('metrics') export class MetricsController { - constructor(private readonly dbMetrics: DbMetricsService) {} + constructor( + private readonly dbMetrics: DbMetricsService, + private readonly indexerMonitor: IndexerMonitorService, + ) {} @Get('db') async getDbMetrics(@Headers('x-internal-key') key: string) { @@ -16,4 +20,11 @@ export class MetricsController { if (!expected || key !== expected) throw new UnauthorizedException(); return this.dbMetrics.snapshot(); } + + @Get('indexer') + async getIndexerMetrics(@Headers('x-internal-key') key: string) { + const expected = process.env.INTERNAL_API_KEY; + if (!expected || key !== expected) throw new UnauthorizedException(); + return this.indexerMonitor.getMetrics(); + } } diff --git a/apps/api/src/metrics/metrics.module.ts b/apps/api/src/metrics/metrics.module.ts index ebf9583..8cc58dc 100644 --- a/apps/api/src/metrics/metrics.module.ts +++ b/apps/api/src/metrics/metrics.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { CacheModule } from '../cache/cache.module'; import { DbMetricsService } from './db-metrics.service'; +import { IndexerMonitorService } from './indexer-monitor.service'; import { MetricsController } from './metrics.controller'; @Module({ imports: [CacheModule], - providers: [DbMetricsService], + providers: [DbMetricsService, IndexerMonitorService], controllers: [MetricsController], - exports: [DbMetricsService], + exports: [DbMetricsService, IndexerMonitorService], }) export class MetricsModule {} diff --git a/apps/api/src/swaps/swaps.e2e.spec.ts b/apps/api/src/swaps/swaps.e2e.spec.ts new file mode 100644 index 0000000..7b91d74 --- /dev/null +++ b/apps/api/src/swaps/swaps.e2e.spec.ts @@ -0,0 +1,146 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { SwapsService } from './swaps.service'; +import { SwapsRepository } from './swaps.repository'; +import { SwapsController } from './swaps.controller'; + +const mockSwap = { + id: 'swap-e2e-1', + poolId: 'pool-e2e-1', + token0Symbol: 'XLM', + token1Symbol: 'USDC', + amount0: '1000000', + amount1: '-500000', + priceAtSwap: '0.5', + feeAmount: '300', + txHash: 'txhash-e2e-1', + walletAddress: '0xSender', + timestamp: 1700000000000, +}; + +describe('Swaps E2E (mocked RPC)', () => { + let app: INestApplication; + let mockRepo: jest.Mocked; + + beforeEach(async () => { + mockRepo = { + listSwaps: jest.fn(), + } as unknown as jest.Mocked; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [SwapsController], + providers: [ + SwapsService, + { provide: SwapsRepository, useValue: mockRepo }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + jest.clearAllMocks(); + }); + + describe('GET /swaps', () => { + it('returns paginated swap list', async () => { + mockRepo.listSwaps.mockResolvedValue({ items: [mockSwap], total: 1 }); + + const res = await request(app.getHttpServer()) + .get('/swaps') + .query({ page: 1, limit: 10 }) + .expect(200); + + expect(res.body.items).toHaveLength(1); + expect(res.body.total).toBe(1); + expect(res.body.totalPages).toBe(1); + expect(res.body.items[0].poolId).toBe('pool-e2e-1'); + }); + + it('returns empty list when no swaps exist', async () => { + mockRepo.listSwaps.mockResolvedValue({ items: [], total: 0 }); + + const res = await request(app.getHttpServer()) + .get('/swaps') + .expect(200); + + expect(res.body.items).toEqual([]); + expect(res.body.total).toBe(0); + expect(res.body.totalPages).toBe(0); + }); + + it('filters swaps by pool id', async () => { + mockRepo.listSwaps.mockResolvedValue({ items: [mockSwap], total: 1 }); + + const res = await request(app.getHttpServer()) + .get('/swaps') + .query({ pool: 'pool-e2e-1' }) + .expect(200); + + expect(mockRepo.listSwaps).toHaveBeenCalledWith( + expect.objectContaining({ pool: 'pool-e2e-1' }), + ); + expect(res.body.items[0].poolId).toBe('pool-e2e-1'); + }); + + it('filters swaps by wallet address', async () => { + mockRepo.listSwaps.mockResolvedValue({ items: [mockSwap], total: 1 }); + + await request(app.getHttpServer()) + .get('/swaps') + .query({ wallet: '0xSender' }) + .expect(200); + + expect(mockRepo.listSwaps).toHaveBeenCalledWith( + expect.objectContaining({ wallet: '0xSender' }), + ); + }); + + it('applies default pagination when no page/limit supplied', async () => { + mockRepo.listSwaps.mockResolvedValue({ items: [], total: 0 }); + + await request(app.getHttpServer()).get('/swaps').expect(200); + + expect(mockRepo.listSwaps).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, limit: 20 }), + ); + }); + + it('maps feeAmount from repository to response', async () => { + mockRepo.listSwaps.mockResolvedValue({ items: [mockSwap], total: 1 }); + + const res = await request(app.getHttpServer()).get('/swaps').expect(200); + + expect(res.body.items[0].feeAmount).toBe('300'); + }); + + it('maps transactionHash from repository txHash', async () => { + mockRepo.listSwaps.mockResolvedValue({ items: [mockSwap], total: 1 }); + + const res = await request(app.getHttpServer()).get('/swaps').expect(200); + + expect(res.body.items[0].transactionHash).toBe('txhash-e2e-1'); + }); + + it('returns 500 when repository throws an unexpected error', async () => { + mockRepo.listSwaps.mockRejectedValue(new Error('DB unavailable')); + + await request(app.getHttpServer()).get('/swaps').expect(500); + }); + + it('correctly computes totalPages for multi-page result', async () => { + mockRepo.listSwaps.mockResolvedValue({ items: [mockSwap], total: 25 }); + + const res = await request(app.getHttpServer()) + .get('/swaps') + .query({ limit: 10 }) + .expect(200); + + expect(res.body.totalPages).toBe(3); + }); + }); +});