Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions BackendAcademy/src/app.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
});
});
});
9 changes: 7 additions & 2 deletions BackendAcademy/src/health/coverage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ForbiddenException, Injectable, TooManyRequestsException } from '@nestjs/common';
import {
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';

export interface ChatMessage {
id: string;
Expand Down Expand Up @@ -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);
Expand Down
72 changes: 66 additions & 6 deletions BackendAcademy/src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}