feat(backend): improve health check with dependency probes
This commit is contained in:
parent
9f85dc5dde
commit
6e8efd2b80
@ -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,
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
52
apps/backend/src/modules/health/health.service.spec.ts
Normal file
52
apps/backend/src/modules/health/health.service.spec.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
72
apps/backend/src/modules/health/health.service.ts
Normal file
72
apps/backend/src/modules/health/health.service.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user