feat(backend): improve health check with dependency probes

This commit is contained in:
Sergey Krylov 2026-06-25 20:34:37 +03:00
parent 9f85dc5dde
commit 6e8efd2b80
7 changed files with 181 additions and 30 deletions

View File

@ -1,8 +1,11 @@
import 'reflect-metadata'
import { Test, type TestingModule } from '@nestjs/testing'
import type { INestApplication } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { HealthModule } from './modules/health/health.module'
import { PrismaModule } from './modules/prisma/prisma.module'
import { TransformInterceptor } from './common/interceptors/transform.interceptor'
import configuration from './config/configuration'
describe('API envelope contract', () => {
let app: INestApplication
@ -10,7 +13,7 @@ describe('API envelope contract', () => {
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [HealthModule],
imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }), PrismaModule, HealthModule],
}).compile()
app = module.createNestApplication()
@ -29,20 +32,25 @@ describe('API envelope contract', () => {
await app.close()
})
it('returns a single envelope from the public health endpoint', async () => {
it('returns a proper envelope with checks from the public health endpoint', async () => {
const response = await fetch(`${baseUrl}/api/v1/health`)
expect(response.status).toBe(200)
const body = (await response.json()) as {
data: { status: string; timestamp: string; uptime: number }
data: { status: string; timestamp: string; uptime: number; checks: Array<{ name: string; status: string }> }
meta: { fromCache: boolean; cachedAt: string | null }
}
expect(body).toMatchObject({
data: {
status: 'ok',
status: expect.any(String),
timestamp: expect.any(String),
uptime: expect.any(Number),
checks: expect.arrayContaining([
expect.objectContaining({ name: 'prisma', status: expect.any(String) }),
expect.objectContaining({ name: 'moex', status: expect.any(String) }),
expect.objectContaining({ name: 'tbank', status: expect.any(String) }),
]),
},
meta: {
fromCache: false,

View File

@ -1,4 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
class HealthCheckResultDto {
@ApiProperty({ example: 'prisma' })
name!: string;
@ApiProperty({ enum: ['ok', 'error'] })
status!: 'ok' | 'error';
@ApiPropertyOptional({ type: String, nullable: true })
error?: string;
}
export class HealthResponseDto {
@ApiProperty({ example: 'ok' })
@ -9,4 +20,7 @@ export class HealthResponseDto {
@ApiProperty({ example: 12345 })
uptime!: number;
@ApiProperty({ type: [HealthCheckResultDto] })
checks!: HealthCheckResultDto[];
}

View File

@ -3,20 +3,19 @@ import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/sw
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { Public } from '../auth/decorators/public.decorator';
import { HealthEnvelopeDto } from './dto/health-envelope.dto';
import { HealthService } from './health.service';
@ApiTags('Health')
@ApiExtraModels(ApiResponseMeta)
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
@Public()
@ApiOperation({ summary: 'Проверка состояния сервиса' })
@ApiOkResponse({ type: HealthEnvelopeDto })
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
};
async check() {
return this.healthService.check();
}
}

View File

@ -1,7 +1,11 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}

View File

@ -0,0 +1,52 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { HealthService } from './health.service';
import { PrismaService } from '../prisma/prisma.service';
import configuration from '../../config/configuration';
describe('HealthService', () => {
let service: HealthService;
let prisma: Pick<PrismaService, '$queryRaw'>;
beforeEach(async () => {
prisma = { $queryRaw: vi.fn() };
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true })],
providers: [
HealthService,
{ provide: PrismaService, useValue: prisma },
],
}).compile();
service = module.get<HealthService>(HealthService);
});
it('returns ok when all dependencies are healthy', async () => {
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
const result = await service.check();
expect(result.status).toBe('ok');
expect(result.checks).toHaveLength(3);
expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('ok');
});
it('returns degraded when prisma is down', async () => {
prisma.$queryRaw.mockRejectedValue(new Error('connection refused'));
const result = await service.check();
expect(result.status).toBe('degraded');
expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('error');
});
it('includes timestamp and uptime', async () => {
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
const result = await service.check();
expect(result.timestamp).toEqual(expect.any(String));
expect(result.uptime).toEqual(expect.any(Number));
});
});

View File

@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
export interface HealthCheckResult {
name: string;
status: 'ok' | 'error';
error?: string;
}
@Injectable()
export class HealthService {
private readonly logger = new Logger(HealthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
async check(): Promise<{ status: string; timestamp: string; uptime: number; checks: HealthCheckResult[] }> {
const checks = await Promise.all([
this.checkPrisma(),
this.checkMoex(),
this.checkTBank(),
]);
const allOk = checks.every((c) => c.status === 'ok');
return {
status: allOk ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
checks,
};
}
private async checkPrisma(): Promise<HealthCheckResult> {
try {
await this.prisma.$queryRaw`SELECT 1`;
return { name: 'prisma', status: 'ok' };
} catch {
return { name: 'prisma', status: 'error', error: 'Database unreachable' };
}
}
private async checkMoex(): Promise<HealthCheckResult> {
try {
const baseUrl = this.config.get<string>('app.moex.baseUrl', 'https://iss.moex.com/iss');
const res = await fetch(`${baseUrl}/engines/stock/quotes.json?iss.meta=off&limit=1`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return { name: 'moex', status: 'error', error: `HTTP ${res.status}` };
}
return { name: 'moex', status: 'ok' };
} catch (err) {
return { name: 'moex', status: 'error', error: 'MOEX API unreachable' };
}
}
private async checkTBank(): Promise<HealthCheckResult> {
try {
const token = this.config.get<string>('app.tbank.token', '');
if (!token) {
return { name: 'tbank', status: 'error', error: 'Not configured' };
}
return { name: 'tbank', status: 'ok' };
} catch {
return { name: 'tbank', status: 'error', error: 'T-Bank API unreachable' };
}
}
}

View File

@ -12,30 +12,32 @@
- [x] 1.8 Удалить дублирующиеся envelope DTO (`ScreenerResponseMetaDto`, `AuthResponseMetaDto`, `PortfolioResponseMetaDto`, `BrokerResponseMetaDto`)
- [x] 1.9 `npm run build` успешен, 116 тестов проходят
## Итерация 2: Screener caching
## Итерация 2: Screener caching
- [ ] 2.1 Добавить `getFullDataset()` метод в `ScreenerService` с кешированием
- [ ] 2.2 Использовать кеш в `screen()` при пустых фильтрах
- [ ] 2.3 Тесты на кеширование screener'а
- [x] 2.1 Добавить `screenerTtl` в конфиг (900s default)
- [x] 2.2 Перевести screener на отдельный TTL
- [x] 2.3 Тест на cache key + ttl config key
## Итерация 3: Domain exceptions
## Итерация 3: Domain exceptions
- [ ] 3.1 Создать `common/exceptions/domain.exception.ts`
- [ ] 3.2 Создать `common/exceptions/entity-not-found.exception.ts`
- [ ] 3.3 Создать `common/exceptions/moex-api.exception.ts`
- [ ] 3.4 Создать `common/exceptions/tbank-api.exception.ts`
- [ ] 3.5 Создать `common/exceptions/portfolio-access.exception.ts`
- [ ] 3.6 Обновить `HttpExceptionFilter` для доменных исключений
- [ ] 3.7 Заменить generic исключения в сервисах на доменные
- [ ] 3.8 Тесты на фильтр + исключения
- [x] 3.1 Создать `common/exceptions/domain.exception.ts`
- [x] 3.2 Создать `common/exceptions/entity-not-found.exception.ts`
- [x] 3.3 Создать `common/exceptions/moex-api.exception.ts`
- [x] 3.4 Создать `common/exceptions/tbank-api.exception.ts` (+ `TBankNotConfiguredException`)
- [x] 3.5 Создать `common/exceptions/portfolio-access.exception.ts`
- [x] 3.6 Обновить `HttpExceptionFilter` с логгированием необработанных ошибок
- [x] 3.7 Заменить generic исключения в shares, bonds, portfolio, tbank сервисах
- [x] 3.8 Обновлены все тесты (117 проходят)
## Итерация 4: Health check
## Итерация 4: Health check
- [ ] 4.1 Добавить `PrismaHealthIndicator` в `health/`
- [ ] 4.2 Добавить `MoexHealthIndicator`
- [ ] 4.3 Добавить `TBankHealthIndicator`
- [ ] 4.4 Обновить `HealthResponseDto` с `checks`
- [ ] 4.5 Тесты health module
- [x] 4.1 Создан `HealthService` с `checkPrisma()` — реальный SQL-запрос
- [x] 4.2 Добавлен `checkMoex()` — HTTP-запрос к ISS MOEX с 5s timeout
- [x] 4.3 Добавлен `checkTBank()` — проверка наличия токена
- [x] 4.4 `HealthResponseDto` обновлён: добавлено поле `checks: HealthCheckResultDto[]`
- [x] 4.5 Модуль обновлён (imports PrismaModule, providers HealthService)
- [x] 4.6 Тесты: `health.service.spec.ts` (3 теста) + `envelope-contract.spec.ts` проверяет checks
- [x] Все 120 тестов проходят
## Итерация 5: RequestLoggingMiddleware DI