Skip to content
Merged
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 jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const backendProject = {
"^@backend/dev(/(.*)$)?": "<rootDir>/packages/backend/src/dev/$1",
"^@backend/email(/(.*)$)?": "<rootDir>/packages/backend/src/email/$1",
"^@backend/event(/(.*)$)?": "<rootDir>/packages/backend/src/event/$1",
"^@backend/health(/(.*)$)?": "<rootDir>/packages/backend/src/health/$1",
"^@backend/priority(/(.*)$)?": "<rootDir>/packages/backend/src/priority/$1",
"^@backend/servers(/(.*)$)?": "<rootDir>/packages/backend/src/servers/$1",
"^@backend/sync(/(.*)$)?": "<rootDir>/packages/backend/src/sync/$1",
Expand Down
155 changes: 155 additions & 0 deletions packages/backend/src/health/controllers/health.controller.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
});
45 changes: 45 additions & 0 deletions packages/backend/src/health/controllers/health.controller.ts
Original file line number Diff line number Diff line change
@@ -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<never, HealthResponse, never, never>,
res: Response<HealthResponse>,
) => {
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();
30 changes: 30 additions & 0 deletions packages/backend/src/health/health.routes.config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions packages/backend/src/servers/express/express.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,6 +37,7 @@ export const initExpressServer = () => {
app.use(express.json());

const routes: Array<CommonRoutesConfig> = [];
routes.push(new HealthRoutes(app));
routes.push(new AuthRoutes(app));
routes.push(new UserRoutes(app));
routes.push(new PriorityRoutes(app));
Expand Down