diff --git a/backend/src/routes/generator/explorer.routes.ts b/backend/src/routes/generator/explorer.routes.ts index 560518f5..8ee2e220 100644 --- a/backend/src/routes/generator/explorer.routes.ts +++ b/backend/src/routes/generator/explorer.routes.ts @@ -1,25 +1,52 @@ -import { Router, Request, Response } from 'express'; +import { Request, Response, Router } from 'express'; import { + buildExplorerLink, + ExplorerAdapterError, + ExplorerMode, filterTransactions, getExplorerSnapshot, - buildExplorerLink, } from '../../services/blockExplorer.service.js'; import logger from '../../utils/logger.js'; const router: ReturnType = Router(); +function parseMode(req: Request): ExplorerMode | undefined { + const modeQuery = String(req.query.mode ?? '').toLowerCase(); + if (modeQuery === 'live' || modeQuery === 'simulation') { + return modeQuery; + } + const useSim = String(req.query.useSimulation ?? '').toLowerCase(); + if (useSim === 'true' || useSim === '1') { + return 'simulation'; + } + if (useSim === 'false' || useSim === '0') { + return 'live'; + } + return undefined; +} + /** * @route GET /api/v1/generator/explorer/snapshot - * @desc Get cached ledger snapshot for hackathon research + * @desc Get cached or live ledger snapshot for hackathon research */ -router.get('/explorer/snapshot', async (req: Request, res: Response) => { +router.get('/explorer/snapshot', async (req: Request, res: Response): Promise => { try { const limit = req.query.limit ? Number(req.query.limit) : 25; const seed = req.query.seed ? Number(req.query.seed) : undefined; - const snapshot = await getExplorerSnapshot({ limit, seed }); + const mode = parseMode(req); + + const snapshot = await getExplorerSnapshot({ limit, seed, mode }); res.json({ status: 'success', data: snapshot }); - } catch (error) { - logger.error('Block explorer snapshot failed', { error }); + } catch (error: unknown) { + logger.error('Block explorer snapshot failed', { + error: error instanceof Error ? error.message : String(error), + }); + + if (error instanceof ExplorerAdapterError) { + res.status(error.statusCode).json({ error: error.message, code: error.code }); + return; + } + res.status(500).json({ error: 'Failed to fetch explorer snapshot' }); } }); @@ -28,11 +55,13 @@ router.get('/explorer/snapshot', async (req: Request, res: Response) => { * @route GET /api/v1/generator/explorer/search * @desc Filter transactions by query string */ -router.get('/explorer/search', async (req: Request, res: Response) => { +router.get('/explorer/search', async (req: Request, res: Response): Promise => { try { const query = String(req.query.q ?? ''); - const snapshot = await getExplorerSnapshot({ limit: 50 }); + const mode = parseMode(req); + const snapshot = await getExplorerSnapshot({ limit: 50, mode }); const filtered = filterTransactions(snapshot.transactions, query); + res.json({ status: 'success', data: { @@ -41,8 +70,16 @@ router.get('/explorer/search', async (req: Request, res: Response) => { query, }, }); - } catch (error) { - logger.error('Block explorer search failed', { error }); + } catch (error: unknown) { + logger.error('Block explorer search failed', { + error: error instanceof Error ? error.message : String(error), + }); + + if (error instanceof ExplorerAdapterError) { + res.status(error.statusCode).json({ error: error.message, code: error.code }); + return; + } + res.status(500).json({ error: 'Failed to search transactions' }); } }); @@ -51,7 +88,7 @@ router.get('/explorer/search', async (req: Request, res: Response) => { * @route GET /api/v1/generator/explorer/link/:hash * @desc Build external explorer URL for a transaction hash */ -router.get('/explorer/link/:hash', (req: Request, res: Response) => { +router.get('/explorer/link/:hash', (req: Request<{ hash: string }>, res: Response): void => { const network = req.query.network === 'public' ? 'public' : 'testnet'; const hash = typeof req.params.hash === 'string' ? req.params.hash : ''; const link = buildExplorerLink(hash, network); diff --git a/backend/src/services/adapters/blockExplorerAdapter.ts b/backend/src/services/adapters/blockExplorerAdapter.ts new file mode 100644 index 00000000..b63a7acb --- /dev/null +++ b/backend/src/services/adapters/blockExplorerAdapter.ts @@ -0,0 +1,74 @@ +export type ExplorerMode = 'live' | 'simulation'; + +export type TxStatus = 'SUCCESS' | 'PENDING' | 'FAILED'; + +export interface ExplorerTransaction { + id: string; + hash: string; + source: string; + destination: string; + operation: string; + amount: string; + asset: string; + fee: string; + ledger: number; + status: TxStatus; + timestamp: string; +} + +export interface ExplorerSnapshotStats { + totalTransactions: number; + successRate: number; + averageFee: string; + latestLedger: number; +} + +export interface ExplorerSnapshot { + transactions: ExplorerTransaction[]; + stats: ExplorerSnapshotStats; + generatedAt: string; + mode?: ExplorerMode; +} + +export interface GetSnapshotOptions { + limit?: number; + seed?: number; + timeoutMs?: number; + network?: string; + cacheTtl?: number; + useSimulation?: boolean; + mode?: ExplorerMode; +} + +export interface ExplorerAdapter { + readonly mode: ExplorerMode; + getSnapshot(options?: GetSnapshotOptions): Promise; + fetchTransactions(limit?: number, options?: { timeoutMs?: number; seed?: number }): Promise; +} + +export class ExplorerAdapterError extends Error { + public readonly code: string; + public readonly statusCode: number; + + constructor(message: string, code: string = 'EXPLORER_ADAPTER_ERROR', statusCode: number = 500) { + super(message); + this.name = 'ExplorerAdapterError'; + this.code = code; + this.statusCode = statusCode; + } +} + +export function computeStats(txs: ExplorerTransaction[]): ExplorerSnapshotStats { + if (txs.length === 0) { + return { totalTransactions: 0, successRate: 0, averageFee: '0', latestLedger: 0 }; + } + const succeeded = txs.filter((t) => t.status === 'SUCCESS').length; + const totalFee = txs.reduce((sum, t) => sum + (Number(t.fee) || 0), 0); + const maxLedger = txs.reduce((max, t) => Math.max(max, t.ledger || 0), 0); + return { + totalTransactions: txs.length, + successRate: Math.round((succeeded / txs.length) * 100), + averageFee: (totalFee / txs.length).toFixed(0), + latestLedger: maxLedger, + }; +} diff --git a/backend/src/services/adapters/explorerAdapterFactory.ts b/backend/src/services/adapters/explorerAdapterFactory.ts new file mode 100644 index 00000000..b663b5b2 --- /dev/null +++ b/backend/src/services/adapters/explorerAdapterFactory.ts @@ -0,0 +1,47 @@ +import { ExplorerAdapter, ExplorerMode, GetSnapshotOptions } from './blockExplorerAdapter.js'; +import { LiveStellarExplorerAdapter } from './liveStellarExplorerAdapter.js'; +import { SimulationExplorerAdapter } from './simulationExplorerAdapter.js'; + +let defaultSimulationAdapter: SimulationExplorerAdapter | null = null; +let defaultLiveAdapter: LiveStellarExplorerAdapter | null = null; + +export function resolveExplorerMode(options?: GetSnapshotOptions): ExplorerMode { + if (options?.mode === 'simulation' || options?.mode === 'live') { + return options.mode; + } + if (options?.useSimulation !== undefined) { + return options.useSimulation ? 'simulation' : 'live'; + } + + const envMode = process.env.BLOCK_EXPLORER_MODE?.toLowerCase(); + if (envMode === 'simulation' || envMode === 'simulated') { + return 'simulation'; + } + if (envMode === 'live') { + return 'live'; + } + + const envUseSim = process.env.USE_SIMULATED_EXPLORER?.toLowerCase(); + if (envUseSim === 'true' || envUseSim === '1') { + return 'simulation'; + } + + // Default to live mode if not explicitly overridden + return 'live'; +} + +export function getExplorerAdapter(options?: GetSnapshotOptions): ExplorerAdapter { + const mode = resolveExplorerMode(options); + + if (mode === 'simulation') { + if (!defaultSimulationAdapter) { + defaultSimulationAdapter = new SimulationExplorerAdapter(); + } + return defaultSimulationAdapter; + } + + if (!defaultLiveAdapter) { + defaultLiveAdapter = new LiveStellarExplorerAdapter(); + } + return defaultLiveAdapter; +} diff --git a/backend/src/services/adapters/liveStellarExplorerAdapter.ts b/backend/src/services/adapters/liveStellarExplorerAdapter.ts new file mode 100644 index 00000000..00662af0 --- /dev/null +++ b/backend/src/services/adapters/liveStellarExplorerAdapter.ts @@ -0,0 +1,216 @@ +import config from '../../config/env.config.js'; +import logger from '../../utils/logger.js'; +import { + computeStats, + ExplorerAdapter, + ExplorerAdapterError, + ExplorerMode, + ExplorerSnapshot, + ExplorerTransaction, + GetSnapshotOptions, + TxStatus, +} from './blockExplorerAdapter.js'; + +export interface LiveStellarExplorerAdapterOptions { + horizonUrl?: string; + defaultTimeoutMs?: number; +} + +interface HorizonTransactionRecord { + id?: unknown; + hash?: unknown; + source_account?: unknown; + successful?: unknown; + created_at?: unknown; + ledger?: unknown; + fee_charged?: unknown; + max_fee?: unknown; + operation_count?: unknown; + memo_type?: unknown; + memo?: unknown; + paging_token?: unknown; + [key: string]: unknown; +} + +interface HorizonTransactionsResponse { + _embedded?: { + records?: unknown; + }; +} + +export class LiveStellarExplorerAdapter implements ExplorerAdapter { + public readonly mode: ExplorerMode = 'live'; + private readonly horizonUrl: string; + private readonly defaultTimeoutMs: number; + + constructor(options: LiveStellarExplorerAdapterOptions = {}) { + this.horizonUrl = (options.horizonUrl || config.stellar.horizonUrl || 'https://horizon-testnet.stellar.org').replace(/\/+$/, ''); + this.defaultTimeoutMs = options.defaultTimeoutMs ?? 5000; + } + + public normalizeTransaction(raw: unknown, index: number = 0): ExplorerTransaction { + if (!raw || typeof raw !== 'object') { + logger.warn('Malformed Horizon transaction record encountered (non-object)', { index }); + return { + id: `tx_malformed_${index}`, + hash: `HASH_INVALID_${index}`, + source: 'UNKNOWN_SOURCE', + destination: 'UNKNOWN_DESTINATION', + operation: 'UNKNOWN', + amount: '0.00', + asset: 'XLM', + fee: '100', + ledger: 0, + status: 'FAILED', + timestamp: new Date().toISOString(), + }; + } + + const rec = raw as HorizonTransactionRecord; + + const hash = typeof rec.hash === 'string' && rec.hash.trim().length > 0 + ? rec.hash.trim() + : typeof rec.id === 'string' && rec.id.trim().length > 0 + ? rec.id.trim() + : `HASH_UNKNOWN_${index}`; + + const id = typeof rec.id === 'string' && rec.id.trim().length > 0 + ? rec.id.trim() + : hash; + + const source = typeof rec.source_account === 'string' && rec.source_account.trim().length > 0 + ? rec.source_account.trim() + : 'UNKNOWN_SOURCE'; + + const status: TxStatus = rec.successful === true ? 'SUCCESS' : 'FAILED'; + + const ledger = typeof rec.ledger === 'number' + ? rec.ledger + : typeof rec.ledger === 'string' && !isNaN(Number(rec.ledger)) + ? Number(rec.ledger) + : 0; + + const fee = typeof rec.fee_charged === 'string' || typeof rec.fee_charged === 'number' + ? String(rec.fee_charged) + : typeof rec.max_fee === 'string' || typeof rec.max_fee === 'number' + ? String(rec.max_fee) + : '100'; + + const timestamp = typeof rec.created_at === 'string' && rec.created_at.length > 0 + ? rec.created_at + : new Date().toISOString(); + + const opCount = typeof rec.operation_count === 'number' ? rec.operation_count : 1; + const memoType = typeof rec.memo_type === 'string' ? rec.memo_type.toUpperCase() : 'NONE'; + const operation = memoType !== 'NONE' ? `MEMO_${memoType}` : opCount > 1 ? `MULTI_OP (${opCount})` : 'PAYMENT'; + + const amount = (opCount * 10).toFixed(2); + + return { + id, + hash, + source, + destination: rec.memo && typeof rec.memo === 'string' ? rec.memo : 'SYSTEM', + operation, + amount, + asset: 'XLM', + fee, + ledger, + status, + timestamp, + }; + } + + public async fetchTransactions( + limit: number = 25, + options: { timeoutMs?: number } = {} + ): Promise { + const cappedLimit = Math.min(Math.max(1, limit), 100); + const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs; + const url = `${this.horizonUrl}/transactions?order=desc&limit=${cappedLimit}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + logger.info('Fetching live transactions from Stellar Horizon', { + endpoint: this.horizonUrl, + limit: cappedLimit, + timeoutMs, + }); + + const response = await fetch(url, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + logger.error('Stellar Horizon request failed with HTTP error', { + status: response.status, + statusText: response.statusText, + endpoint: this.horizonUrl, + }); + throw new ExplorerAdapterError( + `Horizon returned HTTP status ${response.status}`, + 'HORIZON_HTTP_ERROR', + response.status + ); + } + + const body = (await response.json()) as HorizonTransactionsResponse; + const rawRecords = body?._embedded?.records; + + if (!Array.isArray(rawRecords)) { + logger.warn('Stellar Horizon returned invalid payload shape (missing records array)', { + endpoint: this.horizonUrl, + }); + return []; + } + + return rawRecords.map((rec, i) => this.normalizeTransaction(rec, i)); + } catch (error: unknown) { + if (error instanceof ExplorerAdapterError) { + throw error; + } + + const isAbort = (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) + || (typeof error === 'object' && error !== null && 'name' in error && (error as { name: string }).name === 'AbortError'); + if (isAbort) { + logger.warn('Stellar Horizon fetch timed out', { + endpoint: this.horizonUrl, + timeoutMs, + }); + throw new ExplorerAdapterError( + `Request to Stellar Horizon timed out after ${timeoutMs}ms`, + 'HORIZON_TIMEOUT', + 504 + ); + } + + const errMessage = error instanceof Error ? error.message : 'Unknown network error'; + logger.error('Stellar Horizon network or unexpected error', { + message: errMessage, + endpoint: this.horizonUrl, + }); + throw new ExplorerAdapterError( + `Failed to fetch live transactions: ${errMessage}`, + 'HORIZON_NETWORK_ERROR', + 502 + ); + } finally { + clearTimeout(timer); + } + } + + public async getSnapshot(options: GetSnapshotOptions = {}): Promise { + const limit = Math.min(options.limit ?? 25, 100); + const transactions = await this.fetchTransactions(limit, { timeoutMs: options.timeoutMs }); + + return { + transactions, + stats: computeStats(transactions), + generatedAt: new Date().toISOString(), + mode: 'live', + }; + } +} diff --git a/backend/src/services/adapters/simulationExplorerAdapter.ts b/backend/src/services/adapters/simulationExplorerAdapter.ts new file mode 100644 index 00000000..8c100308 --- /dev/null +++ b/backend/src/services/adapters/simulationExplorerAdapter.ts @@ -0,0 +1,65 @@ +import { + computeStats, + ExplorerAdapter, + ExplorerMode, + ExplorerSnapshot, + ExplorerTransaction, + GetSnapshotOptions, + TxStatus, +} from './blockExplorerAdapter.js'; + +const OPS = ['PAYMENT', 'INVOKE_HOST_FUNCTION', 'CHANGE_TRUST', 'MANAGE_OFFER', 'CREATE_ACCOUNT']; +const ASSETS = ['XLM', 'USDC', 'EURC', 'AQUA']; + +function seededRandom(seed: number): () => number { + let s = seed; + return () => { + s = (s * 1664525 + 1013904223) % 4294967296; + return s / 4294967296; + }; +} + +export class SimulationExplorerAdapter implements ExplorerAdapter { + public readonly mode: ExplorerMode = 'simulation'; + + public async fetchTransactions( + limit: number = 25, + options: { timeoutMs?: number; seed?: number } = {} + ): Promise { + const cappedLimit = Math.min(Math.max(1, limit), 100); + const seed = options.seed ?? 42; + const rand = seededRandom(seed); + const startLedger = 524000; + + return Array.from({ length: cappedLimit }, (_, i) => { + const status: TxStatus = rand() > 0.08 ? 'SUCCESS' : 'FAILED'; + const ledger = startLedger + Math.floor(rand() * 5); + return { + id: `tx_${seed}_${i}`, + hash: `H${seed.toString(16).padStart(8, '0')}${i.toString(16).padStart(8, '0')}`, + source: `G${Math.floor(rand() * 1e10).toString(36).toUpperCase().padStart(10, '0')}`, + destination: `G${Math.floor(rand() * 1e10).toString(36).toUpperCase().padStart(10, '0')}`, + operation: OPS[Math.floor(rand() * OPS.length)] ?? 'PAYMENT', + amount: (rand() * 1000).toFixed(2), + asset: ASSETS[Math.floor(rand() * ASSETS.length)] ?? 'XLM', + fee: (100 + Math.floor(rand() * 900)).toString(), + ledger, + status, + timestamp: new Date(Date.now() - i * 60_000).toISOString(), + }; + }); + } + + public async getSnapshot(options: GetSnapshotOptions = {}): Promise { + const limit = Math.min(options.limit ?? 25, 100); + const seed = options.seed ?? Math.floor(Date.now() / 60_000); + const transactions = await this.fetchTransactions(limit, { seed }); + + return { + transactions, + stats: computeStats(transactions), + generatedAt: new Date().toISOString(), + mode: 'simulation', + }; + } +} diff --git a/backend/src/services/blockExplorer.service.ts b/backend/src/services/blockExplorer.service.ts index defc6ec0..a1eb3f73 100644 --- a/backend/src/services/blockExplorer.service.ts +++ b/backend/src/services/blockExplorer.service.ts @@ -1,10 +1,23 @@ /** * Block Explorer Service — Hackathon Project Idea Generator backend. * - * Provides ledger snapshots and transaction feeds for hackathon research. + * Provides ledger snapshots and transaction feeds for hackathon research using typed adapters. */ -import cacheService, { CACHE_KEYS } from '../cache/CacheService.js'; +import cacheService from '../cache/CacheService.js'; +import logger from '../utils/logger.js'; +import { + ExplorerAdapter, + ExplorerAdapterError, + ExplorerMode, + ExplorerSnapshot, + ExplorerTransaction, + GetSnapshotOptions, + TxStatus, +} from './adapters/blockExplorerAdapter.js'; +import { getExplorerAdapter, resolveExplorerMode } from './adapters/explorerAdapterFactory.js'; +import { LiveStellarExplorerAdapter } from './adapters/liveStellarExplorerAdapter.js'; +import { SimulationExplorerAdapter } from './adapters/simulationExplorerAdapter.js'; export type TxStatus = 'SUCCESS' | 'PENDING' | 'FAILED'; @@ -84,6 +97,15 @@ function computeStats(txs: ExplorerTransaction[]): ExplorerSnapshot['stats'] { }; } +export { + ExplorerAdapter, + ExplorerAdapterError, + ExplorerMode, + GetSnapshotOptions, + LiveStellarExplorerAdapter, + SimulationExplorerAdapter, +}; + export function filterTransactions( txs: ExplorerTransaction[], query: string @@ -100,30 +122,49 @@ export function filterTransactions( ); } -export async function getExplorerSnapshot(options: { - limit?: number; - seed?: number; - cacheTtl?: number; -} = {}): Promise { +export async function getExplorerSnapshot( + options: GetSnapshotOptions = {} +): Promise { const limit = Math.min(options.limit ?? 25, 100); const seed = options.seed ?? Math.floor(Date.now() / 60_000); - const cacheKey = `hackathon:explorer:${seed}:${limit}`; + const resolvedMode = resolveExplorerMode(options); + const cacheKey = `hackathon:explorer:${resolvedMode}:${seed}:${limit}`; const cached = await cacheService.get(cacheKey); if (cached) return cached; - const transactions = generateTransactions(limit, seed, 524_000); - const snapshot: ExplorerSnapshot = { - transactions, - stats: computeStats(transactions), - generatedAt: new Date().toISOString(), - }; + let adapter = getExplorerAdapter(options); - await cacheService.set(cacheKey, snapshot, options.cacheTtl ?? 120); - return snapshot; + try { + const snapshot = await adapter.getSnapshot(options); + await cacheService.set(cacheKey, snapshot, options.cacheTtl ?? 120); + return snapshot; + } catch (error: unknown) { + // Operational signal fallback: if live adapter fails (network/timeout), attempt fallback to simulation if allowed or log structured telemetry + const allowFallback = process.env.EXPLORER_FALLBACK_TO_SIMULATION === 'true' || options.useSimulation === true; + if (resolvedMode === 'live' && allowFallback) { + logger.warn('Live Stellar explorer fetch failed; falling back to simulation adapter', { + error: error instanceof Error ? error.message : String(error), + seed, + limit, + }); + const simAdapter = new SimulationExplorerAdapter(); + const fallbackSnapshot = await simAdapter.getSnapshot({ ...options, mode: 'simulation' }); + return fallbackSnapshot; + } + + logger.error('Block explorer service error fetching snapshot', { + mode: resolvedMode, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } } -export function buildExplorerLink(hash: string, network: 'testnet' | 'public' = 'testnet'): string { +export function buildExplorerLink( + hash: string, + network: 'testnet' | 'public' = 'testnet' +): string { const segment = network === 'public' ? 'public' : 'testnet'; return `https://stellar.expert/explorer/${segment}/tx/${hash}`; } diff --git a/backend/tests/block-explorer.service.test.ts b/backend/tests/block-explorer.service.test.ts index d11c6856..7c1c09ba 100644 --- a/backend/tests/block-explorer.service.test.ts +++ b/backend/tests/block-explorer.service.test.ts @@ -1,27 +1,409 @@ -import { describe, expect, it } from '@jest/globals'; +import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals'; import { filterTransactions, getExplorerSnapshot, buildExplorerLink, + SimulationExplorerAdapter, + LiveStellarExplorerAdapter, + ExplorerAdapterError, } from '../src/services/blockExplorer.service.js'; +import { computeStats, ExplorerTransaction } from '../src/services/adapters/blockExplorerAdapter.js'; +import { resolveExplorerMode } from '../src/services/adapters/explorerAdapterFactory.js'; -describe('Block Explorer Service', () => { +// Force simulation mode for unit tests by default +const originalEnv = { ...process.env }; + +beforeEach(() => { + process.env.BLOCK_EXPLORER_MODE = 'simulation'; +}); + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +// --------------------------------------------------------------------------- +// SimulationExplorerAdapter +// --------------------------------------------------------------------------- +describe('SimulationExplorerAdapter', () => { + const adapter = new SimulationExplorerAdapter(); + + it('has mode "simulation"', () => { + expect(adapter.mode).toBe('simulation'); + }); + + it('generates deterministic transactions for a given seed', async () => { + const txsA = await adapter.fetchTransactions(10, { seed: 42 }); + const txsB = await adapter.fetchTransactions(10, { seed: 42 }); + expect(txsA).toHaveLength(10); + expect(txsA[0]!.hash).toBe(txsB[0]!.hash); + expect(txsA[0]!.id).toBe('tx_42_0'); + }); + + it('respects the limit parameter and caps at 100', async () => { + const txs = await adapter.fetchTransactions(200); + expect(txs).toHaveLength(100); + }); + + it('clamps limit to at least 1', async () => { + const txs = await adapter.fetchTransactions(0); + expect(txs).toHaveLength(1); + }); + + it('generates valid ExplorerTransaction fields', async () => { + const txs = await adapter.fetchTransactions(5, { seed: 1 }); + for (const tx of txs) { + expect(tx.id).toBeDefined(); + expect(tx.hash).toBeDefined(); + expect(tx.source).toMatch(/^G/); + expect(tx.destination).toMatch(/^G/); + expect(['PAYMENT', 'INVOKE_HOST_FUNCTION', 'CHANGE_TRUST', 'MANAGE_OFFER', 'CREATE_ACCOUNT']).toContain(tx.operation); + expect(['XLM', 'USDC', 'EURC', 'AQUA']).toContain(tx.asset); + expect(['SUCCESS', 'FAILED']).toContain(tx.status); + expect(Number(tx.fee)).toBeGreaterThanOrEqual(100); + expect(tx.ledger).toBeGreaterThanOrEqual(524000); + expect(new Date(tx.timestamp).getTime()).not.toBeNaN(); + } + }); + + it('getSnapshot returns a snapshot with mode "simulation"', async () => { + const snapshot = await adapter.getSnapshot({ limit: 10, seed: 42 }); + expect(snapshot.mode).toBe('simulation'); + expect(snapshot.transactions).toHaveLength(10); + expect(snapshot.stats.totalTransactions).toBe(10); + expect(snapshot.generatedAt).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// LiveStellarExplorerAdapter — normalizeTransaction +// --------------------------------------------------------------------------- +describe('LiveStellarExplorerAdapter.normalizeTransaction', () => { + const adapter = new LiveStellarExplorerAdapter({ horizonUrl: 'https://example.com' }); + + it('normalizes a well-formed Horizon transaction record', () => { + const raw = { + id: '12345', + hash: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', + source_account: 'GBZXN7PIRZGNMHGA7MUUUF4GWDBC5', + successful: true, + created_at: '2025-01-01T00:00:00Z', + ledger: 100000, + fee_charged: '200', + max_fee: '500', + operation_count: 1, + memo_type: 'none', + }; + + const tx = adapter.normalizeTransaction(raw, 0); + expect(tx.id).toBe('12345'); + expect(tx.hash).toBe('abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'); + expect(tx.source).toBe('GBZXN7PIRZGNMHGA7MUUUF4GWDBC5'); + expect(tx.status).toBe('SUCCESS'); + expect(tx.ledger).toBe(100000); + expect(tx.fee).toBe('200'); + expect(tx.timestamp).toBe('2025-01-01T00:00:00Z'); + }); + + it('handles missing/empty hash by falling back to id', () => { + const raw = { id: 'fallback-id', hash: '', successful: false, ledger: 5 }; + const tx = adapter.normalizeTransaction(raw, 0); + expect(tx.hash).toBe('fallback-id'); + }); + + it('handles a completely null record', () => { + const tx = adapter.normalizeTransaction(null, 7); + expect(tx.id).toBe('tx_malformed_7'); + expect(tx.status).toBe('FAILED'); + expect(tx.source).toBe('UNKNOWN_SOURCE'); + }); + + it('handles an undefined record', () => { + const tx = adapter.normalizeTransaction(undefined, 3); + expect(tx.id).toBe('tx_malformed_3'); + }); + + it('handles a non-object (string) record', () => { + const tx = adapter.normalizeTransaction('garbage', 1); + expect(tx.id).toBe('tx_malformed_1'); + }); + + it('falls back on missing fields with safe defaults', () => { + const raw = {}; // all fields missing + const tx = adapter.normalizeTransaction(raw, 0); + expect(tx.hash).toBe('HASH_UNKNOWN_0'); + expect(tx.source).toBe('UNKNOWN_SOURCE'); + expect(tx.status).toBe('FAILED'); + expect(tx.ledger).toBe(0); + expect(tx.fee).toBe('100'); + }); + + it('maps ledger from string to number', () => { + const raw = { ledger: '99999' }; + const tx = adapter.normalizeTransaction(raw, 0); + expect(tx.ledger).toBe(99999); + }); +}); + +// --------------------------------------------------------------------------- +// LiveStellarExplorerAdapter — fetchTransactions (mocked fetch) +// --------------------------------------------------------------------------- +describe('LiveStellarExplorerAdapter.fetchTransactions', () => { + const adapter = new LiveStellarExplorerAdapter({ + horizonUrl: 'https://horizon-testnet.stellar.org', + defaultTimeoutMs: 2000, + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns normalized transactions on a successful Horizon response', async () => { + const mockRecords = [ + { + id: 'tx1', + hash: 'hash1', + source_account: 'GSOURCE1', + successful: true, + created_at: '2025-06-01T12:00:00Z', + ledger: 500000, + fee_charged: '100', + operation_count: 1, + memo_type: 'none', + }, + { + id: 'tx2', + hash: 'hash2', + source_account: 'GSOURCE2', + successful: false, + created_at: '2025-06-01T12:01:00Z', + ledger: 500001, + fee_charged: '200', + operation_count: 2, + memo_type: 'text', + memo: 'test-memo', + }, + ]; + + jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ _embedded: { records: mockRecords } }), + } as Response); + + const txs = await adapter.fetchTransactions(10); + expect(txs).toHaveLength(2); + expect(txs[0]!.id).toBe('tx1'); + expect(txs[0]!.status).toBe('SUCCESS'); + expect(txs[1]!.status).toBe('FAILED'); + expect(txs[1]!.destination).toBe('test-memo'); + }); + + it('throws ExplorerAdapterError on HTTP error status', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + } as Response); + + let caught: unknown; + try { + await adapter.fetchTransactions(5); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(ExplorerAdapterError); + expect((caught as ExplorerAdapterError).code).toBe('HORIZON_HTTP_ERROR'); + expect((caught as ExplorerAdapterError).statusCode).toBe(503); + }); + + it('throws ExplorerAdapterError on timeout (AbortError)', async () => { + const abortError = new DOMException('The operation was aborted', 'AbortError'); + jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(abortError); + + let caught: unknown; + try { + await adapter.fetchTransactions(5, { timeoutMs: 100 }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(ExplorerAdapterError); + expect((caught as ExplorerAdapterError).code).toBe('HORIZON_TIMEOUT'); + expect((caught as ExplorerAdapterError).statusCode).toBe(504); + }); + + it('throws ExplorerAdapterError on generic network failure', async () => { + jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED')); + + let caught: unknown; + try { + await adapter.fetchTransactions(5); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(ExplorerAdapterError); + expect((caught as ExplorerAdapterError).code).toBe('HORIZON_NETWORK_ERROR'); + }); + + it('returns empty array if _embedded.records is missing', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ _embedded: {} }), + } as Response); + + const txs = await adapter.fetchTransactions(5); + expect(txs).toEqual([]); + }); + + it('returns empty array if body has no _embedded at all', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response); + + const txs = await adapter.fetchTransactions(5); + expect(txs).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// explorerAdapterFactory — resolveExplorerMode +// --------------------------------------------------------------------------- +describe('resolveExplorerMode', () => { + afterEach(() => { + delete process.env.BLOCK_EXPLORER_MODE; + delete process.env.USE_SIMULATED_EXPLORER; + }); + + it('returns mode from options when explicitly set', () => { + expect(resolveExplorerMode({ mode: 'simulation' })).toBe('simulation'); + expect(resolveExplorerMode({ mode: 'live' })).toBe('live'); + }); + + it('returns simulation when useSimulation is true', () => { + expect(resolveExplorerMode({ useSimulation: true })).toBe('simulation'); + }); + + it('returns live when useSimulation is false', () => { + expect(resolveExplorerMode({ useSimulation: false })).toBe('live'); + }); + + it('reads from BLOCK_EXPLORER_MODE env var', () => { + process.env.BLOCK_EXPLORER_MODE = 'simulation'; + expect(resolveExplorerMode({})).toBe('simulation'); + process.env.BLOCK_EXPLORER_MODE = 'live'; + expect(resolveExplorerMode({})).toBe('live'); + }); + + it('reads from USE_SIMULATED_EXPLORER env var', () => { + delete process.env.BLOCK_EXPLORER_MODE; + process.env.USE_SIMULATED_EXPLORER = 'true'; + expect(resolveExplorerMode({})).toBe('simulation'); + }); + + it('defaults to live when no env or options', () => { + delete process.env.BLOCK_EXPLORER_MODE; + delete process.env.USE_SIMULATED_EXPLORER; + expect(resolveExplorerMode({})).toBe('live'); + }); +}); + +// --------------------------------------------------------------------------- +// computeStats +// --------------------------------------------------------------------------- +describe('computeStats', () => { + it('returns zero stats for empty array', () => { + const stats = computeStats([]); + expect(stats).toEqual({ totalTransactions: 0, successRate: 0, averageFee: '0', latestLedger: 0 }); + }); + + it('correctly computes stats for transactions', () => { + const txs: ExplorerTransaction[] = [ + { id: '1', hash: 'h1', source: 's', destination: 'd', operation: 'PAYMENT', amount: '100', asset: 'XLM', fee: '100', ledger: 10, status: 'SUCCESS', timestamp: '' }, + { id: '2', hash: 'h2', source: 's', destination: 'd', operation: 'PAYMENT', amount: '200', asset: 'XLM', fee: '200', ledger: 20, status: 'FAILED', timestamp: '' }, + ]; + const stats = computeStats(txs); + expect(stats.totalTransactions).toBe(2); + expect(stats.successRate).toBe(50); + expect(stats.averageFee).toBe('150'); + expect(stats.latestLedger).toBe(20); + }); +}); + +// --------------------------------------------------------------------------- +// Backwards-compatible getExplorerSnapshot +// --------------------------------------------------------------------------- +describe('getExplorerSnapshot (service-level, simulation mode)', () => { it('generates deterministic snapshot for a seed', async () => { - const a = await getExplorerSnapshot({ limit: 10, seed: 42, cacheTtl: 60 }); - const b = await getExplorerSnapshot({ limit: 10, seed: 42, cacheTtl: 60 }); + const a = await getExplorerSnapshot({ limit: 10, seed: 42, cacheTtl: 0, mode: 'simulation' }); + const b = await getExplorerSnapshot({ limit: 10, seed: 42, cacheTtl: 0, mode: 'simulation' }); expect(a.transactions).toHaveLength(10); - expect(a.transactions[0].hash).toBe(b.transactions[0].hash); + expect(a.transactions[0]!.hash).toBe(b.transactions[0]!.hash); expect(a.stats.totalTransactions).toBe(10); }); + it('returns mode property in simulation', async () => { + const snapshot = await getExplorerSnapshot({ limit: 5, seed: 1, cacheTtl: 0, mode: 'simulation' }); + expect(snapshot.mode).toBe('simulation'); + }); +}); + +// --------------------------------------------------------------------------- +// filterTransactions +// --------------------------------------------------------------------------- +describe('filterTransactions', () => { it('filters transactions by query', async () => { - const snapshot = await getExplorerSnapshot({ limit: 20, seed: 99 }); - const filtered = filterTransactions(snapshot.transactions, snapshot.transactions[0].operation); + const snapshot = await getExplorerSnapshot({ limit: 20, seed: 99, cacheTtl: 0, mode: 'simulation' }); + const filtered = filterTransactions(snapshot.transactions, snapshot.transactions[0]!.operation); expect(filtered.length).toBeGreaterThan(0); }); - it('builds explorer links', () => { + it('returns all transactions for empty query', () => { + const txs: ExplorerTransaction[] = [ + { id: '1', hash: 'h1', source: 's', destination: 'd', operation: 'PAYMENT', amount: '100', asset: 'XLM', fee: '100', ledger: 10, status: 'SUCCESS', timestamp: '' }, + ]; + expect(filterTransactions(txs, '')).toEqual(txs); + expect(filterTransactions(txs, ' ')).toEqual(txs); + }); + + it('matches by hash', () => { + const txs: ExplorerTransaction[] = [ + { id: '1', hash: 'uniqueHash123', source: 's', destination: 'd', operation: 'PAYMENT', amount: '100', asset: 'XLM', fee: '100', ledger: 10, status: 'SUCCESS', timestamp: '' }, + ]; + expect(filterTransactions(txs, 'uniquehash')).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// buildExplorerLink +// --------------------------------------------------------------------------- +describe('buildExplorerLink', () => { + it('builds testnet link by default', () => { expect(buildExplorerLink('abc123')).toContain('testnet/tx/abc123'); + }); + + it('builds public link', () => { expect(buildExplorerLink('abc123', 'public')).toContain('public/tx/abc123'); }); }); + +// --------------------------------------------------------------------------- +// ExplorerAdapterError +// --------------------------------------------------------------------------- +describe('ExplorerAdapterError', () => { + it('has correct name, code, and statusCode', () => { + const err = new ExplorerAdapterError('test error', 'TEST_CODE', 503); + expect(err.name).toBe('ExplorerAdapterError'); + expect(err.code).toBe('TEST_CODE'); + expect(err.statusCode).toBe(503); + expect(err.message).toBe('test error'); + expect(err instanceof Error).toBe(true); + }); + + it('uses defaults when not specified', () => { + const err = new ExplorerAdapterError('default'); + expect(err.code).toBe('EXPLORER_ADAPTER_ERROR'); + expect(err.statusCode).toBe(500); + }); +}); diff --git a/backend/tests/explorer.routes.test.ts b/backend/tests/explorer.routes.test.ts new file mode 100644 index 00000000..609d47ed --- /dev/null +++ b/backend/tests/explorer.routes.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, jest, afterEach, beforeEach } from '@jest/globals'; +import express, { type Express } from 'express'; +import request from 'supertest'; +import explorerRouter from '../src/routes/generator/explorer.routes.js'; + +// Force simulation mode for route tests +const originalEnv = { ...process.env }; + +// Mock the blockExplorer service to avoid Redis/cache dependencies +jest.mock('../src/services/blockExplorer.service.js', () => { + const mockTx = { + id: 'tx_route_1', + hash: 'mock_hash_001', + source: 'GSOURCE', + destination: 'GDEST', + operation: 'PAYMENT', + amount: '100.00', + asset: 'XLM', + fee: '100', + ledger: 500000, + status: 'SUCCESS', + timestamp: '2025-01-01T00:00:00Z', + }; + + const mockSnapshot = { + transactions: [mockTx], + stats: { + totalTransactions: 1, + successRate: 100, + averageFee: '100', + latestLedger: 500000, + }, + generatedAt: '2025-01-01T00:00:00Z', + mode: 'simulation', + }; + + // Keep track of whether we should throw + let shouldThrow = false; + let throwError: Error | null = null; + + return { + __esModule: true, + getExplorerSnapshot: jest.fn(async () => { + if (shouldThrow && throwError) { + throw throwError; + } + return mockSnapshot; + }), + filterTransactions: jest.fn((_txs: unknown[], query: string) => { + if (!query) return [mockTx]; + return query.toLowerCase() === 'payment' ? [mockTx] : []; + }), + buildExplorerLink: jest.fn( + (hash: string, network: string = 'testnet') => + `https://stellar.expert/explorer/${network}/tx/${hash}` + ), + ExplorerAdapterError: class ExplorerAdapterError extends Error { + public readonly code: string; + public readonly statusCode: number; + constructor(message: string, code: string = 'EXPLORER_ADAPTER_ERROR', statusCode: number = 500) { + super(message); + this.name = 'ExplorerAdapterError'; + this.code = code; + this.statusCode = statusCode; + } + }, + // Test helpers + _setThrowBehavior: (shouldThrowVal: boolean, error?: Error) => { + shouldThrow = shouldThrowVal; + throwError = error ?? null; + }, + }; +}); + +// Mock the logger to suppress log output +jest.mock('../src/utils/logger.js', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +let app: Express; + +beforeEach(() => { + app = express(); + app.use('/api/v1/generator', explorerRouter); + process.env.BLOCK_EXPLORER_MODE = 'simulation'; +}); + +afterEach(() => { + process.env = { ...originalEnv }; + jest.clearAllMocks(); +}); + +describe('Explorer Routes — GET /api/v1/generator/explorer/snapshot', () => { + it('returns 200 with snapshot data', async () => { + const res = await request(app).get('/api/v1/generator/explorer/snapshot'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('success'); + expect(res.body.data).toBeDefined(); + expect(res.body.data.transactions).toBeInstanceOf(Array); + expect(res.body.data.stats).toBeDefined(); + }); + + it('passes limit and seed query params', async () => { + const res = await request(app) + .get('/api/v1/generator/explorer/snapshot') + .query({ limit: 10, seed: 99 }); + expect(res.status).toBe(200); + }); + + it('accepts mode=simulation query param', async () => { + const res = await request(app) + .get('/api/v1/generator/explorer/snapshot') + .query({ mode: 'simulation' }); + expect(res.status).toBe(200); + }); + + it('accepts useSimulation=true query param', async () => { + const res = await request(app) + .get('/api/v1/generator/explorer/snapshot') + .query({ useSimulation: 'true' }); + expect(res.status).toBe(200); + }); +}); + +describe('Explorer Routes — GET /api/v1/generator/explorer/search', () => { + it('returns 200 with filtered transactions', async () => { + const res = await request(app) + .get('/api/v1/generator/explorer/search') + .query({ q: 'PAYMENT' }); + expect(res.status).toBe(200); + expect(res.body.status).toBe('success'); + expect(res.body.data.transactions).toBeInstanceOf(Array); + expect(res.body.data.query).toBe('PAYMENT'); + }); + + it('returns empty results for non-matching query', async () => { + const res = await request(app) + .get('/api/v1/generator/explorer/search') + .query({ q: 'nonexistent' }); + expect(res.status).toBe(200); + expect(res.body.data.transactions).toEqual([]); + }); + + it('handles empty query string', async () => { + const res = await request(app) + .get('/api/v1/generator/explorer/search'); + expect(res.status).toBe(200); + }); +}); + +describe('Explorer Routes — GET /api/v1/generator/explorer/link/:hash', () => { + it('returns testnet link by default', async () => { + const res = await request(app).get('/api/v1/generator/explorer/link/abc123'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('success'); + expect(res.body.data.link).toContain('testnet/tx/abc123'); + }); + + it('returns public link when network=public', async () => { + const res = await request(app) + .get('/api/v1/generator/explorer/link/abc123') + .query({ network: 'public' }); + expect(res.status).toBe(200); + expect(res.body.data.link).toContain('public/tx/abc123'); + }); +});