Skip to content

Commit 5a69054

Browse files
Merge pull request #59 from justinhartman/feat/create-health-check-api-for-domains
Add health check endpoints
2 parents 2f61416 + c7975e9 commit 5a69054

File tree

8 files changed

+415
-10
lines changed

8 files changed

+415
-10
lines changed

api/index.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const mockApp = () => ({
99

1010
const expressMock: any = jest.fn(() => mockApp());
1111
expressMock.static = jest.fn(() => 'static');
12+
expressMock.Router = jest.fn(() => ({ get: jest.fn() }));
1213

1314
jest.mock('express', () => expressMock);
1415

@@ -18,10 +19,12 @@ jest.mock('../config/db', () => connectMock);
1819
const appRouter = 'appRouter';
1920
const authRouter = 'authRouter';
2021
const watchlistRouter = 'watchlistRouter';
22+
const healthRouter = 'healthRouter';
2123

2224
jest.mock('../routes/app', () => appRouter);
2325
jest.mock('../routes/auth', () => authRouter);
2426
jest.mock('../routes/watchlist', () => watchlistRouter);
27+
jest.mock('../routes/health', () => healthRouter);
2528

2629
const baseConfig = {
2730
APP_NAME: 'name',
@@ -49,6 +52,7 @@ describe('api/index', () => {
4952
expect(res.locals.APP_NAME).toBe('name');
5053
expect(res.locals.CARD_TYPE).toBe('card-add');
5154
expect(connectMock).toHaveBeenCalled();
55+
expect(app.use).toHaveBeenCalledWith('/health', healthRouter);
5256
expect(app.use).toHaveBeenCalledWith('/', appRouter);
5357
expect(app.use).toHaveBeenCalledWith('/user', authRouter);
5458
expect(app.use).toHaveBeenCalledWith('/watchlist', watchlistRouter);
@@ -64,6 +68,7 @@ describe('api/index', () => {
6468
localsMiddleware({}, res, () => {});
6569
expect(res.locals.CARD_TYPE).toBe('card');
6670
expect(connectMock).not.toHaveBeenCalled();
71+
expect(app.use).toHaveBeenCalledWith('/health', healthRouter);
6772
expect(app.use).toHaveBeenCalledWith('/', appRouter);
6873
expect(app.use).not.toHaveBeenCalledWith('/user', authRouter);
6974
});

api/index.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @license MIT
88
*/
99

10-
import express, { Request, Response, NextFunction } from 'express';
10+
import express from 'express';
1111
import path from 'path';
1212
import { inject } from '@vercel/analytics';
1313

@@ -16,6 +16,8 @@ import connectDB from '../config/db';
1616
import { useAuth } from '../helpers/appHelper';
1717
import appRouter from '../routes/app';
1818
import authRouter from '../routes/auth';
19+
import healthRouter from '../routes/health';
20+
import appLocals from '../middleware/appLocals';
1921
import watchlistRouter from '../routes/watchlist';
2022

2123
/**
@@ -35,15 +37,7 @@ inject({
3537
* @param {Response} res - The response object containing the HTTP response details.
3638
* @param {NextFunction} next - The next middleware function in the chain.
3739
*/
38-
app.use((req: Request, res: Response, next: NextFunction) => {
39-
res.locals.APP_NAME = appConfig.APP_NAME;
40-
res.locals.APP_SUBTITLE = appConfig.APP_SUBTITLE;
41-
res.locals.APP_DESCRIPTION = appConfig.APP_DESCRIPTION;
42-
res.locals.APP_URL = appConfig.APP_URL;
43-
// We have different card types based on whether the app uses MongoDB or not.
44-
res.locals.CARD_TYPE = useAuth ? 'card-add' : 'card';
45-
next();
46-
});
40+
app.use(appLocals);
4741

4842
app.set('view engine', 'ejs');
4943
app.set('views', path.join(__dirname, '../views'));
@@ -58,6 +52,7 @@ app.use(express.static('public'));
5852
* Load standard routes and conditionally use additional routes based on the value of useAuth boolean.
5953
* The method checks if MONGO_DB_URI is true then connects to MongoDB and uses additional middleware.
6054
*/
55+
app.use('/health', healthRouter);
6156
app.use('/', appRouter);
6257
// Test if MONGO_DB_URI is set.
6358
if (useAuth) {
@@ -75,6 +70,7 @@ if (useAuth) {
7570
* @returns {void} - No return value.
7671
*/
7772
app.listen(appConfig.API_PORT, appConfig.API_HOST, () => {
73+
/* c8 ignore next */
7874
console.log(`Server is running on http://${appConfig.API_HOST}:${appConfig.API_PORT}`);
7975
});
8076

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { Request, Response } from 'express';
2+
3+
import appConfig from '../config/app';
4+
import httpClient from '../helpers/httpClient';
5+
import healthController, { checkDomainHealth } from './healthController';
6+
7+
jest.mock('../helpers/httpClient', () => ({
8+
__esModule: true,
9+
default: {
10+
get: jest.fn(),
11+
},
12+
}));
13+
14+
type MockedHttpClient = jest.Mocked<typeof httpClient>;
15+
const mockedHttpClient = httpClient as MockedHttpClient;
16+
17+
describe('checkDomainHealth', () => {
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
});
21+
22+
test('returns error when domain is not configured', async () => {
23+
const result = await checkDomainHealth('VIDSRC_DOMAIN');
24+
expect(result).toEqual({
25+
name: 'VIDSRC_DOMAIN',
26+
status: 'error',
27+
message: 'Domain not configured',
28+
});
29+
});
30+
31+
test('returns success when endpoint responds with < 400 status', async () => {
32+
mockedHttpClient.get.mockResolvedValue({ status: 200 } as any);
33+
const result = await checkDomainHealth('VIDSRC_DOMAIN', 'vidsrc.example');
34+
expect(result).toEqual({
35+
name: 'VIDSRC_DOMAIN',
36+
domain: 'vidsrc.example',
37+
status: 'success',
38+
httpStatus: 200,
39+
});
40+
expect(mockedHttpClient.get).toHaveBeenCalledWith('https://vidsrc.example', expect.any(Object));
41+
});
42+
43+
test('returns error when endpoint responds with >= 400 status', async () => {
44+
mockedHttpClient.get.mockResolvedValue({ status: 404 } as any);
45+
const result = await checkDomainHealth('VIDSRC_DOMAIN', 'vidsrc.example');
46+
expect(result).toEqual({
47+
name: 'VIDSRC_DOMAIN',
48+
domain: 'vidsrc.example',
49+
status: 'error',
50+
httpStatus: 404,
51+
message: 'Received status code 404',
52+
});
53+
});
54+
55+
test('returns error when request throws', async () => {
56+
mockedHttpClient.get.mockRejectedValue(new Error('boom'));
57+
const result = await checkDomainHealth('VIDSRC_DOMAIN', 'vidsrc.example');
58+
expect(result).toEqual({
59+
name: 'VIDSRC_DOMAIN',
60+
domain: 'vidsrc.example',
61+
status: 'error',
62+
message: 'boom',
63+
});
64+
});
65+
});
66+
67+
describe('healthController', () => {
68+
const createRes = () => {
69+
const res: Partial<Response> = {};
70+
res.json = jest.fn().mockReturnValue(res);
71+
return res as Response;
72+
};
73+
74+
beforeEach(() => {
75+
jest.clearAllMocks();
76+
(appConfig as any).VIDSRC_DOMAIN = 'vidsrc.example';
77+
(appConfig as any).MULTI_DOMAIN = 'multi.example';
78+
(appConfig as any).APP_URL = 'https://app.example';
79+
});
80+
81+
test('getEmbedDomains returns results for both domains', async () => {
82+
mockedHttpClient.get
83+
.mockResolvedValueOnce({ status: 200 } as any)
84+
.mockResolvedValueOnce({ status: 503 } as any);
85+
86+
const res = createRes();
87+
await healthController.getEmbedDomains({} as Request, res);
88+
89+
expect(res.json).toHaveBeenCalledWith({
90+
domains: [
91+
{
92+
name: 'VIDSRC_DOMAIN',
93+
domain: 'vidsrc.example',
94+
status: 'success',
95+
httpStatus: 200,
96+
},
97+
{
98+
name: 'MULTI_DOMAIN',
99+
domain: 'multi.example',
100+
status: 'error',
101+
httpStatus: 503,
102+
message: 'Received status code 503',
103+
},
104+
],
105+
});
106+
});
107+
108+
test('getAppUrl returns single domain result', async () => {
109+
mockedHttpClient.get.mockResolvedValue({ status: 301 } as any);
110+
const res = createRes();
111+
await healthController.getAppUrl({} as Request, res);
112+
expect(res.json).toHaveBeenCalledWith({
113+
name: 'APP_URL',
114+
domain: 'https://app.example',
115+
status: 'success',
116+
httpStatus: 301,
117+
});
118+
});
119+
120+
test('getEmbedDomains handles missing MULTI_DOMAIN', async () => {
121+
(appConfig as any).MULTI_DOMAIN = undefined;
122+
mockedHttpClient.get.mockResolvedValue({ status: 200 } as any);
123+
124+
const res = createRes();
125+
await healthController.getEmbedDomains({} as Request, res);
126+
127+
expect(res.json).toHaveBeenCalledWith({
128+
domains: [
129+
{
130+
name: 'VIDSRC_DOMAIN',
131+
domain: 'vidsrc.example',
132+
status: 'success',
133+
httpStatus: 200,
134+
},
135+
{
136+
name: 'MULTI_DOMAIN',
137+
status: 'error',
138+
message: 'Domain not configured',
139+
},
140+
],
141+
});
142+
});
143+
});

controllers/healthController.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* @module controllers/healthController
3+
* @description Controller providing health check endpoints for configured domains.
4+
*/
5+
6+
import { Request, Response } from 'express';
7+
8+
import appConfig from '../config/app';
9+
import httpClient from '../helpers/httpClient';
10+
11+
export type DomainHealthStatus = 'success' | 'error';
12+
13+
export interface DomainHealthResult {
14+
name: string;
15+
domain?: string;
16+
status: DomainHealthStatus;
17+
httpStatus?: number;
18+
message?: string;
19+
}
20+
21+
const normalizeUrl = (domain: string): string => {
22+
return /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
23+
};
24+
25+
export const checkDomainHealth = async (
26+
name: string,
27+
domain?: string
28+
): Promise<DomainHealthResult> => {
29+
if (!domain) {
30+
return {
31+
name,
32+
status: 'error',
33+
message: 'Domain not configured',
34+
};
35+
}
36+
37+
try {
38+
const response = await httpClient.get(normalizeUrl(domain), {
39+
maxRedirects: 0,
40+
/* c8 ignore next */
41+
validateStatus: () => true,
42+
});
43+
44+
if (response.status >= 200 && response.status < 400) {
45+
return {
46+
name,
47+
domain,
48+
status: 'success',
49+
httpStatus: response.status,
50+
};
51+
}
52+
53+
return {
54+
name,
55+
domain,
56+
status: 'error',
57+
httpStatus: response.status,
58+
message: `Received status code ${response.status}`,
59+
};
60+
} catch (error: unknown) {
61+
let message = 'Unknown error';
62+
if (error instanceof Error && error.message) {
63+
message = error.message;
64+
}
65+
66+
return {
67+
name,
68+
domain,
69+
status: 'error',
70+
message,
71+
};
72+
}
73+
};
74+
75+
const healthController = {
76+
/**
77+
* Checks the configured embed domains and returns their reachability status.
78+
* @param {Request} _req - Express request object.
79+
* @param {Response} res - Express response object.
80+
* @returns {Promise<Response>} JSON response containing domain health information.
81+
*/
82+
async getEmbedDomains(_req: Request, res: Response): Promise<Response> {
83+
const domains = await Promise.all([
84+
checkDomainHealth('VIDSRC_DOMAIN', appConfig.VIDSRC_DOMAIN),
85+
checkDomainHealth('MULTI_DOMAIN', appConfig.MULTI_DOMAIN),
86+
]);
87+
88+
return res.json({ domains });
89+
},
90+
91+
/**
92+
* Checks the configured application URL and returns its reachability status.
93+
* @param {Request} _req - Express request object.
94+
* @param {Response} res - Express response object.
95+
* @returns {Promise<Response>} JSON response containing APP_URL health information.
96+
*/
97+
async getAppUrl(_req: Request, res: Response): Promise<Response> {
98+
const domain = await checkDomainHealth('APP_URL', appConfig.APP_URL);
99+
return res.json(domain);
100+
},
101+
};
102+
103+
export default healthController;

middleware/appLocals.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
describe('middleware/appLocals', () => {
2+
const configMock = {
3+
APP_NAME: 'Test App',
4+
APP_SUBTITLE: 'Subtitle',
5+
APP_DESCRIPTION: 'Description',
6+
APP_URL: 'https://example.test',
7+
};
8+
9+
const loadMiddleware = (authEnabled: boolean) => {
10+
let middleware: any;
11+
jest.isolateModules(() => {
12+
jest.doMock('../config/app', () => ({
13+
__esModule: true,
14+
default: configMock,
15+
}));
16+
jest.doMock('../helpers/appHelper', () => ({
17+
useAuth: authEnabled,
18+
}));
19+
// eslint-disable-next-line @typescript-eslint/no-var-requires
20+
middleware = require('./appLocals').default;
21+
});
22+
return middleware as (req: any, res: any, next: () => void) => void;
23+
};
24+
25+
afterEach(() => {
26+
jest.resetModules();
27+
jest.clearAllMocks();
28+
});
29+
30+
test('sets locals when authentication is disabled', () => {
31+
const appLocals = loadMiddleware(false);
32+
const res: any = { locals: {} };
33+
const next = jest.fn();
34+
35+
appLocals({}, res, next);
36+
37+
expect(res.locals).toEqual({
38+
APP_NAME: configMock.APP_NAME,
39+
APP_SUBTITLE: configMock.APP_SUBTITLE,
40+
APP_DESCRIPTION: configMock.APP_DESCRIPTION,
41+
APP_URL: configMock.APP_URL,
42+
CARD_TYPE: 'card',
43+
});
44+
expect(next).toHaveBeenCalled();
45+
});
46+
47+
test('sets card type to card-add when authentication is enabled', () => {
48+
const appLocals = loadMiddleware(true);
49+
const res: any = { locals: {} };
50+
const next = jest.fn();
51+
52+
appLocals({}, res, next);
53+
54+
expect(res.locals.CARD_TYPE).toBe('card-add');
55+
expect(next).toHaveBeenCalled();
56+
});
57+
});

0 commit comments

Comments
 (0)