diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 87d8812..998deb3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,30 +1,49 @@ -## 🔗 Related Issue -Closes #issue-number +## Summary ---- +Closes #[issue number] -## 🔖 Title - +Briefly describe what this PR does in 2-3 sentences. ---- +## This repo is for the NestJS backend API only -## 📝 Description - +Before submitting, confirm your changes belong here: ---- +- [ ] My changes are inside src/ or test/ +- [ ] I have NOT added React, React Native, + or frontend component files +- [ ] I have NOT added Rust or Soroban contract code +- [ ] This is NestJS/TypeScript backend work -## 🔄 Changes Made - -- [ ] -- [ ] -- [ ] +## Type of change ---- +- [ ] Bug fix +- [ ] New endpoint +- [ ] New service or module +- [ ] Database migration +- [ ] Background job +- [ ] Test coverage -## 📸 Screenshots (if applicable) - +## Testing ---- +- [ ] npm run build passes with zero TypeScript errors +- [ ] npm test passes — all 184+ existing tests pass +- [ ] No new `any` types introduced anywhere +- [ ] Swagger decorators added to every new endpoint +- [ ] Migration file created for any schema changes +- [ ] New unit tests written for new service methods -## 🗒️ Additional Notes - \ No newline at end of file +## Context files reviewed + +- [ ] context/architecture-context.md +- [ ] context/code-standards.md +- [ ] context/progress-tracker.md updated + +## Mandatory before requesting review + +Running these must all exit 0: +npm run build +npm test + +If either fails, fix it before opening this PR. +PRs with failing CI checks will be closed without review. +PRs that reduce the test count will be rejected. diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index ae34ec1..8413f8a 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; import { HealthService } from './health.service'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; @@ -13,6 +13,9 @@ export class HealthController { @ApiResponse({ status: 503, description: 'One or more systems degraded' }) async check() { const result = await this.healthService.check(); + if (result.status !== 'ok') { + throw new ServiceUnavailableException(result); + } return result; } @@ -21,7 +24,11 @@ export class HealthController { @ApiResponse({ status: 200, description: 'Database is connected' }) @ApiResponse({ status: 503, description: 'Database connection failed' }) async checkDatabase() { - return this.healthService.checkDatabaseMinimal(); + const result = await this.healthService.checkDatabaseMinimal(); + if (result.status !== 'ok') { + throw new ServiceUnavailableException(result); + } + return result; } @Get('sentry-test') diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts index ac4bf92..4bdd92a 100644 --- a/src/modules/health/health.module.ts +++ b/src/modules/health/health.module.ts @@ -1,10 +1,14 @@ import { Module } from '@nestjs/common'; +import { StellarModule } from '../../stellar/stellar.module'; import { HealthController } from './health.controller'; import { StellarTomlController } from './stellar-toml.controller'; import { HealthService } from './health.service'; import { SupabaseService } from '../../database/supabase.client'; @Module({ + imports: [ + StellarModule, + ], controllers: [HealthController, StellarTomlController], providers: [HealthService, SupabaseService], }) diff --git a/src/modules/health/health.service.ts b/src/modules/health/health.service.ts index d85e87a..f212928 100644 --- a/src/modules/health/health.service.ts +++ b/src/modules/health/health.service.ts @@ -1,27 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { SupabaseService } from '../../database/supabase.client'; - -interface HorizonRoot { - horizon_version: string; - network: string; - core_version: string; - history_latest_ledger: number; -} +import { HorizonClientService } from '../../stellar/horizon-client.service'; @Injectable() export class HealthService { private readonly logger = new Logger(HealthService.name); - private readonly horizonUrl: string; constructor( - private readonly configService: ConfigService, + private readonly horizonClientService: HorizonClientService, private readonly supabaseService: SupabaseService, - ) { - this.horizonUrl = - this.configService.get('STELLAR_HORIZON_URL') || - 'https://horizon-testnet.stellar.org'; - } + ) {} async check() { const [db, horizon, indexer] = await Promise.all([ @@ -49,42 +37,69 @@ export class HealthService { async checkDatabase() { try { const client = this.supabaseService.getClient(); + if (!client) { + throw new Error('Supabase client is unavailable'); + } const { error } = await client.auth.getSession(); if (error && error.message !== 'Invalid Refresh Token' && !error.message.includes('JWT')) { throw error; } - return { status: 'ok', database: 'connected', message: 'Supabase reachable' }; + return { + status: 'ok', + database: 'connected', + message: 'Supabase reachable', + timestamp: new Date().toISOString(), + }; } catch (error) { - this.logger.error({ context: 'HealthService', action: 'checkDatabase', error: error.message }); - return { status: 'error', database: 'disconnected', message: error.message }; + const errorMessage = + error instanceof Error + ? error.message + : typeof error === 'object' && error && 'message' in error + ? String((error as { message: unknown }).message) + : String(error); + this.logger.error({ context: 'HealthService', action: 'checkDatabase', error: errorMessage }); + return { + status: 'error', + database: 'disconnected', + message: errorMessage, + timestamp: new Date().toISOString(), + }; } } async checkHorizon(): Promise<{ status: string; [key: string]: unknown }> { try { - const root = await this.fetchHorizonRoot(); + const root = await this.horizonClientService.getRoot(); + const endpoints = this.horizonClientService.getEndpointStatuses(); + const healthyPrimary = endpoints.some((e) => e.isPrimary && e.status === 'closed'); return { - status: 'ok', + status: healthyPrimary ? 'ok' : 'degraded', horizon: root.horizon_version, network: root.network, protocolVersion: root.core_version, + endpoints, }; } catch (error) { - this.logger.error({ context: 'HealthService', action: 'checkHorizon', error: error.message }); - return { status: 'error', horizon: 'unreachable', message: error.message }; + this.logger.error({ context: 'HealthService', action: 'checkHorizon', error: (error as Error).message }); + return { + status: 'error', + horizon: 'unreachable', + message: (error as Error).message, + endpoints: this.horizonClientService.getEndpointStatuses(), + }; } } async checkIndexerLag(): Promise<{ status: string; [key: string]: unknown }> { try { const cursor = await this.getIndexerCursor(); - const root = await this.fetchHorizonRoot(); + const root = await this.horizonClientService.getRoot(); const latestLedger = root.history_latest_ledger; const lag = latestLedger - cursor; const status = lag < 100 ? 'ok' : lag < 500 ? 'warning' : 'error'; return { status, cursor, latestLedger, lag }; } catch (error) { - return { status: 'unknown', message: error.message }; + return { status: 'unknown', message: (error as Error).message }; } } @@ -92,14 +107,6 @@ export class HealthService { return this.checkDatabase(); } - private async fetchHorizonRoot(): Promise { - const response = await fetch(this.horizonUrl); - if (!response.ok) { - throw new Error(`Horizon returned ${response.status}`); - } - return response.json() as Promise; - } - private async getIndexerCursor(): Promise { try { const db = this.supabaseService.getServiceRoleClient(); @@ -114,4 +121,4 @@ export class HealthService { return 0; } } -} +} \ No newline at end of file diff --git a/src/stellar/horizon-client.service.ts b/src/stellar/horizon-client.service.ts new file mode 100644 index 0000000..592828e --- /dev/null +++ b/src/stellar/horizon-client.service.ts @@ -0,0 +1,230 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; + +export interface EndpointStatus { + url: string; + status: 'closed' | 'open' | 'half-open'; + failureCount: number; + lastFailure: string | null; + isPrimary: boolean; +} + +export interface HorizonRoot { + horizon_version: string; + network: string; + core_version: string; + history_latest_ledger: number; +} + +interface CircuitBreakerState { + url: string; + status: 'closed' | 'open' | 'half-open'; + failureCount: number; + lastFailure: string | null; + cooldownUntil: number | null; +} + +@Injectable() +export class HorizonClientService implements OnModuleInit { + private readonly logger = new Logger(HorizonClientService.name); + private readonly endpoints: string[]; + private readonly states: CircuitBreakerState[] = []; + private currentIndex = 0; + private readonly networkPassphrase: string; + private readonly maxFailures = 3; + private readonly cooldownMs = 30_000; + + constructor(private readonly configService: ConfigService) { + const urlsEnv = this.configService.get('STELLAR_HORIZON_URLS'); + const singleUrl = this.configService.get('STELLAR_HORIZON_URL'); + + if (urlsEnv) { + this.endpoints = urlsEnv + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } else { + this.endpoints = [singleUrl || 'https://horizon-testnet.stellar.org']; + } + + if (this.endpoints.length === 0) { + this.endpoints = ['https://horizon-testnet.stellar.org']; + } + + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + } + + onModuleInit() { + for (const url of this.endpoints) { + this.states.push({ + url, + status: 'closed', + failureCount: 0, + lastFailure: null, + cooldownUntil: null, + }); + } + this.logger.log( + `Horizon client initialized with ${this.endpoints.length} endpoint(s): ${this.endpoints.join(', ')}`, + ); + } + + getNetworkPassphrase(): string { + return this.networkPassphrase; + } + + getEndpointStatuses(): EndpointStatus[] { + return this.states.map((s, i) => ({ + url: s.url, + status: s.status, + failureCount: s.failureCount, + lastFailure: s.lastFailure, + isPrimary: i === this.currentIndex, + })); + } + + async getRoot(): Promise { + return this.withFailover(async (url) => { + const response = await fetch(url); + if (!response.ok) { + const error: Record = { message: `Horizon returned ${response.status}` }; + error.response = { status: response.status }; + throw error; + } + return response.json() as Promise; + }); + } + + async submitTransaction( + transaction: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction, + ): Promise> { + // Note: Transaction submission retries via withFailover rely on Stellar's + // ledger-level idempotency. If a submission succeeds on one endpoint but the + // response is lost, retrying the identical signed transaction/sequence number + // on a subsequent endpoint is safe, as it will either yield the original result + // or fail idempotently with a sequence number error (tx_bad_seq). + return this.withFailover(async (url) => { + const server = new StellarSdk.Horizon.Server(url); + const result = await server.submitTransaction(transaction); + return result as unknown as Record; + }); + } + + async getTransaction(hash: string): Promise> { + return this.withFailover(async (url) => { + const server = new StellarSdk.Horizon.Server(url); + const result = await server.transactions().transaction(hash).call(); + return result as unknown as Record; + }); + } + + private async withFailover( + operation: (url: string) => Promise, + ): Promise { + const startIndex = this.currentIndex; + const tried = new Set(); + + for (let i = 0; i < this.endpoints.length; i++) { + const idx = (startIndex + i) % this.endpoints.length; + + if (tried.has(idx)) { + continue; + } + tried.add(idx); + + const state = this.states[idx]; + + if (state.status === 'open') { + if (state.cooldownUntil && Date.now() < state.cooldownUntil) { + continue; + } + state.status = 'half-open'; + } + + try { + const result = await operation(state.url); + + state.status = 'closed'; + state.failureCount = 0; + state.lastFailure = null; + state.cooldownUntil = null; + + if (idx !== this.currentIndex) { + this.logger.warn(`Failing over to Horizon endpoint: ${state.url}`); + this.currentIndex = idx; + } + + return result; + } catch (error) { + if (!this.isEndpointError(error)) { + throw error; + } + + state.failureCount++; + state.lastFailure = (error as Error).message; + + this.logger.error( + `Horizon endpoint ${state.url} failed: ${(error as Error).message}`, + ); + + if (state.failureCount >= this.maxFailures) { + state.status = 'open'; + state.cooldownUntil = Date.now() + this.cooldownMs; + this.logger.warn( + `Circuit breaker opened for ${state.url} (${this.cooldownMs}ms cooldown)`, + ); + } + } + } + + throw new Error('All Horizon endpoints are unavailable'); + } + + private isEndpointError(error: unknown): boolean { + if (error instanceof StellarSdk.NetworkError) { + return true; + } + + const err = error as { + response?: { + status?: number; + data?: { extras?: { result_codes?: unknown } }; + }; + message?: string; + }; + + if (err?.response?.data?.extras?.result_codes) { + return false; + } + + const status = err?.response?.status; + + if (status === 404) { + return false; + } + + if (status && status >= 400 && status < 500 && status !== 429) { + return false; + } + + if (status === 429 || (status && status >= 500)) { + return true; + } + + const message = String(err?.message ?? '').toLowerCase(); + return ( + message.includes('timeout') || + message.includes('econnrefused') || + message.includes('econnreset') || + message.includes('network') || + message.includes('socket') || + message.includes('fetch') || + message.includes('dns') || + message.includes('enotfound') || + message.includes('eai_again') + ); + } +} diff --git a/src/stellar/stellar.module.ts b/src/stellar/stellar.module.ts index 702699e..d9fbf9e 100644 --- a/src/stellar/stellar.module.ts +++ b/src/stellar/stellar.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { SorobanService } from '../blockchain/soroban/soroban.service'; +import { HorizonClientService } from './horizon-client.service'; +import { StellarService } from './stellar.service'; import { CreditLineContractClient } from './contracts/clients/creditline.client'; import { ReputationContractClient } from './contracts/clients/reputation.client'; import { LiquidityPoolContractClient } from './contracts/clients/liquidity-pool.client'; @@ -10,6 +12,8 @@ import { ParametersContractClient } from './contracts/clients/parameters.client' @Module({ imports: [ConfigModule], providers: [ + HorizonClientService, + StellarService, SorobanService, CreditLineContractClient, ReputationContractClient, @@ -18,6 +22,8 @@ import { ParametersContractClient } from './contracts/clients/parameters.client' ParametersContractClient, ], exports: [ + HorizonClientService, + StellarService, SorobanService, CreditLineContractClient, ReputationContractClient, diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts new file mode 100644 index 0000000..99fa431 --- /dev/null +++ b/src/stellar/stellar.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import * as StellarSdk from 'stellar-sdk'; +import { + HorizonClientService, + EndpointStatus, + HorizonRoot, +} from './horizon-client.service'; + +@Injectable() +export class StellarService { + constructor( + private readonly horizonClientService: HorizonClientService, + ) {} + + getNetworkPassphrase(): string { + return this.horizonClientService.getNetworkPassphrase(); + } + + getEndpointStatuses(): EndpointStatus[] { + return this.horizonClientService.getEndpointStatuses(); + } + + async getHorizonRoot(): Promise { + return this.horizonClientService.getRoot(); + } + + async submitTransaction( + transaction: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction, + ): Promise> { + return this.horizonClientService.submitTransaction(transaction); + } + + async getTransaction(hash: string): Promise> { + return this.horizonClientService.getTransaction(hash); + } +} diff --git a/test/__mocks__/stellar-sdk.js b/test/__mocks__/stellar-sdk.js index 7a3098c..fdc9ff5 100644 --- a/test/__mocks__/stellar-sdk.js +++ b/test/__mocks__/stellar-sdk.js @@ -53,6 +53,7 @@ module.exports = { })), })), }, + NetworkError: class NetworkError extends Error {}, BASE_FEE: '100', Networks: { PUBLIC: 'Public Global Stellar Network ; September 2015', diff --git a/test/e2e/modules/health/health.e2e-spec.ts b/test/e2e/modules/health/health.e2e-spec.ts index fa6a971..297e6d6 100644 --- a/test/e2e/modules/health/health.e2e-spec.ts +++ b/test/e2e/modules/health/health.e2e-spec.ts @@ -2,14 +2,53 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '../../../../src/app.module'; +import { SupabaseService } from '../../../../src/database/supabase.client'; +import { HorizonClientService } from '../../../../src/stellar/horizon-client.service'; describe('HealthController (e2e)', () => { let app: INestApplication; beforeAll(async () => { + process.env.JWT_SECRET = 'test_jwt_secret_for_e2e_testing_min_32_chars'; + process.env.JWT_REFRESH_SECRET = 'test_jwt_refresh_secret_for_e2e_testing_min_32_chars'; + process.env.SUPABASE_URL = 'https://test.supabase.co'; + process.env.SUPABASE_ANON_KEY = 'test-anon-key'; + + const mockSupabase = { + getClient: jest.fn().mockReturnValue({ auth: { getSession: jest.fn().mockResolvedValue({ error: null }) } }), + getServiceRoleClient: jest.fn().mockReturnValue({ + from: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + order: jest.fn().mockReturnValue({ + limit: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ data: { last_ledger: 990 } }), + }), + }), + }), + }), + }), + }; + + const mockHorizon = { + getRoot: jest.fn().mockResolvedValue({ + horizon_version: '2.0.0', + network: 'testnet', + core_version: 'v22.0.0', + history_latest_ledger: 1000, + }), + getEndpointStatuses: jest.fn().mockReturnValue([ + { url: 'https://horizon.stellar.org', status: 'closed', failureCount: 0, lastFailure: null, isPrimary: true }, + ]), + }; + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(SupabaseService) + .useValue(mockSupabase) + .overrideProvider(HorizonClientService) + .useValue(mockHorizon) + .compile(); app = moduleFixture.createNestApplication(); await app.init(); diff --git a/test/unit/modules/health/health.controller.spec.ts b/test/unit/modules/health/health.controller.spec.ts index 2e7f1f7..d8f8460 100644 --- a/test/unit/modules/health/health.controller.spec.ts +++ b/test/unit/modules/health/health.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { HealthController } from '../../../../src/modules/health/health.controller'; import { HealthService } from '../../../../src/modules/health/health.service'; +import { ServiceUnavailableException } from '@nestjs/common'; describe('HealthController', () => { let controller: HealthController; @@ -50,6 +51,19 @@ describe('HealthController', () => { expect(result).toEqual(expectedResult); expect(healthService.check).toHaveBeenCalledTimes(1); }); + + it('should throw ServiceUnavailableException if health is degraded', async () => { + const degradedResult = { + status: 'degraded', + timestamp: new Date().toISOString(), + service: 'StepFi API', + }; + + mockHealthService.check.mockResolvedValue(degradedResult); + + await expect(controller.check()).rejects.toThrow(ServiceUnavailableException); + expect(healthService.check).toHaveBeenCalledTimes(1); + }); }); describe('checkDatabase', () => { @@ -68,6 +82,20 @@ describe('HealthController', () => { expect(result).toEqual(expectedResult); expect(healthService.checkDatabaseMinimal).toHaveBeenCalledTimes(1); }); + + it('should throw ServiceUnavailableException if database connection failed', async () => { + const failedResult = { + status: 'error', + database: 'disconnected', + message: 'Connection failed', + timestamp: new Date().toISOString(), + }; + + mockHealthService.checkDatabaseMinimal.mockResolvedValue(failedResult); + + await expect(controller.checkDatabase()).rejects.toThrow(ServiceUnavailableException); + expect(healthService.checkDatabaseMinimal).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/test/unit/modules/health/health.service.spec.ts b/test/unit/modules/health/health.service.spec.ts index 6dc82bc..59cde51 100644 --- a/test/unit/modules/health/health.service.spec.ts +++ b/test/unit/modules/health/health.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { HealthService } from '../../../../src/modules/health/health.service'; import { SupabaseService } from '../../../../src/database/supabase.client'; import { ConfigService } from '@nestjs/config'; +import { HorizonClientService } from '../../../../src/stellar/horizon-client.service'; describe('HealthService', () => { let service: HealthService; @@ -18,6 +19,21 @@ describe('HealthService', () => { getServiceRoleClient: jest.fn(() => mockSupabaseClient), }; + const mockHorizonClientService = { + getRoot: jest.fn().mockResolvedValue({ + horizon_version: '2.0.0', + network: 'testnet', + core_version: 'v22.0.0', + history_latest_ledger: 12345, + }), + getEndpointStatuses: jest.fn().mockReturnValue([ + { url: 'https://horizon-testnet.stellar.org', status: 'closed', failureCount: 0, lastFailure: null, isPrimary: true }, + ]), + getNetworkPassphrase: jest.fn().mockReturnValue('Test SDF Network ; September 2015'), + submitTransaction: jest.fn(), + getTransaction: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -27,10 +43,8 @@ describe('HealthService', () => { useValue: mockSupabaseService, }, { - provide: ConfigService, - useValue: { - get: jest.fn((key: string, defaultValue: any) => defaultValue), - }, + provide: HorizonClientService, + useValue: mockHorizonClientService, }, ], }).compile(); diff --git a/test/unit/stellar/horizon-client.service.spec.ts b/test/unit/stellar/horizon-client.service.spec.ts new file mode 100644 index 0000000..fb8a233 --- /dev/null +++ b/test/unit/stellar/horizon-client.service.spec.ts @@ -0,0 +1,301 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { HorizonClientService } from '../../../src/stellar/horizon-client.service'; + +jest.mock('stellar-sdk'); + +describe('HorizonClientService', () => { + let mockFetch: jest.Mock; + let originalFetch: typeof global.fetch; + + const mockHorizonServerInstance = { + submitTransaction: jest.fn(), + transactions: jest.fn(), + }; + + const mockConfig = { + get: jest.fn(), + }; + + beforeAll(() => { + originalFetch = global.fetch; + mockFetch = jest.fn(); + global.fetch = mockFetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + beforeEach(() => { + mockConfig.get.mockReset(); + mockFetch.mockReset(); + jest.clearAllMocks(); + (StellarSdk.Horizon.Server as jest.Mock).mockReturnValue(mockHorizonServerInstance); + }); + + async function createService(mockConfigImpl?: (key: string) => any): Promise { + if (mockConfigImpl) { + mockConfig.get.mockImplementation(mockConfigImpl); + } + const module: TestingModule = await Test.createTestingModule({ + providers: [ + HorizonClientService, + { + provide: ConfigService, + useValue: mockConfig, + }, + ], + }).compile(); + + const svc = module.get(HorizonClientService); + svc.onModuleInit(); + return svc; + } + + describe('Initialization and Configuration', () => { + it('should fall back to default endpoint if no config is provided', async () => { + mockConfig.get.mockImplementation((key: string) => { + if (key === 'STELLAR_NETWORK_PASSPHRASE') return 'Test SDF Network ; September 2015'; + return undefined; + }); + + const tempService = await createService(); + const statuses = tempService.getEndpointStatuses(); + expect(statuses.length).toBe(1); + expect(statuses[0].url).toBe('https://horizon-testnet.stellar.org'); + expect(statuses[0].isPrimary).toBe(true); + }); + + it('should parse multiple endpoints from STELLAR_HORIZON_URLS', async () => { + const tempService = await createService((key: string) => { + if (key === 'STELLAR_HORIZON_URLS') { + return 'https://endpoint1.org, https://endpoint2.org '; + } + return undefined; + }); + + const statuses = tempService.getEndpointStatuses(); + expect(statuses.length).toBe(2); + expect(statuses[0].url).toBe('https://endpoint1.org'); + expect(statuses[0].isPrimary).toBe(true); + expect(statuses[1].url).toBe('https://endpoint2.org'); + expect(statuses[1].isPrimary).toBe(false); + }); + }); + + describe('withFailover Operations', () => { + it('should execute successfully on primary endpoint', async () => { + const service = await createService((key: string) => { + if (key === 'STELLAR_HORIZON_URLS') { + return 'https://primary.org, https://backup.org'; + } + return undefined; + }); + + const rootResponse = { + horizon_version: '2.0.0', + network: 'testnet', + core_version: 'v22.0.0', + history_latest_ledger: 12345, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(rootResponse), + }); + + const result = await service.getRoot(); + + expect(result).toEqual(rootResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith('https://primary.org'); + + const statuses = service.getEndpointStatuses(); + expect(statuses[0].isPrimary).toBe(true); + expect(statuses[0].status).toBe('closed'); + }); + + it('should failover to secondary if primary fails with network error', async () => { + const service = await createService((key: string) => { + if (key === 'STELLAR_HORIZON_URLS') { + return 'https://primary.org, https://backup.org'; + } + return undefined; + }); + + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + + const rootResponse = { + horizon_version: '2.0.0', + network: 'testnet', + core_version: 'v22.0.0', + history_latest_ledger: 12345, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(rootResponse), + }); + + const result = await service.getRoot(); + + expect(result).toEqual(rootResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toBe('https://primary.org'); + expect(mockFetch.mock.calls[1][0]).toBe('https://backup.org'); + + const statuses = service.getEndpointStatuses(); + expect(statuses[0].isPrimary).toBe(false); + expect(statuses[0].failureCount).toBe(1); + expect(statuses[1].isPrimary).toBe(true); + expect(statuses[1].status).toBe('closed'); + }); + + it('should open circuit breaker for primary after 3 consecutive failures', async () => { + const singleService = await createService((key: string) => { + if (key === 'STELLAR_HORIZON_URLS') { + return 'https://primary.org'; + } + return undefined; + }); + + // Trigger 3 failures on primary + for (let i = 0; i < 3; i++) { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect(singleService.getRoot()).rejects.toThrow('All Horizon endpoints are unavailable'); + } + + const statuses = singleService.getEndpointStatuses(); + expect(statuses[0].status).toBe('open'); + expect(statuses[0].failureCount).toBe(3); + expect(statuses[0].lastFailure).toBe('fetch failed'); + }); + + it('should skip open endpoint and only request backup if cooldown is active', async () => { + const service = await createService((key: string) => { + if (key === 'STELLAR_HORIZON_URLS') { + return 'https://primary.org, https://backup.org'; + } + return undefined; + }); + + // 1. primary fails, backup succeeds. currentIndex -> 1 (backup). primary failCount = 1. + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + mockFetch.mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValue({}) }); + await service.getRoot(); + + // 2. backup fails, primary fails. Both fail. primary failCount = 2, backup failCount = 1. + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect(service.getRoot()).rejects.toThrow('All Horizon endpoints are unavailable'); + + // 3. backup fails, primary fails. Both fail. primary failCount = 3 (opens!), backup failCount = 2. + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect(service.getRoot()).rejects.toThrow('All Horizon endpoints are unavailable'); + + // Cooldown fast forward so both can be checked + const originalNow = Date.now; + Date.now = () => originalNow() + 31000; + + // 4. backup fails (opens!), primary succeeds (closes!). currentIndex -> 0 (primary). + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + mockFetch.mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValue({}) }); + await service.getRoot(); + + // Restore time: backup is now open with active cooldown. primary is closed. + Date.now = originalNow; + + mockFetch.mockClear(); + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + + // 5. call getRoot. primary fails. backup should be skipped since it is open and cooldown is active. + await expect(service.getRoot()).rejects.toThrow('All Horizon endpoints are unavailable'); + + // Verify backup was skipped and not called + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith('https://primary.org'); + }); + + it('should try primary as half-open when cooldown expires and reset on success', async () => { + const singleService = await createService((key: string) => { + if (key === 'STELLAR_HORIZON_URLS') { + return 'https://primary.org'; + } + return undefined; + }); + + // Open primary breaker + for (let i = 0; i < 3; i++) { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect(singleService.getRoot()).rejects.toThrow('All Horizon endpoints are unavailable'); + } + + // Fast forward cooldown time + const originalNow = Date.now; + Date.now = () => originalNow() + 31000; // > 30s cooldown + + mockFetch.mockClear(); + // Primary success now (half-open -> closed) + mockFetch.mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValue({ success: true }) }); + + await singleService.getRoot(); + + expect(mockFetch).toHaveBeenCalledWith('https://primary.org'); + const statuses = singleService.getEndpointStatuses(); + expect(statuses[0].status).toBe('closed'); + expect(statuses[0].failureCount).toBe(0); + + Date.now = originalNow; // restore + }); + + it('should throw error when all endpoints are failed', async () => { + const service = await createService((key: string) => { + if (key === 'STELLAR_HORIZON_URLS') { + return 'https://primary.org, https://backup.org'; + } + return undefined; + }); + + mockFetch.mockRejectedValue(new TypeError('fetch failed')); + + await expect(service.getRoot()).rejects.toThrow('All Horizon endpoints are unavailable'); + }); + }); + + describe('submitTransaction & getTransaction wrappers', () => { + let service: HorizonClientService; + + beforeEach(async () => { + service = await createService(); + }); + + it('should execute submitTransaction and wrap response', async () => { + const mockResult = { hash: 'txhash123' }; + mockHorizonServerInstance.submitTransaction.mockResolvedValue(mockResult); + + const mockTx = {} as any; + const result = await service.submitTransaction(mockTx); + + expect(result).toEqual(mockResult); + }); + + it('should execute getTransaction and wrap response', async () => { + const mockResult = { hash: 'txhash123', ledger: 100 }; + const mockTransactionCall = { + call: jest.fn().mockResolvedValue(mockResult), + }; + const mockTransactionsBuilder = { + transaction: jest.fn().mockReturnValue(mockTransactionCall), + }; + mockHorizonServerInstance.transactions = jest.fn().mockReturnValue(mockTransactionsBuilder); + + const result = await service.getTransaction('txhash123'); + + expect(result).toEqual(mockResult); + expect(mockTransactionsBuilder.transaction).toHaveBeenCalledWith('txhash123'); + }); + }); +}); diff --git a/test/unit/stellar/stellar.service.spec.ts b/test/unit/stellar/stellar.service.spec.ts new file mode 100644 index 0000000..13f3429 --- /dev/null +++ b/test/unit/stellar/stellar.service.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StellarService } from '../../../src/stellar/stellar.service'; +import { HorizonClientService } from '../../../src/stellar/horizon-client.service'; + +describe('StellarService', () => { + let service: StellarService; + let horizonClientService: HorizonClientService; + + const mockHorizonClientService = { + getNetworkPassphrase: jest.fn(), + getEndpointStatuses: jest.fn(), + getRoot: jest.fn(), + submitTransaction: jest.fn(), + getTransaction: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StellarService, + { + provide: HorizonClientService, + useValue: mockHorizonClientService, + }, + ], + }).compile(); + + service = module.get(StellarService); + horizonClientService = module.get(HorizonClientService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should delegate getNetworkPassphrase to horizonClientService', () => { + mockHorizonClientService.getNetworkPassphrase.mockReturnValue('Test SDF Network ; September 2015'); + const result = service.getNetworkPassphrase(); + expect(result).toBe('Test SDF Network ; September 2015'); + expect(horizonClientService.getNetworkPassphrase).toHaveBeenCalledTimes(1); + }); + + it('should delegate getEndpointStatuses to horizonClientService', () => { + const mockStatuses = [ + { url: 'https://horizon.stellar.org', status: 'closed', failureCount: 0, lastFailure: null, isPrimary: true }, + ] as any[]; + mockHorizonClientService.getEndpointStatuses.mockReturnValue(mockStatuses); + const result = service.getEndpointStatuses(); + expect(result).toEqual(mockStatuses); + expect(horizonClientService.getEndpointStatuses).toHaveBeenCalledTimes(1); + }); + + it('should delegate getHorizonRoot to horizonClientService', async () => { + const mockRoot = { horizon_version: '2.0.0' } as any; + mockHorizonClientService.getRoot.mockResolvedValue(mockRoot); + const result = await service.getHorizonRoot(); + expect(result).toEqual(mockRoot); + expect(horizonClientService.getRoot).toHaveBeenCalledTimes(1); + }); + + it('should delegate submitTransaction to horizonClientService', async () => { + const mockTx = {} as any; + const mockRes = { hash: '123' }; + mockHorizonClientService.submitTransaction.mockResolvedValue(mockRes); + const result = await service.submitTransaction(mockTx); + expect(result).toEqual(mockRes); + expect(horizonClientService.submitTransaction).toHaveBeenCalledWith(mockTx); + }); + + it('should delegate getTransaction to horizonClientService', async () => { + const mockRes = { hash: '123' }; + mockHorizonClientService.getTransaction.mockResolvedValue(mockRes); + const result = await service.getTransaction('123'); + expect(result).toEqual(mockRes); + expect(horizonClientService.getTransaction).toHaveBeenCalledWith('123'); + }); +});