diff --git a/jest.config.js b/jest.config.js index fc60ffc05..b2bc99e05 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,6 +15,7 @@ const backendProject = { "^@backend/dev(/(.*)$)?": "/packages/backend/src/dev/$1", "^@backend/email(/(.*)$)?": "/packages/backend/src/email/$1", "^@backend/event(/(.*)$)?": "/packages/backend/src/event/$1", + "^@backend/health(/(.*)$)?": "/packages/backend/src/health/$1", "^@backend/priority(/(.*)$)?": "/packages/backend/src/priority/$1", "^@backend/servers(/(.*)$)?": "/packages/backend/src/servers/$1", "^@backend/sync(/(.*)$)?": "/packages/backend/src/sync/$1", diff --git a/packages/backend/src/health/controllers/health.controller.test.ts b/packages/backend/src/health/controllers/health.controller.test.ts new file mode 100644 index 000000000..670155e67 --- /dev/null +++ b/packages/backend/src/health/controllers/health.controller.test.ts @@ -0,0 +1,155 @@ +import { Status } from "@core/errors/status.codes"; +import { BaseDriver } from "@backend/__tests__/drivers/base.driver"; +import { + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import mongoService from "@backend/common/services/mongo.service"; + +describe("HealthController", () => { + const baseDriver = new BaseDriver(); + + beforeAll(setupTestDb); + afterAll(cleanupTestDb); + + describe("check", () => { + it("should return 200 OK with status ok and timestamp", async () => { + const response = await baseDriver + .getServer() + .get("/api/health") + .expect(Status.OK); + + expect(response.body).toEqual({ + status: "ok", + timestamp: expect.any(String), + }); + + // Verify timestamp is a valid ISO string + const timestamp = new Date(response.body.timestamp); + expect(timestamp.toISOString()).toBe(response.body.timestamp); + }); + + it("should return a recent timestamp", async () => { + const beforeRequest = new Date(); + const response = await baseDriver + .getServer() + .get("/api/health") + .expect(Status.OK); + const afterRequest = new Date(); + + const responseTimestamp = new Date(response.body.timestamp); + + // Timestamp should be between before and after request time + expect(responseTimestamp.getTime()).toBeGreaterThanOrEqual( + beforeRequest.getTime() - 1000, // Allow 1 second tolerance + ); + expect(responseTimestamp.getTime()).toBeLessThanOrEqual( + afterRequest.getTime() + 1000, // Allow 1 second tolerance + ); + }); + + it("should not require authentication", async () => { + // Health endpoint should be accessible without session + const response = await baseDriver + .getServer() + .get("/api/health") + .expect(Status.OK); + + expect(response.body.status).toBe("ok"); + }); + + it("should be accessible even with invalid session", async () => { + // Health endpoint should work regardless of session validity + const response = await baseDriver + .getServer() + .get("/api/health") + .set("Cookie", "session=invalid") + .expect(Status.OK); + + expect(response.body.status).toBe("ok"); + }); + + it("should verify database connectivity", async () => { + // When database is connected, endpoint should return successfully + const response = await baseDriver + .getServer() + .get("/api/health") + .expect(Status.OK); + + expect(response.body).toEqual({ + status: "ok", + timestamp: expect.any(String), + }); + }); + + it("should return 500 when database connectivity check fails", async () => { + const pingSpy = jest + .spyOn(Object.getPrototypeOf(mongoService.db.admin()), "ping") + .mockRejectedValue(new Error("database unavailable")); + + try { + const response = await baseDriver + .getServer() + .get("/api/health") + .expect(Status.INTERNAL_SERVER); + + expect(response.body).toEqual({ + status: "error", + timestamp: expect.any(String), + }); + } finally { + pingSpy.mockRestore(); + } + }); + + it("should return consistent response structure", async () => { + const response = await baseDriver + .getServer() + .get("/api/health") + .expect(Status.OK); + + // Verify response has exactly the expected fields + expect(Object.keys(response.body)).toEqual(["status", "timestamp"]); + expect(typeof response.body.status).toBe("string"); + expect(typeof response.body.timestamp).toBe("string"); + expect(response.body.status).toBe("ok"); + }); + + it("should handle multiple concurrent requests", async () => { + // Use fewer concurrent requests to avoid connection issues + const requests = Array.from({ length: 3 }, () => + baseDriver + .getServer() + .get("/api/health") + .expect(Status.OK) + .catch((error) => { + // Retry once on connection error + return baseDriver.getServer().get("/api/health").expect(Status.OK); + }), + ); + + const responses = await Promise.all(requests); + + // All requests should succeed + responses.forEach((response) => { + expect(response.body.status).toBe("ok"); + expect(response.body.timestamp).toBeDefined(); + }); + + // Timestamps should be close to each other (within same second) + const timestamps = responses.map((r) => new Date(r.body.timestamp)); + const minTime = Math.min(...timestamps.map((t) => t.getTime())); + const maxTime = Math.max(...timestamps.map((t) => t.getTime())); + expect(maxTime - minTime).toBeLessThan(2000); // Within 2 seconds + }); + + it("should return proper content-type header", async () => { + const response = await baseDriver + .getServer() + .get("/api/health") + .expect(Status.OK); + + expect(response.headers["content-type"]).toMatch(/application\/json/); + }); + }); +}); diff --git a/packages/backend/src/health/controllers/health.controller.ts b/packages/backend/src/health/controllers/health.controller.ts new file mode 100644 index 000000000..082df64c2 --- /dev/null +++ b/packages/backend/src/health/controllers/health.controller.ts @@ -0,0 +1,45 @@ +import { type Request, type Response } from "express"; +import { Status } from "@core/errors/status.codes"; +import { Logger } from "@core/logger/winston.logger"; +import mongoService from "@backend/common/services/mongo.service"; + +interface HealthResponse { + status: "ok" | "error"; + timestamp: string; +} + +const logger = Logger("app:health.controller"); + +class HealthController { + /** + * GET /api/health + * Health check endpoint that verifies basic system connectivity + * + * @returns {Object} Health status with timestamp + * @returns {200} OK - Database is reachable + * @returns {500} Internal Server Error - Database is unreachable + */ + check = async ( + _req: Request, + res: Response, + ) => { + const timestamp = new Date().toISOString(); + + try { + await mongoService.db.admin().ping(); + + res.status(Status.OK).json({ + status: "ok", + timestamp, + }); + } catch (error) { + logger.error("Database connectivity check failed", error); + res.status(Status.INTERNAL_SERVER).json({ + status: "error", + timestamp, + }); + } + }; +} + +export default new HealthController(); diff --git a/packages/backend/src/health/health.routes.config.ts b/packages/backend/src/health/health.routes.config.ts new file mode 100644 index 000000000..5e8ef8ccb --- /dev/null +++ b/packages/backend/src/health/health.routes.config.ts @@ -0,0 +1,30 @@ +import type express from "express"; +import { CommonRoutesConfig } from "@backend/common/common.routes.config"; +import healthController from "./controllers/health.controller"; + +/** + * Health Routes Configuration + * + * Provides health check endpoint for monitoring system status. + * This endpoint does not require authentication as it's used by + * load balancers, monitoring tools, and orchestration systems. + */ +export class HealthRoutes extends CommonRoutesConfig { + constructor(app: express.Application) { + super(app, "HealthRoutes"); + } + + configureRoutes(): express.Application { + /** + * GET /api/health + * Health check endpoint that verifies basic system connectivity + * + * @returns {Object} Health status with timestamp + * @returns {200} OK - Database is reachable + * @returns {500} Internal Server Error - Database is unreachable + */ + this.app.route(`/api/health`).get(healthController.check); + + return this.app; + } +} diff --git a/packages/backend/src/servers/express/express.server.ts b/packages/backend/src/servers/express/express.server.ts index 79ff75371..f58b7a371 100644 --- a/packages/backend/src/servers/express/express.server.ts +++ b/packages/backend/src/servers/express/express.server.ts @@ -15,6 +15,7 @@ import { supertokensCors, } from "@backend/common/middleware/supertokens.middleware"; import { EventRoutes } from "@backend/event/event.routes.config"; +import { HealthRoutes } from "@backend/health/health.routes.config"; import { PriorityRoutes } from "@backend/priority/priority.routes.config"; import { SyncRoutes } from "@backend/sync/sync.routes.config"; import { UserRoutes } from "@backend/user/user.routes.config"; @@ -36,6 +37,7 @@ export const initExpressServer = () => { app.use(express.json()); const routes: Array = []; + routes.push(new HealthRoutes(app)); routes.push(new AuthRoutes(app)); routes.push(new UserRoutes(app)); routes.push(new PriorityRoutes(app));