diff --git a/apps/public-api/src/__tests__/health.route.test.js b/apps/public-api/src/__tests__/health.route.test.js new file mode 100644 index 000000000..c846204dc --- /dev/null +++ b/apps/public-api/src/__tests__/health.route.test.js @@ -0,0 +1,74 @@ +'use strict'; + +const mockRedis = { + status: 'ready', + ping: jest.fn(), +}; + +jest.mock('mongoose', () => ({ + connection: { + readyState: 1, + }, +})); + +jest.mock('@urbackend/common', () => ({ + redis: mockRedis, +})); + +const mongoose = require('mongoose'); +const express = require('express'); +const request = require('supertest'); +const healthRoute = require('../routes/health'); + +describe('health route', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + mongoose.connection.readyState = 1; + mockRedis.status = 'ready'; + mockRedis.ping.mockResolvedValue('PONG'); + + app = express(); + app.use('/api/health', healthRoute); + }); + + test('returns ok when mongodb and redis are connected', async () => { + const res = await request(app).get('/api/health'); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body.dependencies).toEqual({ + mongodb: 'connected', + redis: 'connected', + }); + expect(typeof res.body.timestamp).toBe('string'); + expect(mockRedis.ping).toHaveBeenCalledTimes(1); + }); + + test('returns error when mongodb is disconnected', async () => { + mongoose.connection.readyState = 0; + + const res = await request(app).get('/api/health'); + + expect(res.status).toBe(503); + expect(res.body.status).toBe('error'); + expect(res.body.dependencies).toEqual({ + mongodb: 'disconnected', + redis: 'connected', + }); + }); + + test('returns error when redis is not responsive', async () => { + mockRedis.ping.mockRejectedValue(new Error('redis unavailable')); + + const res = await request(app).get('/api/health'); + + expect(res.status).toBe(503); + expect(res.body.status).toBe('error'); + expect(res.body.dependencies).toEqual({ + mongodb: 'connected', + redis: 'disconnected', + }); + }); +}); diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index b4c5108d9..db5cbc362 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -60,6 +60,7 @@ const userAuthRoute = require('./routes/userAuth'); const storageRoute = require('./routes/storage'); const schemaRoute = require('./routes/schemas'); const mailRoute = require('./routes/mail'); +const healthRoute = require('./routes/health'); // ROUTES SETUP app.use('/api/userAuth', limiter, logger, userAuthRoute); @@ -78,6 +79,7 @@ app.use('/api/data', projectCorsPreflight, limiter, logger, dataRoute); app.use('/api/schemas', projectCorsPreflight, limiter, logger, schemaRoute); app.use('/api/storage', projectCorsPreflight, limiter, logger, storageRoute); app.use('/api/mail', projectCorsPreflight, limiter, logger, mailRoute); +app.use('/api/health', limiter, logger, healthRoute); app.get('/api/server-ip', async (req, res) => { const ip = await getPublicIp(); diff --git a/apps/public-api/src/controllers/health.controller.js b/apps/public-api/src/controllers/health.controller.js new file mode 100644 index 000000000..750711f6c --- /dev/null +++ b/apps/public-api/src/controllers/health.controller.js @@ -0,0 +1,35 @@ +const mongoose = require('mongoose'); +const { redis } = require('@urbackend/common'); +const REDIS_PING_TIMEOUT_MS = 500; + +const getHealth = async (req, res) => { + const isMongoConnected = mongoose.connection.readyState === 1; + + let isRedisConnected = false; + if (redis?.status === 'ready' && typeof redis.ping === 'function') { + try { + const pingResponse = await Promise.race([ + redis.ping(), + new Promise((resolve) => setTimeout(() => resolve('TIMEOUT'), REDIS_PING_TIMEOUT_MS)), + ]); + isRedisConnected = pingResponse === 'PONG'; + } catch (_error) { + isRedisConnected = false; + } + } + + const status = isMongoConnected && isRedisConnected ? 'ok' : 'error'; + + return res.status(status === 'ok' ? 200 : 503).json({ + status, + timestamp: new Date().toISOString(), + dependencies: { + mongodb: isMongoConnected ? 'connected' : 'disconnected', + redis: isRedisConnected ? 'connected' : 'disconnected', + }, + }); +}; + +module.exports = { + getHealth, +}; diff --git a/apps/public-api/src/routes/health.js b/apps/public-api/src/routes/health.js new file mode 100644 index 000000000..154fca7f3 --- /dev/null +++ b/apps/public-api/src/routes/health.js @@ -0,0 +1,8 @@ +const express = require('express'); +const { getHealth } = require('../controllers/health.controller'); + +const router = express.Router(); + +router.get('/', getHealth); + +module.exports = router;