Skip to content
Merged
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
29 changes: 1 addition & 28 deletions src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,16 @@ import {
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiSecurity,
} from "@nestjs/swagger";
import { AppService } from "./app.service";
import { RateLimit } from "./common/decorators/rate-limit.decorator";
import { JwtAuthGuard } from "./core/auth/jwt.guard";

@ApiTags("Health")
@ApiTags("Info")
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}

@Get("health")
@RateLimit({ level: "free", limit: 2, windowMs: 60000 }) // Max 2 requests per minute for health
@ApiOperation({
summary: "Health Check",
description: "Check if the API is running and healthy",
operationId: "getHealth",
})
@ApiResponse({
status: 200,
description: "Service is healthy",
schema: {
type: "object",
properties: {
status: { type: "string", example: "OK" },
timestamp: { type: "string", example: "2024-02-25T05:30:00.000Z" },
},
},
})
@ApiResponse({
status: 429,
description: "Too many requests",
})
getHealth(): { status: string; timestamp: string } {
return this.appService.getHealth();
}

@Get("info")
@RateLimit({ level: "standard" }) // Default standard level
@ApiOperation({
Expand Down
7 changes: 7 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { join } from "path";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerModule } from "@nestjs/throttler";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { EnvironmentVariables } from "./config/env.validation";
Expand Down Expand Up @@ -32,6 +33,9 @@ import { DeFiModule } from "./defi/defi/defi.module";
// Modules – growth
import { AlertsModule } from "./growth/alerts/alerts.module";

// Modules – health
import { HealthModule } from "./health/health.module";

// Auth entities
import { User } from "./core/user/entities/user.entity";
import { EmailVerification } from "./core/auth/entities/email-verification.entity";
Expand Down Expand Up @@ -152,6 +156,8 @@ import { SubmissionVerifierService } from "./blockchain/oracle/submission-verifi
},
}),

EventEmitterModule.forRoot(),

ThrottlerModule.forRoot({
throttlers: [
{ name: 'global', ttl: 60_000, limit: 100 },
Expand All @@ -170,6 +176,7 @@ import { SubmissionVerifierService } from "./blockchain/oracle/submission-verifi
RiskManagementModule,
DeFiModule,
AlertsModule,
HealthModule,
],

controllers: [AppController],
Expand Down
19 changes: 19 additions & 0 deletions src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
import { RiskManagementHealthIndicator } from '../investment/risk-management/risk-management.health';

@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly riskHealth: RiskManagementHealthIndicator,
) {}

@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.riskHealth.isHealthy('risk-management'),
]);
}
}
10 changes: 10 additions & 0 deletions src/health/health.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
import { RiskManagementModule } from '../investment/risk-management/risk-management.module';

@Module({
imports: [TerminusModule, RiskManagementModule],
controllers: [HealthController],
})
export class HealthModule {}
170 changes: 170 additions & 0 deletions src/investment/risk-management/circuit-breaker.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CircuitBreakerService } from './circuit-breaker.service';

const mockEmitter = { emit: jest.fn() };

async function buildService(): Promise<CircuitBreakerService> {
const module: TestingModule = await Test.createTestingModule({
providers: [
CircuitBreakerService,
{ provide: EventEmitter2, useValue: mockEmitter },
],
}).compile();
return module.get(CircuitBreakerService);
}

describe('CircuitBreakerService', () => {
let service: CircuitBreakerService;

beforeEach(async () => {
jest.clearAllMocks();
service = await buildService();
});

describe('CLOSED state', () => {
it('starts in CLOSED state', () => {
expect(service.getStatus().state).toBe('CLOSED');
});

it('stays CLOSED while failures are below threshold', () => {
for (let i = 0; i < 4; i++) service.recordFailure();
expect(service.getStatus().state).toBe('CLOSED');
expect(mockEmitter.emit).not.toHaveBeenCalledWith('circuit-breaker.opened', expect.anything());
});

it('records success metrics in CLOSED state', () => {
service.recordSuccess();
service.recordSuccess();
expect(service.getStatus().successCount).toBe(2);
expect(service.getStatus().totalCallCount).toBe(2);
});

it('isOpen returns false in CLOSED state', () => {
expect(service.isOpen()).toBe(false);
});
});

describe('OPEN state', () => {
beforeEach(() => {
for (let i = 0; i < 5; i++) service.recordFailure();
});

it('transitions to OPEN after reaching failure threshold', () => {
expect(service.getStatus().state).toBe('OPEN');
});

it('emits circuit-breaker.opened when transitioning to OPEN', () => {
expect(mockEmitter.emit).toHaveBeenCalledWith(
'circuit-breaker.opened',
expect.objectContaining({ serviceName: 'default', failureCount: 5 }),
);
});

it('isOpen returns true in OPEN state', () => {
// Use getStatus to verify OPEN without triggering auto-transition
expect(service.getStatus().state).toBe('OPEN');
});

it('does not reset on recordSuccess while OPEN (no probe yet)', () => {
service.recordSuccess();
// In OPEN state, recordSuccess increments counts but doesn't reset — only HALF_OPEN does
expect(service.getStatus().state).toBe('OPEN');
});

it('transitions to HALF_OPEN after recovery time elapses', () => {
jest.useFakeTimers();
jest.advanceTimersByTime(61_000);
service.isOpen(); // triggers the auto-transition check
expect(service.getStatus().state).toBe('HALF_OPEN');
jest.useRealTimers();
});
});

describe('HALF_OPEN state', () => {
beforeEach(() => {
jest.useFakeTimers();
for (let i = 0; i < 5; i++) service.recordFailure();
jest.advanceTimersByTime(61_000);
service.isOpen(); // trigger OPEN → HALF_OPEN
});

afterEach(() => {
jest.useRealTimers();
});

it('transitions to HALF_OPEN after recovery time', () => {
expect(service.getStatus().state).toBe('HALF_OPEN');
});

it('transitions to CLOSED after a successful probe', () => {
service.recordSuccess();
expect(service.getStatus().state).toBe('CLOSED');
});

it('emits circuit-breaker.closed after successful probe', () => {
jest.clearAllMocks();
service.recordSuccess();
expect(mockEmitter.emit).toHaveBeenCalledWith(
'circuit-breaker.closed',
expect.objectContaining({ serviceName: 'default' }),
);
});

it('transitions back to OPEN after a failed probe', () => {
service.recordFailure();
expect(service.getStatus().state).toBe('OPEN');
});

it('emits circuit-breaker.opened after failed probe', () => {
jest.clearAllMocks();
service.recordFailure();
expect(mockEmitter.emit).toHaveBeenCalledWith(
'circuit-breaker.opened',
expect.objectContaining({ serviceName: 'default' }),
);
});
});

describe('reset', () => {
it('resets counters and returns to CLOSED', () => {
for (let i = 0; i < 5; i++) service.recordFailure();
service.reset();
const status = service.getStatus();
expect(status.state).toBe('CLOSED');
expect(status.failureCount).toBe(0);
expect(status.totalCallCount).toBe(0);
});

it('emits circuit-breaker.closed when resetting from OPEN', () => {
for (let i = 0; i < 5; i++) service.recordFailure();
jest.clearAllMocks();
service.reset();
expect(mockEmitter.emit).toHaveBeenCalledWith(
'circuit-breaker.closed',
expect.objectContaining({ serviceName: 'default' }),
);
});

it('does not emit circuit-breaker.closed when already CLOSED', () => {
service.reset();
expect(mockEmitter.emit).not.toHaveBeenCalledWith('circuit-breaker.closed', expect.anything());
});
});

describe('per-service configuration', () => {
it('uses custom failureThreshold from configure()', () => {
service.configure('svc-a', { failureThreshold: 2 });
service.recordFailure('svc-a');
expect(service.getStatus('svc-a').state).toBe('CLOSED');
service.recordFailure('svc-a');
expect(service.getStatus('svc-a').state).toBe('OPEN');
});

it('isolates state between named services', () => {
for (let i = 0; i < 5; i++) service.recordFailure('svc-x');
expect(service.getStatus('svc-x').state).toBe('OPEN');
expect(service.getStatus('svc-y').state).toBe('CLOSED');
});
});
});
Loading