Skip to content

Commit b5336b3

Browse files
authored
Merge branch 'main' into MindBlockLabs#311
2 parents 6814f67 + 16b2773 commit b5336b3

9 files changed

Lines changed: 2518 additions & 0 deletions

File tree

backend/src/app.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import { JwtAuthModule, JwtAuthMiddleware } from './auth/middleware/jwt-auth.mod
2020
import { REDIS_CLIENT } from './redis/redis.constants';
2121
import jwtConfig from './auth/authConfig/jwt.config';
2222
import { UsersService } from './users/providers/users.service';
23+
import { HealthModule } from './health/health.module';
24+
25+
// const ENV = process.env.NODE_ENV;
26+
// console.log('NODE_ENV:', process.env.NODE_ENV);
27+
// console.log('ENV:', ENV);
2328

2429
@Module({
2530
imports: [
@@ -95,6 +100,7 @@ import { UsersService } from './users/providers/users.service';
95100
publicRoutes: ['/auth', '/api', '/docs', '/health'],
96101
}),
97102
}),
103+
HealthModule,
98104
],
99105
controllers: [AppController],
100106
providers: [AppService],
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { HealthController } from './health.controller';
3+
import { HealthService } from './health.service';
4+
import { HealthCheckResult } from './health.interfaces';
5+
6+
describe('HealthController', () => {
7+
let controller: HealthController;
8+
let healthService: HealthService;
9+
10+
const mockHealthService = {
11+
getBasicHealth: jest.fn(),
12+
getLivenessHealth: jest.fn(),
13+
getReadinessHealth: jest.fn(),
14+
getDetailedHealth: jest.fn(),
15+
isAppShuttingDown: jest.fn(),
16+
};
17+
18+
beforeEach(async () => {
19+
const module: TestingModule = await Test.createTestingModule({
20+
controllers: [HealthController],
21+
providers: [
22+
{
23+
provide: HealthService,
24+
useValue: mockHealthService,
25+
},
26+
],
27+
}).compile();
28+
29+
controller = module.get<HealthController>(HealthController);
30+
healthService = module.get<HealthService>(HealthService);
31+
});
32+
33+
afterEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
describe('GET /health', () => {
38+
it('should return basic health check', async () => {
39+
const expectedResult: HealthCheckResult = {
40+
status: 'healthy',
41+
version: '1.0.0',
42+
uptime: 3600,
43+
timestamp: '2023-01-01T00:00:00.000Z',
44+
};
45+
46+
mockHealthService.isAppShuttingDown.mockReturnValue(false);
47+
mockHealthService.getBasicHealth.mockResolvedValue(expectedResult);
48+
49+
const result = await controller.getBasicHealth();
50+
51+
expect(result).toEqual(expectedResult);
52+
expect(healthService.isAppShuttingDown).toHaveBeenCalled();
53+
expect(healthService.getBasicHealth).toHaveBeenCalled();
54+
});
55+
56+
it('should return 403 when app is shutting down', async () => {
57+
mockHealthService.isAppShuttingDown.mockReturnValue(true);
58+
59+
await expect(controller.getBasicHealth()).rejects.toThrow('Application is shutting down');
60+
expect(healthService.isAppShuttingDown).toHaveBeenCalled();
61+
expect(healthService.getBasicHealth).not.toHaveBeenCalled();
62+
});
63+
});
64+
65+
describe('GET /health/live', () => {
66+
it('should return liveness health check', async () => {
67+
const expectedResult: HealthCheckResult = {
68+
status: 'healthy',
69+
version: '1.0.0',
70+
uptime: 3600,
71+
timestamp: '2023-01-01T00:00:00.000Z',
72+
};
73+
74+
mockHealthService.isAppShuttingDown.mockReturnValue(false);
75+
mockHealthService.getLivenessHealth.mockResolvedValue(expectedResult);
76+
77+
const result = await controller.getLivenessHealth();
78+
79+
expect(result).toEqual(expectedResult);
80+
expect(healthService.isAppShuttingDown).toHaveBeenCalled();
81+
expect(healthService.getLivenessHealth).toHaveBeenCalled();
82+
});
83+
84+
it('should return 403 when app is shutting down', async () => {
85+
mockHealthService.isAppShuttingDown.mockReturnValue(true);
86+
87+
await expect(controller.getLivenessHealth()).rejects.toThrow('Application is shutting down');
88+
expect(healthService.isAppShuttingDown).toHaveBeenCalled();
89+
expect(healthService.getLivenessHealth).not.toHaveBeenCalled();
90+
});
91+
});
92+
93+
describe('GET /health/ready', () => {
94+
it('should return readiness health check when healthy', async () => {
95+
const expectedResult: HealthCheckResult = {
96+
status: 'healthy',
97+
version: '1.0.0',
98+
uptime: 3600,
99+
timestamp: '2023-01-01T00:00:00.000Z',
100+
checks: {
101+
database: { status: 'healthy', responseTime: 5 },
102+
redis: { status: 'healthy', responseTime: 2 },
103+
memory: { status: 'healthy', responseTime: 1 },
104+
filesystem: { status: 'healthy', responseTime: 1 },
105+
},
106+
};
107+
108+
mockHealthService.isAppShuttingDown.mockReturnValue(false);
109+
mockHealthService.getReadinessHealth.mockResolvedValue(expectedResult);
110+
111+
const result = await controller.getReadinessHealth();
112+
113+
expect(result).toEqual(expectedResult);
114+
expect(healthService.isAppShuttingDown).toHaveBeenCalled();
115+
expect(healthService.getReadinessHealth).toHaveBeenCalled();
116+
});
117+
118+
it('should return 403 when app is shutting down', async () => {
119+
mockHealthService.isAppShuttingDown.mockReturnValue(true);
120+
121+
await expect(controller.getReadinessHealth()).rejects.toThrow('Application is shutting down');
122+
expect(healthService.isAppShuttingDown).toHaveBeenCalled();
123+
expect(healthService.getReadinessHealth).not.toHaveBeenCalled();
124+
});
125+
126+
it('should return 403 when service is not ready', async () => {
127+
const expectedResult: HealthCheckResult = {
128+
status: 'unhealthy',
129+
version: '1.0.0',
130+
uptime: 3600,
131+
timestamp: '2023-01-01T00:00:00.000Z',
132+
checks: {
133+
database: { status: 'unhealthy', error: 'Connection failed' },
134+
redis: { status: 'healthy', responseTime: 2 },
135+
memory: { status: 'healthy', responseTime: 1 },
136+
filesystem: { status: 'healthy', responseTime: 1 },
137+
},
138+
};
139+
140+
mockHealthService.isAppShuttingDown.mockReturnValue(false);
141+
mockHealthService.getReadinessHealth.mockResolvedValue(expectedResult);
142+
143+
await expect(controller.getReadinessHealth()).rejects.toThrow('Service not ready');
144+
expect(healthService.isAppShuttingDown).toHaveBeenCalled();
145+
expect(healthService.getReadinessHealth).toHaveBeenCalled();
146+
});
147+
});
148+
149+
describe('GET /health/detailed', () => {
150+
const originalEnv = process.env;
151+
152+
beforeEach(() => {
153+
process.env = { ...originalEnv, ADMIN_HEALTH_KEY: 'test-admin-key' };
154+
});
155+
156+
afterEach(() => {
157+
process.env = originalEnv;
158+
});
159+
160+
it('should return detailed health check with valid admin key', async () => {
161+
const expectedResult: HealthCheckResult = {
162+
status: 'healthy',
163+
version: '1.0.0',
164+
uptime: 3600,
165+
timestamp: '2023-01-01T00:00:00.000Z',
166+
checks: {
167+
database: { status: 'healthy', responseTime: 5, details: { connected: true } },
168+
redis: { status: 'healthy', responseTime: 2, details: { connected: true } },
169+
memory: { status: 'healthy', responseTime: 1, details: { usagePercent: '45%' } },
170+
filesystem: { status: 'healthy', responseTime: 1, details: { writable: true } },
171+
},
172+
};
173+
174+
mockHealthService.isAppShuttingDown.mockReturnValue(false);
175+
mockHealthService.getDetailedHealth.mockResolvedValue(expectedResult);
176+
177+
const result = await controller.getDetailedHealth('test-admin-key');
178+
179+
expect(result).toEqual(expectedResult);
180+
expect(healthService.isAppShuttingDown).toHaveBeenCalled();
181+
expect(healthService.getDetailedHealth).toHaveBeenCalled();
182+
});
183+
184+
it('should return 403 with invalid admin key', async () => {
185+
await expect(controller.getDetailedHealth('invalid-key')).rejects.toThrow('Admin access required');
186+
expect(healthService.isAppShuttingDown).not.toHaveBeenCalled();
187+
expect(healthService.getDetailedHealth).not.toHaveBeenCalled();
188+
});
189+
190+
it('should return 403 when app is shutting down', async () => {
191+
mockHealthService.isAppShuttingDown.mockReturnValue(true);
192+
193+
await expect(controller.getDetailedHealth('test-admin-key')).rejects.toThrow('Application is shutting down');
194+
expect(healthService.isAppShuttingDown).toHaveBeenCalled();
195+
expect(healthService.getDetailedHealth).not.toHaveBeenCalled();
196+
});
197+
});
198+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {
2+
Controller,
3+
Get,
4+
HttpCode,
5+
HttpStatus,
6+
Headers,
7+
ForbiddenException
8+
} from '@nestjs/common';
9+
import { HealthService } from './health.service';
10+
import { HealthCheckResult } from './health.interfaces';
11+
12+
@Controller('health')
13+
export class HealthController {
14+
constructor(private readonly healthService: HealthService) {}
15+
16+
@Get()
17+
@HttpCode(HttpStatus.OK)
18+
async getBasicHealth(): Promise<HealthCheckResult> {
19+
// Check if app is shutting down
20+
if (this.healthService.isAppShuttingDown()) {
21+
throw new ForbiddenException('Application is shutting down');
22+
}
23+
24+
return this.healthService.getBasicHealth();
25+
}
26+
27+
@Get('live')
28+
@HttpCode(HttpStatus.OK)
29+
async getLivenessHealth(): Promise<HealthCheckResult> {
30+
// Kubernetes liveness probe - check if process is alive
31+
if (this.healthService.isAppShuttingDown()) {
32+
throw new ForbiddenException('Application is shutting down');
33+
}
34+
35+
return this.healthService.getLivenessHealth();
36+
}
37+
38+
@Get('ready')
39+
@HttpCode(HttpStatus.OK)
40+
async getReadinessHealth(): Promise<HealthCheckResult> {
41+
// Kubernetes readiness probe - check if ready to serve traffic
42+
if (this.healthService.isAppShuttingDown()) {
43+
throw new ForbiddenException('Application is shutting down');
44+
}
45+
46+
const health = await this.healthService.getReadinessHealth();
47+
48+
// Return appropriate HTTP status based on health
49+
if (health.status === 'unhealthy') {
50+
throw new ForbiddenException('Service not ready');
51+
}
52+
53+
return health;
54+
}
55+
56+
@Get('detailed')
57+
@HttpCode(HttpStatus.OK)
58+
async getDetailedHealth(@Headers('x-admin-key') adminKey?: string): Promise<HealthCheckResult> {
59+
// Admin-only detailed health check
60+
const requiredAdminKey = process.env.ADMIN_HEALTH_KEY || 'admin-key';
61+
62+
if (adminKey !== requiredAdminKey) {
63+
throw new ForbiddenException('Admin access required');
64+
}
65+
66+
if (this.healthService.isAppShuttingDown()) {
67+
throw new ForbiddenException('Application is shutting down');
68+
}
69+
70+
return this.healthService.getDetailedHealth();
71+
}
72+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export interface HealthCheckResult {
2+
status: 'healthy' | 'degraded' | 'unhealthy';
3+
version: string;
4+
uptime: number;
5+
timestamp: string;
6+
checks?: Record<string, HealthCheck>;
7+
}
8+
9+
export interface HealthCheck {
10+
status: 'healthy' | 'degraded' | 'unhealthy';
11+
responseTime?: number;
12+
error?: string;
13+
details?: Record<string, any>;
14+
}
15+
16+
export interface HealthStatus {
17+
database: HealthCheck;
18+
redis: HealthCheck;
19+
memory: HealthCheck;
20+
filesystem: HealthCheck;
21+
externalApis?: Record<string, HealthCheck>;
22+
}
23+
24+
export interface HealthCheckOptions {
25+
includeDetails?: boolean;
26+
timeout?: number;
27+
skipCache?: boolean;
28+
}
29+
30+
export const HEALTH_CHECK_TIMEOUT = 5000; // 5 seconds
31+
export const HEALTH_CACHE_TTL = 30000; // 30 seconds
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { ConfigModule } from '@nestjs/config';
4+
import { HealthController } from './health.controller';
5+
import { HealthService } from './health.service';
6+
import { RedisModule } from '../redis/redis.module';
7+
8+
@Module({
9+
imports: [
10+
ConfigModule,
11+
TypeOrmModule,
12+
RedisModule,
13+
],
14+
controllers: [HealthController],
15+
providers: [HealthService],
16+
exports: [HealthService],
17+
})
18+
export class HealthModule {}

0 commit comments

Comments
 (0)