diff --git a/apps/orchestrator/src/api/health/health.controller.spec.ts b/apps/orchestrator/src/api/health/health.controller.spec.ts index 0b11958..c1c9986 100644 --- a/apps/orchestrator/src/api/health/health.controller.spec.ts +++ b/apps/orchestrator/src/api/health/health.controller.spec.ts @@ -1,13 +1,21 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { HttpException, HttpStatus } from "@nestjs/common"; import { HealthController } from "./health.controller"; import { HealthService } from "./health.service"; +import { ValkeyService } from "../../valkey/valkey.service"; + +// Mock ValkeyService +const mockValkeyService = { + ping: vi.fn(), +} as unknown as ValkeyService; describe("HealthController", () => { let controller: HealthController; let service: HealthService; beforeEach(() => { - service = new HealthService(); + vi.clearAllMocks(); + service = new HealthService(mockValkeyService); controller = new HealthController(service); }); @@ -83,17 +91,46 @@ describe("HealthController", () => { }); describe("GET /health/ready", () => { - it("should return ready status", () => { - const result = controller.ready(); + it("should return ready status with checks when all dependencies are healthy", async () => { + vi.mocked(mockValkeyService.ping).mockResolvedValue(true); + + const result = await controller.ready(); expect(result).toBeDefined(); expect(result).toHaveProperty("ready"); + expect(result).toHaveProperty("checks"); + expect(result.ready).toBe(true); + expect(result.checks.valkey).toBe(true); }); - it("should return ready as true", () => { - const result = controller.ready(); + it("should throw 503 when Valkey is unhealthy", async () => { + vi.mocked(mockValkeyService.ping).mockResolvedValue(false); - expect(result.ready).toBe(true); + await expect(controller.ready()).rejects.toThrow(HttpException); + + try { + await controller.ready(); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(HttpStatus.SERVICE_UNAVAILABLE); + const response = (error as HttpException).getResponse() as { ready: boolean }; + expect(response.ready).toBe(false); + } + }); + + it("should return checks object with individual dependency status", async () => { + vi.mocked(mockValkeyService.ping).mockResolvedValue(true); + + const result = await controller.ready(); + + expect(result.checks).toBeDefined(); + expect(typeof result.checks.valkey).toBe("boolean"); + }); + + it("should handle Valkey ping errors gracefully", async () => { + vi.mocked(mockValkeyService.ping).mockRejectedValue(new Error("Connection refused")); + + await expect(controller.ready()).rejects.toThrow(HttpException); }); }); }); diff --git a/apps/orchestrator/src/api/health/health.controller.ts b/apps/orchestrator/src/api/health/health.controller.ts index a0e0de6..c7e7fa5 100644 --- a/apps/orchestrator/src/api/health/health.controller.ts +++ b/apps/orchestrator/src/api/health/health.controller.ts @@ -1,6 +1,6 @@ -import { Controller, Get, UseGuards } from "@nestjs/common"; +import { Controller, Get, UseGuards, HttpStatus, HttpException } from "@nestjs/common"; import { Throttle } from "@nestjs/throttler"; -import { HealthService } from "./health.service"; +import { HealthService, ReadinessResult } from "./health.service"; import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard"; /** @@ -26,8 +26,13 @@ export class HealthController { @Get("ready") @Throttle({ status: { limit: 200, ttl: 60000 } }) - ready(): { ready: boolean } { - // NOTE: Check Valkey connection, Docker daemon (see issue #TBD) - return { ready: true }; + async ready(): Promise { + const result = await this.healthService.isReady(); + + if (!result.ready) { + throw new HttpException(result, HttpStatus.SERVICE_UNAVAILABLE); + } + + return result; } } diff --git a/apps/orchestrator/src/api/health/health.module.ts b/apps/orchestrator/src/api/health/health.module.ts index bf94834..307b3bc 100644 --- a/apps/orchestrator/src/api/health/health.module.ts +++ b/apps/orchestrator/src/api/health/health.module.ts @@ -1,8 +1,10 @@ import { Module } from "@nestjs/common"; import { HealthController } from "./health.controller"; import { HealthService } from "./health.service"; +import { ValkeyModule } from "../../valkey/valkey.module"; @Module({ + imports: [ValkeyModule], controllers: [HealthController], providers: [HealthService], }) diff --git a/apps/orchestrator/src/api/health/health.service.ts b/apps/orchestrator/src/api/health/health.service.ts index 75c27e7..d05887a 100644 --- a/apps/orchestrator/src/api/health/health.service.ts +++ b/apps/orchestrator/src/api/health/health.service.ts @@ -1,14 +1,56 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; +import { ValkeyService } from "../../valkey/valkey.service"; + +export interface ReadinessResult { + ready: boolean; + checks: { + valkey: boolean; + }; +} @Injectable() export class HealthService { private readonly startTime: number; + private readonly logger = new Logger(HealthService.name); - constructor() { + constructor(private readonly valkeyService: ValkeyService) { this.startTime = Date.now(); } getUptime(): number { return Math.floor((Date.now() - this.startTime) / 1000); } + + /** + * Check if the service is ready to accept requests + * Validates connectivity to required dependencies + */ + async isReady(): Promise { + const valkeyReady = await this.checkValkey(); + + const ready = valkeyReady; + + if (!ready) { + this.logger.warn(`Readiness check failed: valkey=${String(valkeyReady)}`); + } + + return { + ready, + checks: { + valkey: valkeyReady, + }, + }; + } + + private async checkValkey(): Promise { + try { + return await this.valkeyService.ping(); + } catch (error) { + this.logger.error( + "Valkey health check failed", + error instanceof Error ? error.message : String(error) + ); + return false; + } + } } diff --git a/apps/orchestrator/src/valkey/valkey.client.ts b/apps/orchestrator/src/valkey/valkey.client.ts index b0fbe68..c16786b 100644 --- a/apps/orchestrator/src/valkey/valkey.client.ts +++ b/apps/orchestrator/src/valkey/valkey.client.ts @@ -71,6 +71,19 @@ export class ValkeyClient { } } + /** + * Check Valkey connectivity + * @returns true if connection is healthy, false otherwise + */ + async ping(): Promise { + try { + await this.client.ping(); + return true; + } catch { + return false; + } + } + /** * Task State Management */ diff --git a/apps/orchestrator/src/valkey/valkey.service.ts b/apps/orchestrator/src/valkey/valkey.service.ts index 45fdfa8..2c2dee2 100644 --- a/apps/orchestrator/src/valkey/valkey.service.ts +++ b/apps/orchestrator/src/valkey/valkey.service.ts @@ -152,4 +152,12 @@ export class ValkeyService implements OnModuleDestroy { }; await this.setAgentState(state); } + + /** + * Check Valkey connectivity + * @returns true if connection is healthy, false otherwise + */ + async ping(): Promise { + return this.client.ping(); + } }