From ae118cef5a70ee917b76e04e27dcece5d2a30170 Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Sat, 27 Jun 2026 14:32:34 +0100 Subject: [PATCH 1/2] feat(backend): add health check endpoint to backend express server (closes #631) --- .../src/api/controllers/sep12.controller.ts | 3 - .../src/api/routes/queue-dashboard.route.ts | 3 +- backend/src/index.test.ts | 44 ++++++++++- backend/src/index.ts | 78 ++++++++++++++++++- backend/src/lib/redis.ts | 1 + 5 files changed, 117 insertions(+), 12 deletions(-) diff --git a/backend/src/api/controllers/sep12.controller.ts b/backend/src/api/controllers/sep12.controller.ts index 4e5d0005..d8497e14 100644 --- a/backend/src/api/controllers/sep12.controller.ts +++ b/backend/src/api/controllers/sep12.controller.ts @@ -15,9 +15,6 @@ type UploadedFiles = { [fieldname: string]: Array<{ path: string }> }; const ALLOWED_CONTENT_TYPES = (process.env.UPLOAD_ALLOWED_CONTENT_TYPES ?? 'image/jpeg,image/png,application/pdf').split(','); const UPLOAD_URL_EXPIRY_SECONDS = parseInt(process.env.UPLOAD_URL_EXPIRY_SECONDS ?? '900', 10); const KEY_PREFIX = process.env.STORAGE_KEY_PREFIX ?? 'kyc'; - -type UploadedFiles = { [fieldname: string]: Array<{ path: string }> }; - const pack = (enc?: { encryptedData: string; iv: string } | null) => enc ? `${enc.iv}|${enc.encryptedData}` : null; diff --git a/backend/src/api/routes/queue-dashboard.route.ts b/backend/src/api/routes/queue-dashboard.route.ts index 11ae9d11..9fdcb795 100644 --- a/backend/src/api/routes/queue-dashboard.route.ts +++ b/backend/src/api/routes/queue-dashboard.route.ts @@ -12,7 +12,8 @@ const queues = Object.values(QUEUE_NAMES).map( (name) => new BullMQAdapter(new Queue(name, { connection: queueConnection })) ); -createBullBoard({ queues, serverAdapter }); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +createBullBoard({ queues: queues as any, serverAdapter }); const router = Router(); router.use('/', serverAdapter.getRouter()); diff --git a/backend/src/index.test.ts b/backend/src/index.test.ts index d314df1e..8b870979 100644 --- a/backend/src/index.test.ts +++ b/backend/src/index.test.ts @@ -1,9 +1,15 @@ import request from 'supertest'; +import prisma from './lib/prisma'; +import { redis } from './lib/redis'; jest.mock('./lib/prisma', () => ({ - transaction: { - findMany: jest.fn(), - count: jest.fn() + __esModule: true, + default: { + transaction: { + findMany: jest.fn(), + count: jest.fn() + }, + $queryRaw: jest.fn() } })); @@ -20,10 +26,40 @@ const app = require('./index').default; describe('Backend API', () => { - it('should return UP on health check', async () => { + beforeEach(() => { + jest.clearAllMocks(); + (prisma.$queryRaw as jest.Mock).mockResolvedValue([{ '?column?': 1 }]); + if (typeof redis.ping === 'function') { + jest.spyOn(redis, 'ping').mockResolvedValue('PONG'); + } + }); + + it('should return UP on health check when all services are healthy', async () => { const res = await request(app).get('/health'); expect(res.statusCode).toEqual(200); expect(res.body.status).toEqual('UP'); + expect(res.body.services.database).toEqual('UP'); + expect(res.body.services.redis).toEqual('UP'); + }); + + it('should return DOWN on health check when database is down', async () => { + (prisma.$queryRaw as jest.Mock).mockRejectedValue(new Error('DB Connection Refused')); + + const res = await request(app).get('/health'); + expect(res.statusCode).toEqual(503); + expect(res.body.status).toEqual('DOWN'); + expect(res.body.services.database).toEqual('DOWN'); + expect(res.body.services.redis).toEqual('UP'); + }); + + it('should return DOWN on health check when Redis is down', async () => { + jest.spyOn(redis, 'ping').mockRejectedValue(new Error('Redis Timeout')); + + const res = await request(app).get('/health'); + expect(res.statusCode).toEqual(503); + expect(res.body.status).toEqual('DOWN'); + expect(res.body.services.database).toEqual('UP'); + expect(res.body.services.redis).toEqual('DOWN'); }); it('should return 200 on root access', async () => { diff --git a/backend/src/index.ts b/backend/src/index.ts index fd8781c4..9c0174fa 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,6 +30,8 @@ import { createEmailProvider, ConsoleSmsProvider, ConsolePushProvider } from './ import { NotificationType } from './services/notification.service'; import { validateKmsConfigOnStartup } from './lib/key-management.service'; import queueDashboardRouter from './api/routes/queue-dashboard.route'; +import prisma from './lib/prisma'; +import { redis } from './lib/redis'; // Initialize Notification Engine notificationService.registerProvider(NotificationType.EMAIL, createEmailProvider()); @@ -75,11 +77,11 @@ app.get('/', (req: Request, res: Response) => { * /health: * get: * summary: Health check - * description: Check if the API server is running + * description: Check if the API server and its backend dependencies (database, Redis) are running * tags: [Health] * responses: * 200: - * description: Server is healthy + * description: Server and all dependencies are healthy * content: * application/json: * schema: @@ -91,9 +93,77 @@ app.get('/', (req: Request, res: Response) => { * timestamp: * type: string * format: date-time + * services: + * type: object + * properties: + * database: + * type: string + * example: UP + * redis: + * type: string + * example: UP + * 503: + * description: One or more backend dependencies are down + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: DOWN + * timestamp: + * type: string + * format: date-time + * services: + * type: object + * properties: + * database: + * type: string + * example: DOWN + * redis: + * type: string + * example: UP */ -app.get('/health', (req: Request, res: Response) => { - res.json({ status: 'UP', timestamp: new Date().toISOString() }); +app.get('/health', async (req: Request, res: Response) => { + let dbStatus = 'UP'; + let redisStatus = 'UP'; + let isHealthy = true; + + try { + await prisma.$queryRaw`SELECT 1`; + } catch (err) { + dbStatus = 'DOWN'; + isHealthy = false; + logger.error('Health Check - Database connection failed:', err); + } + + try { + const pong = await redis.ping(); + if (pong !== 'PONG') { + redisStatus = 'DOWN'; + isHealthy = false; + } + } catch (err) { + redisStatus = 'DOWN'; + isHealthy = false; + logger.error('Health Check - Redis connection failed:', err); + } + + const responsePayload = { + status: isHealthy ? 'UP' : 'DOWN', + timestamp: new Date().toISOString(), + services: { + database: dbStatus, + redis: redisStatus, + }, + }; + + if (!isHealthy) { + return res.status(503).json(responsePayload); + } + + return res.status(200).json(responsePayload); }); // Swagger API Documentation diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts index 73f527e3..d9ee8f32 100644 --- a/backend/src/lib/redis.ts +++ b/backend/src/lib/redis.ts @@ -30,6 +30,7 @@ export const redis = isTest set: createNoop<(key: string, value: string) => Promise<'OK'>>(Promise.resolve('OK')), del: createNoop<(key: string) => Promise>(Promise.resolve(1)), publish: createNoop<(channel: string, message: string) => Promise>(Promise.resolve(1)), + ping: createNoop<() => Promise>(Promise.resolve('PONG')), } as any) From 1ccd51487c66fc9cfc69518f5dacac685b09d187 Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Sat, 27 Jun 2026 15:02:11 +0100 Subject: [PATCH 2/2] test(backend): add storage-provider and upload-store tests to ensure function coverage thresholds are met --- .../services/storage-provider.service.test.ts | 33 +++++++++++ .../src/services/upload-store.service.test.ts | 58 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 backend/src/services/storage-provider.service.test.ts create mode 100644 backend/src/services/upload-store.service.test.ts diff --git a/backend/src/services/storage-provider.service.test.ts b/backend/src/services/storage-provider.service.test.ts new file mode 100644 index 00000000..6eacc511 --- /dev/null +++ b/backend/src/services/storage-provider.service.test.ts @@ -0,0 +1,33 @@ +import { MockStorageProvider, storageProvider } from './storage-provider.service'; + +describe('storage-provider.service', () => { + describe('MockStorageProvider', () => { + let mockProvider: MockStorageProvider; + + beforeEach(() => { + mockProvider = new MockStorageProvider('test-mock-bucket'); + }); + + it('should generate mock presigned URL', async () => { + const url = await mockProvider.generatePresignedPutUrl('test-key', 'image/png', 3600); + expect(url).toBe('https://test-mock-bucket.mock.storage/test-key?X-Mock-Signed=1'); + }); + + it('should correctly check if object exists', async () => { + expect(await mockProvider.objectExists('test-key')).toBe(false); + mockProvider._markUploaded('test-key'); + expect(await mockProvider.objectExists('test-key')).toBe(true); + }); + + it('should use default mock bucket name if not specified', () => { + const defaultProvider = new MockStorageProvider(); + expect(defaultProvider).toBeDefined(); + }); + }); + + describe('default storageProvider instance', () => { + it('should be defined', () => { + expect(storageProvider).toBeDefined(); + }); + }); +}); diff --git a/backend/src/services/upload-store.service.test.ts b/backend/src/services/upload-store.service.test.ts new file mode 100644 index 00000000..1fc6fc4b --- /dev/null +++ b/backend/src/services/upload-store.service.test.ts @@ -0,0 +1,58 @@ +import { uploadStore } from './upload-store.service'; + +describe('uploadStore', () => { + beforeEach(() => { + // Clear in-memory maps or stale records if any + const recordsMap = (uploadStore as any).records; + if (recordsMap) { + recordsMap.clear(); + } + }); + + it('should create and retrieve an upload record', () => { + const expiresAt = new Date(Date.now() + 60000); + const record = uploadStore.create( + 'GD3...123', + 'id_front', + 'kyc/GD3...123/id_front/1', + 'image/png', + expiresAt + ); + + expect(record.uploadId).toBeDefined(); + expect(record.status).toBe('PENDING'); + expect(record.account).toBe('GD3...123'); + + const fetched = uploadStore.get(record.uploadId); + expect(fetched).toEqual(record); + }); + + it('should set the status of a record', () => { + const expiresAt = new Date(Date.now() + 60000); + const record = uploadStore.create( + 'GD3...123', + 'id_front', + 'kyc/GD3...123/id_front/1', + 'image/png', + expiresAt + ); + + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + expect(uploadStore.get(record.uploadId)?.status).toBe('COMPLETED'); + }); + + it('should expire stale records', () => { + const pastDate = new Date(Date.now() - 10000); + const record = uploadStore.create( + 'GD3...123', + 'id_front', + 'kyc/GD3...123/id_front/1', + 'image/png', + pastDate + ); + + const expiredCount = uploadStore.expireStale(); + expect(expiredCount).toBe(1); + expect(uploadStore.get(record.uploadId)?.status).toBe('EXPIRED'); + }); +});