From 662fcfef0c44935377cb96530eee499dcaf738d0 Mon Sep 17 00:00:00 2001 From: Gibah Joseph Date: Tue, 14 Jul 2026 11:45:57 +0100 Subject: [PATCH] feat: Add health check for Datasette service --- src/routes/health.js | 38 ++++++-- test/integration/health.test.js | 158 +++++++++++++++++++++++++------- test/unit/health.test.js | 58 +++++++++++- 3 files changed, 214 insertions(+), 40 deletions(-) diff --git a/src/routes/health.js b/src/routes/health.js index 6ac8c642e..50e2491f0 100644 --- a/src/routes/health.js +++ b/src/routes/health.js @@ -3,6 +3,7 @@ import config from '../../config/index.js' import AWS from 'aws-sdk' import { createClient } from 'redis' import logger from '../utils/logger.js' +import datasette from '../services/datasette.js' const router = express.Router() @@ -16,21 +17,28 @@ router.get('/', async (req, res) => { const dependencies = [ { name: 's3-bucket', - status: await checkS3Bucket() ? 'ok' : 'unreachable' + status: await checkS3Bucket() ? 'ok' : 'down' }, { name: 'request-api', - status: await checkRequestApi() ? 'ok' : 'unreachable' + status: await checkRequestApi() ? 'ok' : 'down' + }, + { + name: 'datasette', + status: await checkDatasette() ? 'ok' : 'down' } ] if (config.redis) { dependencies.push({ name: 'redis', - status: await checkRedis() ? 'ok' : 'unreachable' + status: await checkRedis() ? 'ok' : 'down', + required: false }) } + const status = getStatus(dependencies) const toReturn = { + status, name: config.serviceNames.submit, environment: config.environment, version: process.env.GIT_COMMIT || 'unknown', @@ -38,14 +46,23 @@ router.get('/', async (req, res) => { dependencies } - const isAnyServiceUnreachable = toReturn.dependencies.some(service => service.status === 'unreachable') - if (isAnyServiceUnreachable) { + if (status === 'down') { res.status(500).json(toReturn) } else { res.json(toReturn) } }) +const getStatus = (dependencies) => { + if (dependencies.some(service => service.status === 'down' && service.required !== false)) { + return 'down' + } + if (dependencies.some(service => service.status === 'down')) { + return 'degraded' + } + return 'ok' +} + const checkS3Bucket = async () => { const s3 = new AWS.S3() return await s3.headBucket({ Bucket: config.aws.bucket }).promise() @@ -62,6 +79,15 @@ const checkRequestApi = async () => { } } +const checkDatasette = async () => { + try { + await datasette.runQuery('SELECT 1') + return true + } catch (error) { + return false + } +} + const checkRedis = async () => { const urlPrefix = `redis${config.redis.secure ? 's' : ''}` const client = createClient({ @@ -85,4 +111,4 @@ const checkRedis = async () => { } export default router -export { checkS3Bucket, checkRequestApi, checkRedis } +export { checkS3Bucket, checkRequestApi, checkDatasette, checkRedis, getStatus } diff --git a/test/integration/health.test.js b/test/integration/health.test.js index e562071a1..4bc59f9f6 100644 --- a/test/integration/health.test.js +++ b/test/integration/health.test.js @@ -1,26 +1,47 @@ import request from 'supertest' import express from 'express' import router from '../../src/routes/health.js' -import { describe, it, expect, vi } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' import AWS from 'aws-sdk' import { createClient } from 'redis' import config from '../../config/index.js' +import datasette from '../../src/services/datasette.js' const app = express() app.use('/', router) vi.mock('aws-sdk') vi.mock('redis') +vi.mock('../../src/services/datasette.js', () => ({ + default: { + runQuery: vi.fn() + } +})) process.env.GIT_COMMIT = 'test_commit_short' -describe('GET health', () => { - it('when all services are healthy', async () => { - AWS.S3.mockReturnValue({ +const mockS3Bucket = (promiseFactory) => { + AWS.S3.mockImplementation(function () { + return { headBucket: vi.fn().mockReturnValue({ - promise: vi.fn().mockResolvedValue({}) + promise: vi.fn().mockImplementation(promiseFactory) }) - }) + } + }) +} + +describe('GET health', () => { + beforeEach(() => { + vi.clearAllMocks() + config.redis = { + secure: false, + host: 'localhost', + port: 6379 + } + }) + + it('when all services are healthy', async () => { + mockS3Bucket(() => Promise.resolve({})) const mockClient = { connect: vi.fn().mockResolvedValue({}), @@ -30,6 +51,7 @@ describe('GET health', () => { createClient.mockReturnValue(mockClient) vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + datasette.runQuery.mockResolvedValue({ formattedData: [{ 1: 1 }] }) const res = await request(app) .get('/') @@ -41,13 +63,16 @@ describe('GET health', () => { expect(res.body).toHaveProperty('version') expect(res.body).toHaveProperty('maintenance') expect(res.body).toHaveProperty('dependencies') - expect(res.body.dependencies).toHaveLength(3) + expect(res.body).toHaveProperty('status', 'ok') + expect(res.body.dependencies).toHaveLength(4) expect(res.body.dependencies[0]).toHaveProperty('name', 's3-bucket') expect(res.body.dependencies[0]).toHaveProperty('status') expect(res.body.dependencies[1]).toHaveProperty('name', 'request-api') expect(res.body.dependencies[1]).toHaveProperty('status') - expect(res.body.dependencies[2]).toHaveProperty('name', 'redis') + expect(res.body.dependencies[2]).toHaveProperty('name', 'datasette') expect(res.body.dependencies[2]).toHaveProperty('status') + expect(res.body.dependencies[3]).toHaveProperty('name', 'redis') + expect(res.body.dependencies[3]).toHaveProperty('status') expect(res.body.name).toEqual(config.serviceNames.submit) expect(res.body.environment).toEqual(config.environment) @@ -63,16 +88,19 @@ describe('GET health', () => { status: 'ok' }, { - name: 'redis', + name: 'datasette', status: 'ok' + }, + { + name: 'redis', + status: 'ok', + required: false } ]) }) it('when s3 bucket is unhealthy', async () => { - AWS.S3.mockReturnValue({ - headBucket: vi.fn().mockRejectedValue(new Error('Bucket does not exist')) - }) + mockS3Bucket(() => Promise.reject(new Error('Bucket does not exist'))) const mockClient = { connect: vi.fn().mockResolvedValue({}), @@ -82,6 +110,7 @@ describe('GET health', () => { createClient.mockReturnValue(mockClient) vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + datasette.runQuery.mockResolvedValue({ formattedData: [{ 1: 1 }] }) const res = await request(app) .get('/') .expect('Content-Type', /json/) @@ -92,13 +121,16 @@ describe('GET health', () => { expect(res.body).toHaveProperty('version') expect(res.body).toHaveProperty('maintenance') expect(res.body).toHaveProperty('dependencies') - expect(res.body.dependencies).toHaveLength(3) + expect(res.body).toHaveProperty('status', 'down') + expect(res.body.dependencies).toHaveLength(4) expect(res.body.dependencies[0]).toHaveProperty('name', 's3-bucket') - expect(res.body.dependencies[0]).toHaveProperty('status', 'unhealthy') + expect(res.body.dependencies[0]).toHaveProperty('status', 'down') expect(res.body.dependencies[1]).toHaveProperty('name', 'request-api') expect(res.body.dependencies[1]).toHaveProperty('status') - expect(res.body.dependencies[2]).toHaveProperty('name', 'redis') + expect(res.body.dependencies[2]).toHaveProperty('name', 'datasette') expect(res.body.dependencies[2]).toHaveProperty('status') + expect(res.body.dependencies[3]).toHaveProperty('name', 'redis') + expect(res.body.dependencies[3]).toHaveProperty('status') expect(res.body.name).toEqual(config.serviceNames.submit) expect(res.body.environment).toEqual(config.environment) expect(res.body.version).toEqual('test_commit_short') @@ -106,23 +138,26 @@ describe('GET health', () => { expect(res.body.dependencies).toStrictEqual([ { name: 's3-bucket', - status: 'unhealthy' + status: 'down' }, { name: 'request-api', status: 'ok' }, { - name: 'redis', + name: 'datasette', status: 'ok' + }, + { + name: 'redis', + status: 'ok', + required: false } ]) }) it('when request api is unhealthy', async () => { - AWS.S3.mockReturnValue({ - headBucket: vi.fn().mockResolvedValue({}) - }) + mockS3Bucket(() => Promise.resolve({})) const mockClient = { connect: vi.fn().mockResolvedValue({}), @@ -132,6 +167,7 @@ describe('GET health', () => { createClient.mockReturnValue(mockClient) vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Request API is down'))) + datasette.runQuery.mockResolvedValue({ formattedData: [{ 1: 1 }] }) const res = await request(app) .get('/') .expect('Content-Type', /json/) @@ -142,13 +178,16 @@ describe('GET health', () => { expect(res.body).toHaveProperty('version') expect(res.body).toHaveProperty('maintenance') expect(res.body).toHaveProperty('dependencies') - expect(res.body.dependencies).toHaveLength(3) + expect(res.body).toHaveProperty('status', 'down') + expect(res.body.dependencies).toHaveLength(4) expect(res.body.dependencies[0]).toHaveProperty('name', 's3-bucket') expect(res.body.dependencies[0]).toHaveProperty('status', 'ok') expect(res.body.dependencies[1]).toHaveProperty('name', 'request-api') - expect(res.body.dependencies[1]).toHaveProperty('status', 'unhealthy') - expect(res.body.dependencies[2]).toHaveProperty('name', 'redis') + expect(res.body.dependencies[1]).toHaveProperty('status', 'down') + expect(res.body.dependencies[2]).toHaveProperty('name', 'datasette') expect(res.body.dependencies[2]).toHaveProperty('status', 'ok') + expect(res.body.dependencies[3]).toHaveProperty('name', 'redis') + expect(res.body.dependencies[3]).toHaveProperty('status', 'ok') expect(res.body.name).toEqual(config.serviceNames.submit) expect(res.body.environment).toEqual(config.environment) expect(res.body.version).toEqual('test_commit_short') @@ -160,19 +199,22 @@ describe('GET health', () => { }, { name: 'request-api', - status: 'unhealthy' + status: 'down' }, { - name: 'redis', + name: 'datasette', status: 'ok' + }, + { + name: 'redis', + status: 'ok', + required: false } ]) }) it('when redis is unhealthy', async () => { - AWS.S3.mockReturnValue({ - headBucket: vi.fn().mockResolvedValue({}) - }) + mockS3Bucket(() => Promise.resolve({})) const mockClient = { connect: vi.fn().mockRejectedValue(new Error('Redis connection failed')), @@ -182,23 +224,28 @@ describe('GET health', () => { createClient.mockReturnValue(mockClient) vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + datasette.runQuery.mockResolvedValue({ formattedData: [{ 1: 1 }] }) const res = await request(app) .get('/') .expect('Content-Type', /json/) - .expect(500) + .expect(200) expect(res.body).toHaveProperty('name') expect(res.body).toHaveProperty('environment') expect(res.body).toHaveProperty('version') expect(res.body).toHaveProperty('maintenance') expect(res.body).toHaveProperty('dependencies') - expect(res.body.dependencies).toHaveLength(3) + expect(res.body).toHaveProperty('status', 'degraded') + expect(res.body.dependencies).toHaveLength(4) expect(res.body.dependencies[0]).toHaveProperty('name', 's3-bucket') expect(res.body.dependencies[0]).toHaveProperty('status', 'ok') expect(res.body.dependencies[1]).toHaveProperty('name', 'request-api') expect(res.body.dependencies[1]).toHaveProperty('status', 'ok') - expect(res.body.dependencies[2]).toHaveProperty('name', 'redis') - expect(res.body.dependencies[2]).toHaveProperty('status', 'unhealthy') + expect(res.body.dependencies[2]).toHaveProperty('name', 'datasette') + expect(res.body.dependencies[2]).toHaveProperty('status', 'ok') + expect(res.body.dependencies[3]).toHaveProperty('name', 'redis') + expect(res.body.dependencies[3]).toHaveProperty('status', 'down') + expect(res.body.dependencies[3]).toHaveProperty('required', false) expect(res.body.name).toEqual(config.serviceNames.submit) expect(res.body.environment).toEqual(config.environment) expect(res.body.version).toEqual('test_commit_short') @@ -212,9 +259,54 @@ describe('GET health', () => { name: 'request-api', status: 'ok' }, + { + name: 'datasette', + status: 'ok' + }, + { + name: 'redis', + status: 'down', + required: false + } + ]) + }) + + it('when datasette is unhealthy', async () => { + mockS3Bucket(() => Promise.resolve({})) + + const mockClient = { + connect: vi.fn().mockResolvedValue({}), + isOpen: true, + quit: vi.fn() + } + createClient.mockReturnValue(mockClient) + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + datasette.runQuery.mockRejectedValue(new Error('Datasette is down')) + + const res = await request(app) + .get('/') + .expect('Content-Type', /json/) + .expect(500) + + expect(res.body).toHaveProperty('status', 'down') + expect(res.body.dependencies).toStrictEqual([ + { + name: 's3-bucket', + status: 'ok' + }, + { + name: 'request-api', + status: 'ok' + }, + { + name: 'datasette', + status: 'down' + }, { name: 'redis', - status: 'unhealthy' + status: 'ok', + required: false } ]) }) diff --git a/test/unit/health.test.js b/test/unit/health.test.js index 394ef5915..55e77ff3a 100644 --- a/test/unit/health.test.js +++ b/test/unit/health.test.js @@ -1,9 +1,15 @@ -import { checkS3Bucket, checkRequestApi } from '../../src/routes/health.js' +import { checkS3Bucket, checkRequestApi, checkDatasette, getStatus } from '../../src/routes/health.js' import AWS from 'aws-sdk' import { describe, test, expect, vi } from 'vitest' +import datasette from '../../src/services/datasette.js' vi.mock('aws-sdk') vi.mock('redis') +vi.mock('../../src/services/datasette.js', () => ({ + default: { + runQuery: vi.fn() + } +})) describe('Health checks', () => { test('checkS3Bucket returns true when bucket is reachable', async () => { @@ -41,4 +47,54 @@ describe('Health checks', () => { const result = await checkRequestApi() expect(result).toBe(false) }) + + test('checkDatasette returns true when Datasette is reachable', async () => { + datasette.runQuery.mockResolvedValue({ formattedData: [{ 1: 1 }] }) + + const result = await checkDatasette() + + expect(datasette.runQuery).toHaveBeenCalledWith('SELECT 1') + expect(result).toBe(true) + }) + + test('checkDatasette returns false when Datasette is not reachable', async () => { + datasette.runQuery.mockRejectedValue(new Error()) + + const result = await checkDatasette() + + expect(result).toBe(false) + }) + + test('getStatus returns ok when all dependencies are ok', () => { + const result = getStatus([ + { name: 's3-bucket', status: 'ok' }, + { name: 'request-api', status: 'ok' }, + { name: 'datasette', status: 'ok' }, + { name: 'redis', status: 'ok' } + ]) + + expect(result).toBe('ok') + }) + + test('getStatus returns degraded when only optional Redis is down', () => { + const result = getStatus([ + { name: 's3-bucket', status: 'ok' }, + { name: 'request-api', status: 'ok' }, + { name: 'datasette', status: 'ok' }, + { name: 'redis', status: 'down', required: false } + ]) + + expect(result).toBe('degraded') + }) + + test('getStatus returns down when any required dependency is down', () => { + const result = getStatus([ + { name: 's3-bucket', status: 'ok' }, + { name: 'request-api', status: 'ok' }, + { name: 'datasette', status: 'down' }, + { name: 'redis', status: 'down', required: false } + ]) + + expect(result).toBe('down') + }) })