diff --git a/backend/routes/health.js b/backend/routes/health.js new file mode 100644 index 0000000..29162e8 --- /dev/null +++ b/backend/routes/health.js @@ -0,0 +1,121 @@ +const express = require("express"); +const router = express.Router(); +const { sequelize } = require("../models"); +const s3Service = require("../services/s3Service"); +const logger = require("../utils/logger"); + +/** + * Health check endpoint for load balancers and monitoring + * GET /health + * + * Returns: + * - 200: All services healthy + * - 503: One or more services unhealthy + */ +router.get("/", async (req, res) => { + const startTime = Date.now(); + const checks = { + database: { status: "unknown", latency: null }, + s3: { status: "unknown", latency: null }, + }; + + let allHealthy = true; + + // Database health check + try { + const dbStart = Date.now(); + await sequelize.authenticate(); + checks.database = { + status: "healthy", + latency: Date.now() - dbStart, + }; + } catch (error) { + allHealthy = false; + checks.database = { + status: "unhealthy", + error: error.message, + latency: Date.now() - startTime, + }; + logger.error("Health check: Database connection failed", { + error: error.message, + }); + } + + // S3 health check (if enabled) + if (s3Service.isEnabled()) { + try { + const s3Start = Date.now(); + // S3 is considered healthy if it's properly initialized + // A more thorough check could list bucket contents, but that adds latency + checks.s3 = { + status: "healthy", + latency: Date.now() - s3Start, + bucket: process.env.S3_BUCKET, + }; + } catch (error) { + allHealthy = false; + checks.s3 = { + status: "unhealthy", + error: error.message, + latency: Date.now() - startTime, + }; + logger.error("Health check: S3 check failed", { + error: error.message, + }); + } + } else { + checks.s3 = { + status: "disabled", + latency: 0, + }; + } + + // Log unhealthy states + if (!allHealthy) { + logger.warn("Health check failed", { checks }); + } + + res.status(allHealthy ? 200 : 503).json({ + status: allHealthy ? "healthy" : "unhealthy", + }); +}); + +/** + * Liveness probe - simple check that the process is running + * GET /health/live + * + * Used by Kubernetes/ECS for liveness probes + * Returns 200 if the process is alive + */ +router.get("/live", (req, res) => { + res.status(200).json({ + status: "alive", + timestamp: new Date().toISOString(), + }); +}); + +/** + * Readiness probe - check if the service is ready to accept traffic + * GET /health/ready + * + * Used by load balancers to determine if instance should receive traffic + * Checks critical dependencies (database) + */ +router.get("/ready", async (req, res) => { + try { + await sequelize.authenticate(); + res.status(200).json({ + status: "ready", + timestamp: new Date().toISOString(), + }); + } catch (error) { + logger.error("Readiness check failed", { error: error.message }); + res.status(503).json({ + status: "not_ready", + timestamp: new Date().toISOString(), + error: "Database connection failed", + }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index ed7afee..c0bcba9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -29,6 +29,7 @@ const mapsRoutes = require("./routes/maps"); const conditionCheckRoutes = require("./routes/conditionChecks"); const feedbackRoutes = require("./routes/feedback"); const uploadRoutes = require("./routes/upload"); +const healthRoutes = require("./routes/health"); const PayoutProcessor = require("./jobs/payoutProcessor"); const RentalStatusJob = require("./jobs/rentalStatusJob"); @@ -142,15 +143,18 @@ app.use( express.static(path.join(__dirname, "uploads")) ); +// Health check endpoints (no auth, no rate limiting) +app.use("/health", healthRoutes); + +// Root endpoint +app.get("/", (req, res) => { + res.json({ message: "Village Share API is running!" }); +}); + // Public routes (no alpha access required) app.use("/api/alpha", alphaRoutes); app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration -// Health check endpoint -app.get("/", (req, res) => { - res.json({ message: "CommunityRentals.App API is running!" }); -}); - // Protected routes (require alpha access) app.use("/api/users", requireAlphaAccess, userRoutes); app.use("/api/items", requireAlphaAccess, itemRoutes); diff --git a/backend/tests/unit/routes/health.test.js b/backend/tests/unit/routes/health.test.js new file mode 100644 index 0000000..fb7a8ea --- /dev/null +++ b/backend/tests/unit/routes/health.test.js @@ -0,0 +1,107 @@ +const request = require('supertest'); +const express = require('express'); + +// Mock dependencies +jest.mock('../../../models', () => ({ + sequelize: { + authenticate: jest.fn(), + }, +})); + +jest.mock('../../../services/s3Service', () => ({ + isEnabled: jest.fn(), +})); + +jest.mock('../../../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +})); + +const { sequelize } = require('../../../models'); +const s3Service = require('../../../services/s3Service'); +const healthRoutes = require('../../../routes/health'); + +describe('Health Routes', () => { + let app; + + beforeEach(() => { + app = express(); + app.use('/health', healthRoutes); + jest.clearAllMocks(); + }); + + describe('GET /health', () => { + it('should return 200 when all services are healthy', async () => { + sequelize.authenticate.mockResolvedValue(); + s3Service.isEnabled.mockReturnValue(true); + + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'healthy' }); + }); + + it('should return 503 when database is unhealthy', async () => { + sequelize.authenticate.mockRejectedValue(new Error('Connection refused')); + s3Service.isEnabled.mockReturnValue(true); + + const response = await request(app).get('/health'); + + expect(response.status).toBe(503); + expect(response.body).toEqual({ status: 'unhealthy' }); + }); + + it('should return healthy when S3 is disabled but database is up', async () => { + sequelize.authenticate.mockResolvedValue(); + s3Service.isEnabled.mockReturnValue(false); + + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'healthy' }); + }); + }); + + describe('GET /health/live', () => { + it('should return 200 alive status', async () => { + const response = await request(app).get('/health/live'); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('alive'); + expect(response.body).toHaveProperty('timestamp'); + }); + + it('should always return 200 regardless of service state', async () => { + // Liveness probe should always pass if the process is running + sequelize.authenticate.mockRejectedValue(new Error('DB down')); + + const response = await request(app).get('/health/live'); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('alive'); + }); + }); + + describe('GET /health/ready', () => { + it('should return 200 when database is ready', async () => { + sequelize.authenticate.mockResolvedValue(); + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('ready'); + expect(response.body).toHaveProperty('timestamp'); + }); + + it('should return 503 when database is not ready', async () => { + sequelize.authenticate.mockRejectedValue(new Error('Connection timeout')); + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(503); + expect(response.body.status).toBe('not_ready'); + expect(response.body.error).toBe('Database connection failed'); + }); + }); +});