Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mockApp = () => ({

const expressMock: any = jest.fn(() => mockApp());
expressMock.static = jest.fn(() => 'static');
expressMock.Router = jest.fn(() => ({ get: jest.fn() }));

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

Expand All @@ -18,10 +19,12 @@ jest.mock('../config/db', () => connectMock);
const appRouter = 'appRouter';
const authRouter = 'authRouter';
const watchlistRouter = 'watchlistRouter';
const healthRouter = 'healthRouter';

jest.mock('../routes/app', () => appRouter);
jest.mock('../routes/auth', () => authRouter);
jest.mock('../routes/watchlist', () => watchlistRouter);
jest.mock('../routes/health', () => healthRouter);

const baseConfig = {
APP_NAME: 'name',
Expand Down Expand Up @@ -49,6 +52,7 @@ describe('api/index', () => {
expect(res.locals.APP_NAME).toBe('name');
expect(res.locals.CARD_TYPE).toBe('card-add');
expect(connectMock).toHaveBeenCalled();
expect(app.use).toHaveBeenCalledWith('/health', healthRouter);
expect(app.use).toHaveBeenCalledWith('/', appRouter);
expect(app.use).toHaveBeenCalledWith('/user', authRouter);
expect(app.use).toHaveBeenCalledWith('/watchlist', watchlistRouter);
Expand All @@ -64,6 +68,7 @@ describe('api/index', () => {
localsMiddleware({}, res, () => {});
expect(res.locals.CARD_TYPE).toBe('card');
expect(connectMock).not.toHaveBeenCalled();
expect(app.use).toHaveBeenCalledWith('/health', healthRouter);
expect(app.use).toHaveBeenCalledWith('/', appRouter);
expect(app.use).not.toHaveBeenCalledWith('/user', authRouter);
});
Expand Down
16 changes: 6 additions & 10 deletions api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @license MIT
*/

import express, { Request, Response, NextFunction } from 'express';
import express from 'express';
import path from 'path';
import { inject } from '@vercel/analytics';

Expand All @@ -16,6 +16,8 @@ import connectDB from '../config/db';
import { useAuth } from '../helpers/appHelper';
import appRouter from '../routes/app';
import authRouter from '../routes/auth';
import healthRouter from '../routes/health';
import appLocals from '../middleware/appLocals';
import watchlistRouter from '../routes/watchlist';

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

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

Expand Down
143 changes: 143 additions & 0 deletions controllers/healthController.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Request, Response } from 'express';

import appConfig from '../config/app';
import httpClient from '../helpers/httpClient';
import healthController, { checkDomainHealth } from './healthController';

jest.mock('../helpers/httpClient', () => ({
__esModule: true,
default: {
get: jest.fn(),
},
}));

type MockedHttpClient = jest.Mocked<typeof httpClient>;
const mockedHttpClient = httpClient as MockedHttpClient;

describe('checkDomainHealth', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('returns error when domain is not configured', async () => {
const result = await checkDomainHealth('VIDSRC_DOMAIN');
expect(result).toEqual({
name: 'VIDSRC_DOMAIN',
status: 'error',
message: 'Domain not configured',
});
});

test('returns success when endpoint responds with < 400 status', async () => {
mockedHttpClient.get.mockResolvedValue({ status: 200 } as any);
const result = await checkDomainHealth('VIDSRC_DOMAIN', 'vidsrc.example');
expect(result).toEqual({
name: 'VIDSRC_DOMAIN',
domain: 'vidsrc.example',
status: 'success',
httpStatus: 200,
});
expect(mockedHttpClient.get).toHaveBeenCalledWith('https://vidsrc.example', expect.any(Object));
});

test('returns error when endpoint responds with >= 400 status', async () => {
mockedHttpClient.get.mockResolvedValue({ status: 404 } as any);
const result = await checkDomainHealth('VIDSRC_DOMAIN', 'vidsrc.example');
expect(result).toEqual({
name: 'VIDSRC_DOMAIN',
domain: 'vidsrc.example',
status: 'error',
httpStatus: 404,
message: 'Received status code 404',
});
});

test('returns error when request throws', async () => {
mockedHttpClient.get.mockRejectedValue(new Error('boom'));
const result = await checkDomainHealth('VIDSRC_DOMAIN', 'vidsrc.example');
expect(result).toEqual({
name: 'VIDSRC_DOMAIN',
domain: 'vidsrc.example',
status: 'error',
message: 'boom',
});
});
});

describe('healthController', () => {
const createRes = () => {
const res: Partial<Response> = {};
res.json = jest.fn().mockReturnValue(res);
return res as Response;
};

beforeEach(() => {
jest.clearAllMocks();
(appConfig as any).VIDSRC_DOMAIN = 'vidsrc.example';
(appConfig as any).MULTI_DOMAIN = 'multi.example';
(appConfig as any).APP_URL = 'https://app.example';
});

test('getEmbedDomains returns results for both domains', async () => {
mockedHttpClient.get
.mockResolvedValueOnce({ status: 200 } as any)
.mockResolvedValueOnce({ status: 503 } as any);

const res = createRes();
await healthController.getEmbedDomains({} as Request, res);

expect(res.json).toHaveBeenCalledWith({
domains: [
{
name: 'VIDSRC_DOMAIN',
domain: 'vidsrc.example',
status: 'success',
httpStatus: 200,
},
{
name: 'MULTI_DOMAIN',
domain: 'multi.example',
status: 'error',
httpStatus: 503,
message: 'Received status code 503',
},
],
});
});

test('getAppUrl returns single domain result', async () => {
mockedHttpClient.get.mockResolvedValue({ status: 301 } as any);
const res = createRes();
await healthController.getAppUrl({} as Request, res);
expect(res.json).toHaveBeenCalledWith({
name: 'APP_URL',
domain: 'https://app.example',
status: 'success',
httpStatus: 301,
});
});

test('getEmbedDomains handles missing MULTI_DOMAIN', async () => {
(appConfig as any).MULTI_DOMAIN = undefined;
mockedHttpClient.get.mockResolvedValue({ status: 200 } as any);

const res = createRes();
await healthController.getEmbedDomains({} as Request, res);

expect(res.json).toHaveBeenCalledWith({
domains: [
{
name: 'VIDSRC_DOMAIN',
domain: 'vidsrc.example',
status: 'success',
httpStatus: 200,
},
{
name: 'MULTI_DOMAIN',
status: 'error',
message: 'Domain not configured',
},
],
});
});
});
103 changes: 103 additions & 0 deletions controllers/healthController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @module controllers/healthController
* @description Controller providing health check endpoints for configured domains.
*/

import { Request, Response } from 'express';

import appConfig from '../config/app';
import httpClient from '../helpers/httpClient';

export type DomainHealthStatus = 'success' | 'error';

export interface DomainHealthResult {
name: string;
domain?: string;
status: DomainHealthStatus;
httpStatus?: number;
message?: string;
}

const normalizeUrl = (domain: string): string => {
return /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
};

export const checkDomainHealth = async (
name: string,
domain?: string
): Promise<DomainHealthResult> => {
if (!domain) {
return {
name,
status: 'error',
message: 'Domain not configured',
};
}

try {
const response = await httpClient.get(normalizeUrl(domain), {
maxRedirects: 0,
/* c8 ignore next */
validateStatus: () => true,
});

if (response.status >= 200 && response.status < 400) {
return {
name,
domain,
status: 'success',
httpStatus: response.status,
};
}

return {
name,
domain,
status: 'error',
httpStatus: response.status,
message: `Received status code ${response.status}`,
};
} catch (error: unknown) {
let message = 'Unknown error';
if (error instanceof Error && error.message) {
message = error.message;
}

return {
name,
domain,
status: 'error',
message,
};
}
};

const healthController = {
/**
* Checks the configured embed domains and returns their reachability status.
* @param {Request} _req - Express request object.
* @param {Response} res - Express response object.
* @returns {Promise<Response>} JSON response containing domain health information.
*/
async getEmbedDomains(_req: Request, res: Response): Promise<Response> {
const domains = await Promise.all([
checkDomainHealth('VIDSRC_DOMAIN', appConfig.VIDSRC_DOMAIN),
checkDomainHealth('MULTI_DOMAIN', appConfig.MULTI_DOMAIN),
]);

return res.json({ domains });
},

/**
* Checks the configured application URL and returns its reachability status.
* @param {Request} _req - Express request object.
* @param {Response} res - Express response object.
* @returns {Promise<Response>} JSON response containing APP_URL health information.
*/
async getAppUrl(_req: Request, res: Response): Promise<Response> {
const domain = await checkDomainHealth('APP_URL', appConfig.APP_URL);
return res.json(domain);
},
};

export default healthController;
57 changes: 57 additions & 0 deletions middleware/appLocals.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
describe('middleware/appLocals', () => {
const configMock = {
APP_NAME: 'Test App',
APP_SUBTITLE: 'Subtitle',
APP_DESCRIPTION: 'Description',
APP_URL: 'https://example.test',
};

const loadMiddleware = (authEnabled: boolean) => {
let middleware: any;
jest.isolateModules(() => {
jest.doMock('../config/app', () => ({
__esModule: true,
default: configMock,
}));
jest.doMock('../helpers/appHelper', () => ({
useAuth: authEnabled,
}));
// eslint-disable-next-line @typescript-eslint/no-var-requires
middleware = require('./appLocals').default;
});
return middleware as (req: any, res: any, next: () => void) => void;
};

afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});

test('sets locals when authentication is disabled', () => {
const appLocals = loadMiddleware(false);
const res: any = { locals: {} };
const next = jest.fn();

appLocals({}, res, next);

expect(res.locals).toEqual({
APP_NAME: configMock.APP_NAME,
APP_SUBTITLE: configMock.APP_SUBTITLE,
APP_DESCRIPTION: configMock.APP_DESCRIPTION,
APP_URL: configMock.APP_URL,
CARD_TYPE: 'card',
});
expect(next).toHaveBeenCalled();
});

test('sets card type to card-add when authentication is enabled', () => {
const appLocals = loadMiddleware(true);
const res: any = { locals: {} };
const next = jest.fn();

appLocals({}, res, next);

expect(res.locals.CARD_TYPE).toBe('card-add');
expect(next).toHaveBeenCalled();
});
});
Loading