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" %} + +
+ + {{ govukInput({ + errorMessage: errors.first_name if errors, + label: { + text: "First Name", + classes: "govuk-label--m" + }, + classes: "govuk-input--width-10", + id: "first_name", + name: "first_name", + value: first_name + }) }} + + {{ govukButton({ + text: "Submit", + attributes: { + "id": "submit" + } + }) }} + +
+
+
+{% 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); + }); +});