Skip to content
Closed
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
1 change: 1 addition & 0 deletions app/backend/src/app.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export class AppService {
docs: '/api/docs',
endpoints: {
health: '/api/v1/health',
healthDependencies: '/api/v1/health/dependencies',
aid: '/api/v1/aid',
verification: '/api/v1/verification',
},
Expand Down
33 changes: 33 additions & 0 deletions app/backend/src/health/health.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,37 @@ describe('HealthController', () => {
}),
);
});

it('GET /health/dependencies returns ready when all dependency probes pass', async () => {
const healthService = app.get<HealthService>(HealthService);
jest
.spyOn(healthService as any, 'getDependencyProbe')
.mockResolvedValueOnce({
status: 'ready',
ready: true,
service: 'backend',
timestamp: new Date().toISOString(),
checks: {
redis: { status: 'up' },
providerConfiguration: { status: 'up' },
filesystem: { status: 'up' },
},
});

const res = await request(app.getHttpServer())
.get('/health/dependencies')
.expect(200);

expect(res.body).toEqual(
expect.objectContaining({
status: 'ready',
ready: true,
checks: {
redis: expect.objectContaining({ status: 'up' }),
providerConfiguration: expect.objectContaining({ status: 'up' }),
filesystem: expect.objectContaining({ status: 'up' }),
},
}),
);
});
});
56 changes: 55 additions & 1 deletion app/backend/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
import { Response } from 'express';
import { RequestWithRequestId } from '../middleware/request-correlation.middleware';
import { HealthService } from './health.service';
import { LivenessResponse, ReadinessResponse } from './health.service';
import {
LivenessResponse,
ReadinessResponse,
DependencyProbeResponse,
} from './health.service';
import { API_VERSIONS } from '../common/constants/api-version.constants';
import { Public } from '../common/decorators/public.decorator';
import { Throttle } from '@nestjs/throttler';
Expand Down Expand Up @@ -110,6 +114,56 @@ export class HealthController {
return readiness;
}

@Public()
@Get('dependencies')
@Version(API_VERSIONS.V1)
@ApiOperation({
summary: 'Dependency probe',
description:
'Checks Redis connectivity, provider configuration readiness, and filesystem/temp access.',
})
@ApiOkResponse({
description: 'All dependency checks passed.',
schema: {
example: {
status: 'ready',
ready: true,
service: 'backend',
checks: {
redis: { status: 'up' },
providerConfiguration: { status: 'up' },
filesystem: { status: 'up' },
},
},
},
})
@ApiServiceUnavailableResponse({
description: 'One or more dependency checks failed.',
schema: {
example: {
status: 'not_ready',
ready: false,
service: 'backend',
checks: {
redis: { status: 'down' },
providerConfiguration: { status: 'up' },
filesystem: { status: 'up' },
},
},
},
})
async dependencies(
@Res({ passthrough: true }) res: Response,
): Promise<DependencyProbeResponse> {
const probe = await this.healthService.getDependencyProbe();

if (!probe.ready) {
res.status(HttpStatus.SERVICE_UNAVAILABLE);
}

return probe;
}

@Get('error')
@Version(API_VERSIONS.V1)
@ApiOperation({ summary: 'Trigger an error for testing' })
Expand Down
91 changes: 91 additions & 0 deletions app/backend/src/health/health.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { ConfigService } from '@nestjs/config';
import { HealthService } from './health.service';
import { LoggerService } from '../logger/logger.service';
import { PrismaService } from '../prisma/prisma.service';

describe('HealthService dependency probe', () => {
let service: HealthService;
const configValues: Record<string, string | undefined> = {
VERIFICATION_MODE: 'mock',
AI_SERVICE_URL: 'http://localhost:8000',
OPENAI_API_KEY: undefined,
};

const configMock = {
get: jest.fn((key: string) => configValues[key]),
};

const loggerMock = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};

const prismaMock = {} as PrismaService;

beforeEach(() => {
jest.clearAllMocks();
service = new HealthService(
configMock as unknown as ConfigService,
loggerMock as unknown as LoggerService,
prismaMock,
);
});

it('marks provider configuration ready for mock verification mode', async () => {
configValues.VERIFICATION_MODE = 'mock';
configValues.AI_SERVICE_URL = undefined;
configValues.OPENAI_API_KEY = undefined;

const result = await (service as any).checkProviderConfiguration();

expect(result.status).toBe('up');
expect(result.details).toEqual(
expect.objectContaining({
verificationMode: 'mock',
aiServiceUrlConfigured: false,
openAIKeyConfigured: false,
required: false,
}),
);
});

it('marks provider configuration not ready when AI mode is enabled and required config is missing', async () => {
configValues.VERIFICATION_MODE = 'ai';
configValues.AI_SERVICE_URL = 'http://localhost:8000';
configValues.OPENAI_API_KEY = undefined;

const result = await (service as any).checkProviderConfiguration();

expect(result.status).toBe('down');
expect(result.details).toEqual(
expect.objectContaining({
verificationMode: 'ai',
aiServiceUrlConfigured: true,
openAIKeyConfigured: false,
required: true,
}),
);
});

it('returns a ready dependency probe result when all checks pass', async () => {
configValues.VERIFICATION_MODE = 'mock';
configValues.AI_SERVICE_URL = undefined;
configValues.OPENAI_API_KEY = undefined;

jest
.spyOn(service as any, 'checkRedisConnectivity')
.mockResolvedValue({ status: 'up' });
jest
.spyOn(service as any, 'checkFilesystemAccess')
.mockResolvedValue({ status: 'up' });

const probe = await service.getDependencyProbe();

expect(probe.ready).toBe(true);
expect(probe.status).toBe('ready');
expect(probe.checks.redis.status).toBe('up');
expect(probe.checks.filesystem.status).toBe('up');
expect(probe.checks.providerConfiguration.status).toBe('up');
});
});
Loading
Loading