diff --git a/src/config/index.ts b/src/config/index.ts
index 03d13c7..0f6c743 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -14,10 +14,12 @@ export const SERVICE_NAME = 'Node Prototype';
export const LANDING_PAGE = 'info';
export const NOT_FOUND = 'page-not-found';
export const ERROR_PAGE = 'error';
+export const VALIDATION_TEST = 'validation-test';
// Routing paths
+export const ROOT_URL = '/';
export const LANDING_URL = '/info';
-
export const INFO_URL = '/info';
+export const VALIDATION_TEST_URL = '/validation-test';
export const HEALTHCHECK_URL = '/healthcheck';
export const SERVICE_URL = `${BASE_URL}${LANDING_URL}`;
diff --git a/src/controller/validation-test.controller.ts b/src/controller/validation-test.controller.ts
new file mode 100644
index 0000000..d2e3dff
--- /dev/null
+++ b/src/controller/validation-test.controller.ts
@@ -0,0 +1,15 @@
+import { Request, Response } from 'express';
+import { log } from '../utils/logger';
+import * as config from '../config';
+
+export const get = (_req: Request, res: Response) => {
+ return res.render(config.VALIDATION_TEST);
+};
+
+export const post = (req: Request, res: Response) => {
+ const firstName = req.body.first_name;
+
+ log.info(`First Name: ${firstName}`);
+
+ return res.redirect(config.LANDING_PAGE);
+};
diff --git a/src/middleware/validation.middleware.ts b/src/middleware/validation.middleware.ts
new file mode 100644
index 0000000..4167025
--- /dev/null
+++ b/src/middleware/validation.middleware.ts
@@ -0,0 +1,40 @@
+import { NextFunction, Request, Response } from 'express';
+import { validationResult, FieldValidationError } from 'express-validator';
+
+import { log } from '../utils/logger';
+import { FormattedValidationErrors } from '../model/validation.model';
+
+export const checkValidations = (
+ req: Request,
+ res: Response,
+ next: NextFunction
+) => {
+ try {
+ const errorList = validationResult(req);
+
+ if (!errorList.isEmpty()) {
+ const template_path = req.path.substring(1);
+ const errors = formatValidationError(
+ errorList.array() as FieldValidationError[]
+ );
+
+ log.info(`Validation error on ${template_path} page`);
+
+ return res.render(template_path, { ...req.body, errors });
+ }
+
+ return next();
+ } catch (err: any) {
+ log.error(err.message);
+ next(err);
+ }
+};
+
+const formatValidationError = (errorList: FieldValidationError[]) => {
+ const errors = { errorList: [] } as FormattedValidationErrors;
+ errorList.forEach((e: FieldValidationError) => {
+ errors.errorList.push({ href: `#${e.path}`, text: e.msg });
+ errors[e.path] = { text: e.msg };
+ });
+ return errors;
+};
diff --git a/src/model/validation.model.ts b/src/model/validation.model.ts
new file mode 100644
index 0000000..90c5bc1
--- /dev/null
+++ b/src/model/validation.model.ts
@@ -0,0 +1,9 @@
+import { ErrorMessages } from '../validation/error.messages';
+
+export interface FormattedValidationErrors {
+ [key: string]: any;
+ errorList: {
+ href: string;
+ text: ErrorMessages;
+ }[];
+}
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 01e515a..54fadf5 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -3,11 +3,13 @@ import { Router } from 'express';
import { logger } from '../middleware/logger.middleware';
import healthcheckRouter from './healthcheck';
import infoRouter from './info';
+import validationTestRouter from './validation-test';
const router = Router();
router.use(logger);
router.use(healthcheckRouter);
router.use(infoRouter);
+router.use(validationTestRouter);
export default router;
diff --git a/src/routes/validation-test.ts b/src/routes/validation-test.ts
new file mode 100644
index 0000000..c2c33f4
--- /dev/null
+++ b/src/routes/validation-test.ts
@@ -0,0 +1,19 @@
+import { Router } from 'express';
+
+import { checkValidations } from '../middleware/validation.middleware';
+import { validationTest } from '../validation/validation-test.validation';
+import { get, post } from '../controller/validation-test.controller';
+
+import * as config from '../config';
+
+const validationTestRouter = Router();
+
+validationTestRouter.get(config.VALIDATION_TEST_URL, get);
+validationTestRouter.post(
+ config.VALIDATION_TEST_URL,
+ ...validationTest,
+ checkValidations,
+ post
+);
+
+export default validationTestRouter;
diff --git a/src/validation/error.messages.ts b/src/validation/error.messages.ts
new file mode 100644
index 0000000..af363bb
--- /dev/null
+++ b/src/validation/error.messages.ts
@@ -0,0 +1,4 @@
+export enum ErrorMessages {
+ TEST_INFO_ERROR = 'INFO PAGE ERROR MESSAGE TEST',
+ TEST_FIRST_NAME = 'Enter your first name',
+}
diff --git a/src/validation/validation-test.validation.ts b/src/validation/validation-test.validation.ts
new file mode 100644
index 0000000..236c03e
--- /dev/null
+++ b/src/validation/validation-test.validation.ts
@@ -0,0 +1,10 @@
+import { body } from 'express-validator';
+
+import { ErrorMessages } from './error.messages';
+
+export const validationTest = [
+ body('first_name')
+ .not()
+ .isEmpty({ ignore_whitespace: true })
+ .withMessage(ErrorMessages.TEST_FIRST_NAME),
+];
diff --git a/src/views/error-list.html b/src/views/error-list.html
new file mode 100644
index 0000000..16c525c
--- /dev/null
+++ b/src/views/error-list.html
@@ -0,0 +1,9 @@
+{% if errors and errors.errorList and errors.errorList.length > 0 %}
+ {{ govukErrorSummary({
+ titleText: "There is a problem",
+ errorList: errors.errorList if errors,
+ attributes: {
+ "tabindex": "0"
+ }
+ }) }}
+{% endif %}
diff --git a/src/views/include/error-list.html b/src/views/include/error-list.html
new file mode 100644
index 0000000..16c525c
--- /dev/null
+++ b/src/views/include/error-list.html
@@ -0,0 +1,9 @@
+{% if errors and errors.errorList and errors.errorList.length > 0 %}
+ {{ govukErrorSummary({
+ titleText: "There is a problem",
+ errorList: errors.errorList if errors,
+ attributes: {
+ "tabindex": "0"
+ }
+ }) }}
+{% endif %}
diff --git a/src/views/layout.html b/src/views/layout.html
index 81ec36c..178a059 100644
--- a/src/views/layout.html
+++ b/src/views/layout.html
@@ -2,7 +2,11 @@
{% from "govuk/components/footer/macro.njk" import govukFooter %}
{% from "govuk/components/header/macro.njk" import govukHeader %}
-
+{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+{% from "govuk/components/back-link/macro.njk" import govukBackLink %}
+{% from "govuk/components/input/macro.njk" import govukInput %}
+{% from "govuk/components/textarea/macro.njk" import govukTextarea %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
{% block head %}
@@ -22,6 +26,10 @@
}) }}
{% endblock %}
+{% block content %}
+{% endblock %}
+
+
{% block footer %}
{{ govukFooter({
meta: {
diff --git a/src/views/validation-test.html b/src/views/validation-test.html
new file mode 100644
index 0000000..2534367
--- /dev/null
+++ b/src/views/validation-test.html
@@ -0,0 +1,36 @@
+{% extends "layout.html" %}
+
+{% block content %}
+
+
+
Validation Test
+
+
Submit field empty to test validation functionality.
+
+ {% include "include/error-list.html" %}
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/test/integration/routes/validation-test.spec.ts b/test/integration/routes/validation-test.spec.ts
new file mode 100644
index 0000000..c854e26
--- /dev/null
+++ b/test/integration/routes/validation-test.spec.ts
@@ -0,0 +1,74 @@
+jest.mock('../../../src/middleware/logger.middleware');
+jest.mock('../../../src/utils/logger');
+
+import { jest, beforeEach, describe, expect, test } from '@jest/globals';
+import { Request, Response, NextFunction } from 'express';
+import request from 'supertest';
+
+import app from '../../../src/app';
+import * as config from '../../../src/config';
+import { logger } from '../../../src/middleware/logger.middleware';
+import { log } from '../../../src/utils/logger';
+
+import {
+ MOCK_REDIRECT_MESSAGE,
+ MOCK_GET_VALIDATION_TEST_RESPONSE,
+ MOCK_POST_VALIDATION_TEST_RESPONSE,
+} from '../../mock/text.mock';
+import { MOCK_POST_VALIDATION_TEST } from '../../mock/data';
+
+const mockedLogger = logger as jest.Mock;
+mockedLogger.mockImplementation(
+ (_req: Request, _res: Response, next: NextFunction) => next()
+);
+
+describe('validation-test endpoint integration tests', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('GET tests', () => {
+ test('renders the validation-test page', async () => {
+ const res = await request(app).get(config.VALIDATION_TEST_URL);
+
+ expect(res.status).toEqual(200);
+ expect(res.text).toContain(MOCK_GET_VALIDATION_TEST_RESPONSE);
+ expect(mockedLogger).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('POST tests', () => {
+ test('Should redirect to landing page after POST request', async () => {
+ const res = await request(app)
+ .post(config.VALIDATION_TEST_URL)
+ .send(MOCK_POST_VALIDATION_TEST);
+
+ expect(res.status).toEqual(302);
+ expect(res.text).toContain(MOCK_REDIRECT_MESSAGE);
+ expect(mockedLogger).toHaveBeenCalledTimes(1);
+ });
+
+ test('Should render the same page with error messages after POST request', async () => {
+ const res = await request(app)
+ .post(config.VALIDATION_TEST_URL)
+ .send({
+ first_name: '',
+ });
+
+ expect(res.status).toEqual(200);
+ expect(res.text).toContain(MOCK_GET_VALIDATION_TEST_RESPONSE);
+ expect(mockedLogger).toHaveBeenCalledTimes(1);
+ });
+
+ test('Should log the First Name and More Details on POST request.', async () => {
+ const mockLog = log.info as jest.Mock;
+ const res = await request(app)
+ .post(config.VALIDATION_TEST_URL)
+ .send(MOCK_POST_VALIDATION_TEST);
+
+ expect(mockLog).toBeCalledWith(MOCK_POST_VALIDATION_TEST_RESPONSE);
+ expect(res.text).toContain(MOCK_REDIRECT_MESSAGE);
+ expect(mockedLogger).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/test/mock/data.ts b/test/mock/data.ts
index 90cf46c..fe0315b 100644
--- a/test/mock/data.ts
+++ b/test/mock/data.ts
@@ -4,6 +4,9 @@ import * as config from '../../src/config';
import express from 'express';
export const GET_REQUEST_MOCK = { method: 'GET', path: '/test' };
+export const MOCK_POST_VALIDATION_TEST = { first_name: 'example' };
+
+export const MOCK_POST_INFO = { test: 'test' };
export const MOCK_CORS_VALUE = {
origin: [config.CDN_HOST, config.BASE_URL],
diff --git a/test/mock/log.mock.ts b/test/mock/log.mock.ts
new file mode 100644
index 0000000..66ff3b4
--- /dev/null
+++ b/test/mock/log.mock.ts
@@ -0,0 +1,4 @@
+import { log } from '../../src/utils/logger';
+
+export const mockLogInfo = log.info as jest.Mock;
+export const mockLogErrorRequest = log.errorRequest as jest.Mock;
diff --git a/test/mock/session.mock.ts b/test/mock/session.mock.ts
new file mode 100644
index 0000000..6434d1d
--- /dev/null
+++ b/test/mock/session.mock.ts
@@ -0,0 +1 @@
+export const mockID = 'c3931b00-a8b4-4d2d-a165-b9b4d148cd88';
diff --git a/test/mock/text.mock.ts b/test/mock/text.mock.ts
index 4d79fd3..40b3e06 100644
--- a/test/mock/text.mock.ts
+++ b/test/mock/text.mock.ts
@@ -7,3 +7,6 @@ export const MOCK_SERVER_ERROR = 'Pipe 3000 requires elevated privileges';
export const MOCK_ERROR_MESSAGE = 'Something has went wrong';
export const MOCK_WRONG_URL = '/infooo';
export const MOCK_VIEWS_PATH = '/path/to/views';
+export const MOCK_REDIRECT_MESSAGE = 'Found. Redirecting to info';
+export const MOCK_GET_VALIDATION_TEST_RESPONSE = 'Submit field empty to test validation functionality.';
+export const MOCK_POST_VALIDATION_TEST_RESPONSE = 'First Name: example';
diff --git a/test/unit/controller/validation-test.controller.spec.ts b/test/unit/controller/validation-test.controller.spec.ts
new file mode 100644
index 0000000..bc9be65
--- /dev/null
+++ b/test/unit/controller/validation-test.controller.spec.ts
@@ -0,0 +1,59 @@
+jest.mock('../../../src/utils/logger');
+
+import { describe, expect, afterEach, test, jest } from '@jest/globals';
+import { Request, Response } from 'express';
+
+import { get, post } from '../../../src/controller/validation-test.controller';
+import * as config from '../../../src/config';
+import { log } from '../../../src/utils/logger';
+
+import { MOCK_POST_VALIDATION_TEST } from '../../mock/data';
+import { MOCK_POST_VALIDATION_TEST_RESPONSE } from '../../mock/text.mock';
+
+const req = {
+ body: MOCK_POST_VALIDATION_TEST,
+} as Request;
+
+const mockResponse = () => {
+ const res = {} as Response;
+ res.render = jest.fn().mockReturnValue(res) as any;
+ res.redirect = jest.fn().mockReturnValue(res) as any;
+ return res;
+};
+
+describe('Validation test controller test suites', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('validation test GET tests', () => {
+ test('should render validation test page', () => {
+ const res = mockResponse();
+
+ get(req, res);
+
+ expect(res.render).toHaveBeenCalledWith(config.VALIDATION_TEST);
+ });
+ });
+
+ describe('validation-test POST tests', () => {
+ test('should redirect to landing-page on POST request', () => {
+ const res = mockResponse();
+
+ post(req, res);
+
+ expect(res.redirect).toBeCalledWith(config.LANDING_PAGE);
+ });
+ test('should log GitHub handle and More Details on POST request', () => {
+ const res = mockResponse();
+
+ const mockLogInfo = log.info as jest.Mock;
+
+ post(req, res);
+
+ expect(mockLogInfo).toHaveBeenCalledWith(
+ MOCK_POST_VALIDATION_TEST_RESPONSE
+ );
+ });
+ });
+});
diff --git a/test/unit/middleware/validation.middleware.spec.ts b/test/unit/middleware/validation.middleware.spec.ts
new file mode 100644
index 0000000..88ea52e
--- /dev/null
+++ b/test/unit/middleware/validation.middleware.spec.ts
@@ -0,0 +1,98 @@
+jest.mock('express-validator');
+jest.mock('../../../src/utils/logger');
+jest.mock('../../../src/utils/validateFilepath.ts');
+
+import {
+ describe,
+ expect,
+ test,
+ jest,
+ afterEach,
+ beforeEach,
+} from '@jest/globals';
+import { Request, Response, NextFunction } from 'express';
+import { validationResult } from 'express-validator';
+
+import * as config from '../../../src/config';
+
+import { checkValidations } from '../../../src/middleware/validation.middleware';
+import { ErrorMessages } from '../../../src/validation/error.messages';
+import { MOCK_ERROR, MOCK_POST_VALIDATION_TEST } from '../../mock/data';
+import { log } from '../../../src/utils/logger';
+
+const validationResultMock = validationResult as unknown as jest.Mock;
+const logInfoMock = log.info as jest.Mock;
+const logErrorMock = log.error as jest.Mock;
+
+const mockResponse = () => {
+ const res = {} as Response;
+ res.render = jest.fn() as any;
+ return res;
+};
+const mockRequest = () => {
+ const req = {} as Request;
+ req.path = config.VALIDATION_TEST_URL;
+ req.body = MOCK_POST_VALIDATION_TEST;
+ return req;
+};
+const next = jest.fn() as NextFunction;
+
+describe('Validation Middleware test suites', () => {
+ let res: any, req: any;
+
+ beforeEach(() => {
+ res = mockResponse();
+ req = mockRequest();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ test('Should call next if errorList is empty', () => {
+ validationResultMock.mockImplementationOnce(() => {
+ return { isEmpty: () => true };
+ });
+ checkValidations(req, res, next);
+
+ expect(logInfoMock).toBeCalledTimes(0);
+ expect(logErrorMock).toBeCalledTimes(0);
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+
+ test(`should call res.render with ${config.VALIDATION_TEST} view if errorList is not empty`, () => {
+ const fieldKey = 'first_name';
+ validationResultMock.mockImplementationOnce(() => {
+ return {
+ isEmpty: () => false,
+ array: () => [{ path: fieldKey, msg: ErrorMessages.TEST_FIRST_NAME }],
+ };
+ });
+ req.body[fieldKey] = '';
+ checkValidations(req, res, next);
+
+ expect(logInfoMock).toHaveBeenCalledTimes(1);
+ expect(logInfoMock).toHaveBeenCalledWith(
+ `Validation error on ${config.VALIDATION_TEST} page`
+ );
+ expect(res.render).toHaveBeenCalledTimes(1);
+ expect(res.render).toHaveBeenCalledWith(config.VALIDATION_TEST, {
+ ...req.body,
+ errors: {
+ errorList: [{ href: `#${fieldKey}`, text: ErrorMessages.TEST_FIRST_NAME }],
+ [fieldKey]: { text: ErrorMessages.TEST_FIRST_NAME },
+ },
+ });
+ });
+
+ test('should catch the error log error message and call next(err)', () => {
+ validationResultMock.mockImplementationOnce(() => {
+ throw new Error(MOCK_ERROR.message);
+ });
+ checkValidations(req, res, next);
+
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(logErrorMock).toHaveBeenCalledTimes(1);
+ expect(logErrorMock).toHaveBeenCalledWith(MOCK_ERROR.message);
+ });
+});