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
84 changes: 1 addition & 83 deletions src/app.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,104 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { RedisService } from './redis/redis.service';
import { DataSource } from 'typeorm';

jest.mock('./prisma/prisma.service', () => {
return {
PrismaService: jest.fn().mockImplementation(() => ({
$queryRaw: jest.fn().mockResolvedValue([1]),
})),
};
});

import { PrismaService } from './prisma/prisma.service';

describe('AppController', () => {
let appController: AppController;
let redisService: RedisService;
let prismaService: PrismaService;
let dataSource: DataSource;

beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [
AppService,
{
provide: RedisService,
useValue: {
isHealthy: jest.fn().mockResolvedValue(true),
},
},
{
provide: PrismaService,
useValue: {
$queryRaw: jest.fn().mockResolvedValue([1]),
},
},
{
provide: DataSource,
useValue: {
query: jest.fn().mockResolvedValue([1]),
},
},
],
providers: [AppService],
}).compile();

appController = app.get<AppController>(AppController);
redisService = app.get<RedisService>(RedisService);
prismaService = app.get<PrismaService>(PrismaService);
dataSource = app.get<DataSource>(DataSource);
});

describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});

describe('health', () => {
it('should return healthy status when all services are healthy', async () => {
const health = await appController.getHealth();

expect(health.status).toBe('OK');
expect(health.services.database).toBe('healthy');
expect(health.services.prisma).toBe('healthy');
expect(health.services.redis).toBe('healthy');
});

it('should return error status when TypeORM database is unhealthy', async () => {
jest.spyOn(dataSource, 'query').mockRejectedValueOnce(new Error('Connection lost'));

const health = await appController.getHealth();

expect(health.status).toBe('Error');
expect(health.services.database).toContain('unhealthy');
expect(health.services.prisma).toBe('healthy');
expect(health.services.redis).toBe('healthy');
});

it('should return error status when Prisma database is unhealthy', async () => {
jest.spyOn(prismaService, '$queryRaw').mockRejectedValueOnce(new Error('Prisma error'));

const health = await appController.getHealth();

expect(health.status).toBe('Error');
expect(health.services.database).toBe('healthy');
expect(health.services.prisma).toContain('unhealthy');
expect(health.services.redis).toBe('healthy');
});

it('should return error status when Redis is unhealthy', async () => {
jest.spyOn(redisService, 'isHealthy').mockResolvedValueOnce(false);

const health = await appController.getHealth();

expect(health.status).toBe('Error');
expect(health.services.database).toBe('healthy');
expect(health.services.prisma).toBe('healthy');
expect(health.services.redis).toBe('unhealthy');
});
});
});
69 changes: 2 additions & 67 deletions src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiTags } from '@nestjs/swagger';
import { AppService } from './app.service';
import { RedisService } from './redis/redis.service';
import { PrismaService } from './prisma/prisma.service';
import { DataSource } from 'typeorm';
import { Public } from './decorators/public.decorator';

@ApiTags('health')
@Controller()
export class AppController {
constructor(
private readonly appService: AppService,
private readonly redisService: RedisService,
private readonly prismaService: PrismaService,
private readonly dataSource: DataSource,
) {}
constructor(private readonly appService: AppService) {}

@Get()
getHello(): string {
return this.appService.getHello();
}

@Public()
@Get('health')
@ApiOperation({ summary: 'Get application health status' })
async getHealth() {
let dbStatus = 'healthy';
let prismaStatus = 'healthy';
let redisStatus = 'healthy';
let isHealthy = true;

// Check TypeORM DB
try {
const result = await this.dataSource.query('SELECT 1');
if (!result || result.length === 0) {
dbStatus = 'unhealthy';
isHealthy = false;
}
} catch (err) {
dbStatus = `unhealthy: ${err.message}`;
isHealthy = false;
}

// Check Prisma DB
try {
const result = await this.prismaService.$queryRaw`SELECT 1`;
if (!result) {
prismaStatus = 'unhealthy';
isHealthy = false;
}
} catch (err) {
prismaStatus = `unhealthy: ${err.message}`;
isHealthy = false;
}

// Check Redis
try {
const redisHealthy = await this.redisService.isHealthy();
if (!redisHealthy) {
redisStatus = 'unhealthy';
isHealthy = false;
}
} catch (err) {
redisStatus = `unhealthy: ${err.message}`;
isHealthy = false;
}

return {
status: isHealthy ? 'OK' : 'Error',
timestamp: new Date().toISOString(),
services: {
database: dbStatus,
prisma: prismaStatus,
redis: redisStatus,
},
};
}
}
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { GovernanceModule } from './governance/governance.module';
import { AdminModule } from './admin/admin.module';
import { ProfilerModule } from './profiler/profiler.module';
import { ProfilerInterceptor } from './profiler/profiler.interceptor';
import { HealthModule } from './health/health.module';

// In-memory storage for development (no Redis needed)
class ThrottlerMemoryStorage {
Expand Down Expand Up @@ -316,6 +317,7 @@ async function createThrottlerStorage(
GovernanceModule,
AdminModule,
ProfilerModule,
HealthModule,
],
controllers: [AppController],
providers: [
Expand Down
29 changes: 23 additions & 6 deletions src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Public } from '../decorators/public.decorator';
import { HealthService } from './health.service';
import {
DependencyHealthResult,
HealthCheckResult,
LivenessResult,
ReadinessResult,
StartupResult,
} from './health.types';

@ApiTags('Health')
@Public()
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
Expand All @@ -29,16 +33,29 @@ export class HealthController {
res.status(statusCode).json(result);
}

@Get('startup')
@ApiOperation({ summary: 'Startup probe' })
async startup(@Res() res: Response): Promise<void> {
const result: StartupResult = await this.healthService.getStartup();
const statusCode = result.startupComplete && result.ready
? HttpStatus.OK
: HttpStatus.SERVICE_UNAVAILABLE;
res.status(statusCode).json(result);
}

@Get('dependencies')
@ApiOperation({ summary: 'Dependency health report' })
dependencies(): DependencyHealthResult {
return this.healthService.getDependencyHealth();
}

@Get()
@ApiOperation({ summary: 'Aggregated health report' })
async health(@Res() res: Response): Promise<void> {
const result: HealthCheckResult = await this.healthService.getHealth();
const statusCode =
result.status === 'unhealthy'
? HttpStatus.SERVICE_UNAVAILABLE
: result.status === 'degraded'
? HttpStatus.OK
: HttpStatus.OK;
const statusCode = result.status === 'unhealthy'
? HttpStatus.SERVICE_UNAVAILABLE
: HttpStatus.OK;
res.status(statusCode).json(result);
}
}
14 changes: 8 additions & 6 deletions src/health/health.module.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RedisModule } from '../redis/redis.module';
import { HealthService } from './health.service';
import { HealthController } from './health.controller';
import { JobsModule } from '../jobs/jobs.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { IpfsModule } from '../ipfs/ipfs.module';
import { BlockchainModule } from '../blockchain/blockchain.module';

@Module({
imports: [
TypeOrmModule.forFeature(),
RedisModule,
BullModule.registerQueue({
name: 'jobs-queue',
}),
JobsModule,
NotificationsModule,
IpfsModule,
BlockchainModule,
],
providers: [HealthService],
controllers: [HealthController],
Expand Down
72 changes: 72 additions & 0 deletions src/health/health.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { HealthService } from './health.service';
import { RedisService } from '../redis/redis.service';
import { DataSource } from 'typeorm';
import { Queue } from 'bullmq';
import { JobsService } from '../jobs/jobs.service';
import { NotificationService } from '../notifications/services/notification.service';
import { IpfsService } from '../ipfs/ipfs.service';
import { BlockchainStateService } from '../blockchain/state.service';

const mockDataSource = () => ({
isInitialized: true,
Expand All @@ -17,11 +21,31 @@ const mockQueue = () => ({
getJobCounts: jest.fn(),
});

const mockJobsService = () => ({
getQueueMetrics: jest.fn(),
});

const mockNotificationService = () => ({
getMetrics: jest.fn(),
});

const mockIpfsService = () => ({
uploadBuffer: jest.fn(),
});

const mockBlockchainStateService = () => ({
getChainState: jest.fn(),
});

describe('HealthService', () => {
let service: HealthService;
let dataSource: DataSource;
let redisService: RedisService;
let queue: Queue;
let jobsService: JobsService;
let notificationService: NotificationService;
let ipfsService: IpfsService;
let blockchainStateService: BlockchainStateService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand All @@ -30,13 +54,21 @@ describe('HealthService', () => {
{ provide: DataSource, useFactory: mockDataSource },
{ provide: RedisService, useFactory: mockRedisService },
{ provide: 'BullQueue_jobs-queue', useFactory: mockQueue },
{ provide: JobsService, useFactory: mockJobsService },
{ provide: NotificationService, useFactory: mockNotificationService },
{ provide: IpfsService, useFactory: mockIpfsService },
{ provide: BlockchainStateService, useFactory: mockBlockchainStateService },
],
}).compile();

service = module.get<HealthService>(HealthService);
dataSource = module.get<DataSource>(DataSource);
redisService = module.get<RedisService>(RedisService);
queue = module.get<Queue>('BullQueue_jobs-queue');
jobsService = module.get<JobsService>(JobsService);
notificationService = module.get<NotificationService>(NotificationService);
ipfsService = module.get<IpfsService>(IpfsService);
blockchainStateService = module.get<BlockchainStateService>(BlockchainStateService);
});

it('should return alive liveness result', () => {
Expand Down Expand Up @@ -90,4 +122,44 @@ describe('HealthService', () => {
expect(result.ready).toBe(true);
expect(result.status).toBe('degraded');
});

it('should include health metadata and dependency summary', async () => {
(dataSource.query as jest.Mock).mockResolvedValue([{ 1: 1 }]);
(redisService.isHealthy as jest.Mock).mockResolvedValue(true);
(queue.getJobCounts as jest.Mock).mockResolvedValue({
waiting: 0,
active: 0,
completed: 0,
failed: 0,
});

const result = await service.getHealth();

expect(result.environment).toBe(process.env.NODE_ENV ?? 'development');
expect(result.summary).toEqual(
expect.objectContaining({
healthy: expect.any(Number),
degraded: expect.any(Number),
unhealthy: expect.any(Number),
}),
);
expect(result.dependencies.some((dep) => dep.name === 'database')).toBe(true);
});

it('should return not ready while shutting down', async () => {
(dataSource.query as jest.Mock).mockResolvedValue([{ 1: 1 }]);
(redisService.isHealthy as jest.Mock).mockResolvedValue(true);
(queue.getJobCounts as jest.Mock).mockResolvedValue({
waiting: 0,
active: 0,
completed: 0,
failed: 0,
});

(service as unknown as { shuttingDown: boolean }).shuttingDown = true;

const result = await service.getReadiness();
expect(result.ready).toBe(false);
expect(result.status).toBe('unhealthy');
});
});
Loading
Loading