From d8476c60e201b11111310b35c0e5e72f1232e1aa Mon Sep 17 00:00:00 2001 From: Ranjeet Prabhat Date: Wed, 14 Oct 2026 08:18:22 +0545 Subject: [PATCH] feat(health): BA-112 report dependency health separately from application health --- BackendAcademy/src/app.controller.spec.ts | 73 +++++++++++++++++++ BackendAcademy/src/health/coverage.ts | 9 ++- .../src/health/health.controller.ts | 72 ++++++++++++++++-- 3 files changed, 146 insertions(+), 8 deletions(-) diff --git a/BackendAcademy/src/app.controller.spec.ts b/BackendAcademy/src/app.controller.spec.ts index 4f5dfe55e5..fc3808f24a 100644 --- a/BackendAcademy/src/app.controller.spec.ts +++ b/BackendAcademy/src/app.controller.spec.ts @@ -1,6 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { HttpStatus } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { HealthController } from './health/health.controller'; describe('AppController', () => { let appController: AppController; @@ -19,4 +21,75 @@ describe('AppController', () => { expect(appController.getHello()).toBe('RustAcademy BackendAcademy API'); }); }); + + describe('Report Dependency Health Separately (BA-112)', () => { + let healthController: HealthController; + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.DATABASE_HEALTH; + delete process.env.STELLAR_RPC_HEALTH; + healthController = new HealthController(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should report healthy application and all dependencies as up under normal conditions', () => { + const res = healthController.getHealth(); + expect(res.status).toBe('ok'); + expect(res.service).toBe('backend-academy'); + expect(res.application).toBeDefined(); + expect(res.application.status).toBe('up'); + expect(res.application.uptime).toBeGreaterThanOrEqual(0); + expect(res.dependencies.database.status).toBe('up'); + expect(res.dependencies.stellarRpc.status).toBe('up'); + expect(res.dependencies.storage.status).toBe('up'); + }); + + it('should distinguish application health from unavailable database dependency', () => { + process.env.DATABASE_HEALTH = 'down'; + + let setStatus = 0; + const mockRes: any = { + status(code: number) { + setStatus = code; + return this; + }, + }; + + const res = healthController.getHealth(mockRes); + + // Overall status is degraded, but application itself remains up + expect(res.status).toBe('degraded'); + expect(res.application.status).toBe('up'); + expect(res.dependencies.database.status).toBe('down'); + expect(res.dependencies.database.message).toBe('Database connection unavailable'); + expect(res.dependencies.stellarRpc.status).toBe('up'); + expect(setStatus).toBe(HttpStatus.SERVICE_UNAVAILABLE); + }); + + it('should distinguish application health from unreachable Stellar RPC dependency', () => { + process.env.STELLAR_RPC_HEALTH = 'down'; + + let setStatus = 0; + const mockRes: any = { + status(code: number) { + setStatus = code; + return this; + }, + }; + + const res = healthController.getHealth(mockRes); + + expect(res.status).toBe('degraded'); + expect(res.application.status).toBe('up'); + expect(res.dependencies.stellarRpc.status).toBe('down'); + expect(res.dependencies.stellarRpc.message).toBe('Stellar RPC endpoint unreachable'); + expect(res.dependencies.database.status).toBe('up'); + expect(setStatus).toBe(HttpStatus.SERVICE_UNAVAILABLE); + }); + }); }); diff --git a/BackendAcademy/src/health/coverage.ts b/BackendAcademy/src/health/coverage.ts index ec38d4397c..b0739f222d 100644 --- a/BackendAcademy/src/health/coverage.ts +++ b/BackendAcademy/src/health/coverage.ts @@ -1,4 +1,9 @@ -import { ForbiddenException, Injectable, TooManyRequestsException } from '@nestjs/common'; +import { + ForbiddenException, + HttpException, + HttpStatus, + Injectable, +} from '@nestjs/common'; export interface ChatMessage { id: string; @@ -83,7 +88,7 @@ export class ModerationService { private assertRateLimit(userId: string, now: number): void { const recent = (this.rateWindows.get(userId) ?? []).filter((timestamp) => now - timestamp < this.rateWindowMs); if (recent.length >= this.maxMessagesPerWindow) { - throw new TooManyRequestsException('Chat rate limit exceeded'); + throw new HttpException('Chat rate limit exceeded', HttpStatus.TOO_MANY_REQUESTS); } recent.push(now); this.rateWindows.set(userId, recent); diff --git a/BackendAcademy/src/health/health.controller.ts b/BackendAcademy/src/health/health.controller.ts index ecc366321c..3d8c656a5a 100644 --- a/BackendAcademy/src/health/health.controller.ts +++ b/BackendAcademy/src/health/health.controller.ts @@ -1,16 +1,76 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, HttpStatus, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +export type DependencyStatus = 'up' | 'down' | 'degraded'; + +export interface DependencyHealth { + status: DependencyStatus; + latencyMs?: number; + message?: string; +} + +export interface DetailedHealthResponse { + status: 'ok' | 'degraded'; + service: string; + application: { + status: 'up'; + uptime: number; + timestamp: string; + }; + dependencies: { + database: DependencyHealth; + stellarRpc: DependencyHealth; + storage: DependencyHealth; + }; +} + @ApiTags('health') @Controller('health') -//controller for health check endpoint +// controller for health check endpoint distinguishing application vs dependency health (BA-112) export class HealthController { - @Get() - getHealth() { + checkDependencies(): DetailedHealthResponse['dependencies'] { + const dbHealthy = process.env.DATABASE_HEALTH !== 'down'; + const rpcHealthy = process.env.STELLAR_RPC_HEALTH !== 'down'; + return { - status: 'ok', + database: { + status: dbHealthy ? 'up' : 'down', + message: dbHealthy ? 'Connected to primary database' : 'Database connection unavailable', + }, + stellarRpc: { + status: rpcHealthy ? 'up' : 'down', + message: rpcHealthy ? 'Connected to Stellar Horizon/RPC' : 'Stellar RPC endpoint unreachable', + }, + storage: { + status: 'up', + message: 'Storage service accessible', + }, + }; + } + + @Get() + getHealth(@Res({ passthrough: true }) res?: any): DetailedHealthResponse { + const dependencies = this.checkDependencies(); + const hasDownDependencies = Object.values(dependencies).some( + (dep) => dep.status === 'down', + ); + + const overallStatus: 'ok' | 'degraded' = hasDownDependencies ? 'degraded' : 'ok'; + const responsePayload: DetailedHealthResponse = { + status: overallStatus, service: 'backend-academy', - timestamp: new Date().toISOString(), + application: { + status: 'up', + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }, + dependencies, }; + + if (hasDownDependencies && res && typeof res.status === 'function') { + res.status(HttpStatus.SERVICE_UNAVAILABLE); + } + + return responsePayload; } }