diff --git a/src/constants.ts b/src/constants.ts index 874c0f72..58041064 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -54,6 +54,11 @@ export const ExternalUrls = { // POLICIES: env.POLICIES_LINK, } as const; +// used as Session keys (Redis) +export const SessionKeys = { + COMPANY_NUMBER: "companyNumber" +} as const; + // For use by Matomo export const Ids = { BUTTON_ID_INDIVIDUAL_STATEMENT: "continue_button_ind_statement" diff --git a/src/lib/utils/error-manifests/errorManifest.ts b/src/lib/utils/error-manifests/errorManifest.ts index ce9e726b..af51a95e 100644 --- a/src/lib/utils/error-manifests/errorManifest.ts +++ b/src/lib/utils/error-manifests/errorManifest.ts @@ -9,35 +9,97 @@ const errorManifest = { summary: "Your request contains validation errors", inline: "Your request contains validation errors" }, - email: { + firstName: { blank: { - summary: "Enter an email address", - inline: "Enter an email address" + summary: "Enter the first name", + inline: "Enter the first name" }, incorrect: { - summary: "Email is not valid", - inline: "Enter an email address in the correct format, like name@example.com" + summary: "Enter a valid firstname", + inline: "Enter a valid firstname" } }, - companyName: { + middleName: { + blank: {}, + incorrect: { + summary: "Enter a valid middle name", + inline: "Enter a valid middle name" + } + }, + lastName: { + blank: { + summary: "Enter the last name", + inline: "Enter the last name" + }, + incorrect: { + summary: "Enter a valid last name", + inline: "Enter a valid last name" + } + }, + dateOfBirth: { + blank: { + summary: "Enter the date of birth", + inline: "Enter the date of birth" + }, + incorrect: { + summary: "Enter a valid date of birth", + inline: "Enter a valid date of birth" + } + }, + pscType: { + blank: { + summary: "Select if you're providing verification details for a PSC or RLE", + inline: "Select if you're providing verification details for a PSC or RLE" + }, + incorrect: {} + }, + pscVerificationStatus: { + blank: { + summary: "Select the PSC you're providing verification details for", + inline: "Select the PSC you're providing verification details for" + }, + incorrect: {} + }, + relevantOfficerConfirmation: { + blank: { + summary: "Confirm if the relevant officer is a director of the relevant legal entity, or someone whose roles and responsibilities correspond to that of a company director.", + inline: "Confirm if the relevant officer is a director of the relevant legal entity, or someone whose roles and responsibilities correspond to that of a company director." + }, + incorrect: {} + }, + relevantOfficerPersonalCode: { blank: { - summary: "Enter a company name", - inline: "Enter a company name" + summary: "Enter the personal code for the relevant officer", + inline: "Enter the personal code for the relevant officer" }, incorrect: { - summary: "Company name is not valid", - inline: "Enter a valid company name" + summary: "Enter a valid personal code for the relevant officer", + inline: "Enter a valid personal code for the relevant officer" } }, - description: { + pscPersonalCode: { blank: { - summary: "Enter a company description", - inline: "Enter a company description" + summary: "Enter the personal code for the PSC", + inline: "Enter the personal code for the PSC" }, incorrect: { - summary: "Company description is not valid", - inline: "Enter a valid company description; 120 max characters" + summary: "Enter a valid personal code for the PSC", + inline: "Enter a valid personal code for the PSC" } + }, + pscIdentityVerificationStatement: { + blank: { + summary: "Confirm if the identity verification statement is correct", + inline: "Confirm if the identity verification statement is correct" + }, + incorrect: {} + }, + relevantOfficerStatementConfirmation: { + blank: { + summary: "Summary error text for relevantOfficerStatementConfirmation to be provided", + inline: "Inline error text for relevantOfficerStatementConfirmation to be provided" + }, + incorrect: {} } } }; diff --git a/src/lib/validation/form-validators/pscVerification.ts b/src/lib/validation/form-validators/pscVerification.ts new file mode 100644 index 00000000..6ab5040c --- /dev/null +++ b/src/lib/validation/form-validators/pscVerification.ts @@ -0,0 +1,41 @@ +import { logger } from "./../../logger"; +import { GenericValidator } from "./../../validation/generic"; + +export class PscVerificationFormsValidator extends GenericValidator { + + constructor (classParam?: string) { + super(); + } + + validatePscType (payload: any): Promise { + logger.info(`Request to validate PSC-Type form`); + try { + if (typeof payload.pscType === "undefined" || !this.isValidPscType(payload.pscType)) { + this.errors.stack.pscType = this.errorManifest.validation.pscType.blank; + } + + // validate additional form fields here + + if (!Object.keys(this.errors.stack).length) { + return Promise.resolve({}); + } else { + return Promise.reject(this.errors); + } + } catch (err) { + this.errors.serverError = this.errorManifest.generic.serverError; + return Promise.reject(this.errors); + } + } + + validateRleVerificationStatus (payload: any): Promise { + return Promise.resolve({}); + } + + validateRelevantOfficerDetails (payload: any): Promise { + return Promise.resolve({}); + } + + validateRelevantOfficerConfirmationStatements (payload: any): Promise { + return Promise.resolve({}); + } +}; diff --git a/src/lib/validation/generic.ts b/src/lib/validation/generic.ts new file mode 100644 index 00000000..d0f0f9dd --- /dev/null +++ b/src/lib/validation/generic.ts @@ -0,0 +1,72 @@ +// Single field validators that are called by the form validators or "field-group" validators + +import { logger } from "./../logger"; +import errorManifest from "../utils/error-manifests/errorManifest"; + +export class GenericValidator { + + errors: any; + payload: any; + errorManifest: any; + + constructor () { + this.errors = this.getErrorSignature(); + this.errorManifest = errorManifest; + } + + protected getErrorSignature () { + return { + status: 400, + name: "VALIDATION_ERRORS", + message: errorManifest.validation.default.summary, + stack: {} + }; + } + + isValidPscType (pscType: string): boolean { + logger.info(`Request to validate PSC Type`); + // List of PSC types can be separated out into a util file + if (["individual", "rle"].includes(pscType)) { + return true; + } + return false; + } + + isValidFirstName (firstName: string): boolean { + logger.info(`Request to validate email: ${firstName}`); + const regex = /^[a-z\d_-][a-z\d_\-.\s&]{1,71}$/ig; + if (regex.test(firstName)) { + return true; + } + return false; + } + + isValidMiddleName (middleName: string): boolean { + logger.info(`Request to validate company name: ${middleName}`); + const regex = /^[a-z\d_-][a-z\d_\-.\s&]{1,71}$/ig; + if (regex.test(middleName)) { + return true; + } + return false; + } + + isValidLastName (lastName: string): boolean { + logger.info(`Request to validate company name: ${lastName}`); + const regex = /^[a-z\d_-][a-z\d_\-.\s&]{1,71}$/ig; + if (regex.test(lastName)) { + return true; + } + return false; + } + + isValidDateOfBirth (dob: string): boolean { + logger.info(`Request to validate PostCode: ${dob}`); + + // use Luxon to validate DOB + + if (dob) { + return true; + } + return false; + } +}; diff --git a/src/routers/handlers/psc-type/pscTypeHandler.ts b/src/routers/handlers/psc-type/pscTypeHandler.ts index e9be2252..f9f2b94b 100644 --- a/src/routers/handlers/psc-type/pscTypeHandler.ts +++ b/src/routers/handlers/psc-type/pscTypeHandler.ts @@ -5,7 +5,8 @@ import { getLocaleInfo, getLocalesService, selectLang } from "../../../utils/loc import { BaseViewData, GenericHandler, ViewModel } from "../generic"; import { getUrlWithTransactionIdAndSubmissionId } from "../../../utils/url"; import { addSearchParams } from "../../../utils/queryParams"; -interface PscTypeViewData extends BaseViewData { pscType: string } +import { PscVerificationFormsValidator } from "../../../lib/validation/form-validators/pscVerification"; +interface PscTypeViewData extends BaseViewData { pscType: string, nextPageUrl: string } export class PscTypeHandler extends GenericHandler { @@ -48,17 +49,40 @@ export class PscTypeHandler extends GenericHandler { }; } - public executePost (req: Request, res: Response): string { - const lang = selectLang(req.query.lang); - const selectedType = req.body.pscType; + public async executePost (req: Request, res: Response): Promise> { + const viewData = await this.getViewData(req, res); + try { + const lang = selectLang(req.query.lang); + const selectedType = req.body.pscType; + + const queryParams = new URLSearchParams(req.url.split("?")[1]); + queryParams.set("lang", lang); + queryParams.set("pscType", selectedType); + const nextPageUrl = getUrlWithTransactionIdAndSubmissionId(selectPscType(selectedType), req.params.transactionId, req.params.submissionId); + + viewData.pscType = req.query.pscType as string; + viewData.nextPageUrl = `${nextPageUrl}?${queryParams}`; - const queryParams = new URLSearchParams(req.url.split("?")[1]); - queryParams.set("lang", lang); - queryParams.set("pscType", selectedType); + const validator = new PscVerificationFormsValidator(); - const nextPageUrl = getUrlWithTransactionIdAndSubmissionId(selectPscType(selectedType), req.params.transactionId, req.params.submissionId); - return `${nextPageUrl}?${queryParams}`; + viewData.errors = await validator.validatePscType(req.body); + await this.save(req.body); + } catch (err: any) { + logger.error(`${req.method} error: problem handling PSC type request: ${err.message}`); + viewData.errors = this.processHandlerException(err); + } + + return { + templatePath: PscTypeHandler.templatePath, + viewData + }; } + + // call API service to save data here + private save (payload: any): Object { + return Promise.resolve(true); + } + } // TODO update default when error page available. diff --git a/src/routers/pscTypeRouter.ts b/src/routers/pscTypeRouter.ts index b5a5f2ea..27fde33e 100644 --- a/src/routers/pscTypeRouter.ts +++ b/src/routers/pscTypeRouter.ts @@ -12,7 +12,12 @@ pscTypeRouter.get("/", handleExceptions(async (req: Request, res: Response, _nex pscTypeRouter.post("/", handleExceptions(async (req: Request, res: Response, _next: NextFunction) => { const handler = new PscTypeHandler(); - res.redirect(handler.executePost(req, res)); + const params = await handler.executePost(req, res); + if (!Object.keys(params.viewData.errors).length) { + res.redirect(params.viewData.nextPageUrl); + } else { + res.render(params.templatePath, params.viewData); + } })); export default pscTypeRouter; diff --git a/src/views/router_views/psc_type/psc_type.njk b/src/views/router_views/psc_type/psc_type.njk index 881a5ae6..7edba03e 100644 --- a/src/views/router_views/psc_type/psc_type.njk +++ b/src/views/router_views/psc_type/psc_type.njk @@ -7,9 +7,11 @@ {% set title = i18n.psc_type_title %} {% block main_content %} +
{% include "includes/csrf_token.njk" %} - {{ govukRadios({ +
+ {{ govukRadios({ idPrefix: "pscType", name: "pscType", value: pscType, @@ -20,6 +22,7 @@ classes: "govuk-fieldset__legend--l" } }, + errorMessage: errors.pscType.inline if errors.pscType, items: [ { value: "individual", @@ -42,6 +45,7 @@ ] }) }} +
{{ govukDetails({ summaryText: i18n.psc_type_details_title, diff --git a/test/routers/handlers/psc-type/pscType.int.ts b/test/routers/handlers/psc-type/pscType.int.ts new file mode 100644 index 00000000..f9687f86 --- /dev/null +++ b/test/routers/handlers/psc-type/pscType.int.ts @@ -0,0 +1,48 @@ +import { HttpStatusCode } from "axios"; +import * as cheerio from "cheerio"; +import request from "supertest"; +import middlewareMocks from "../../../mocks/allMiddleware.mock"; +import app from "../../../../src/app"; +import { PrefixedUrls } from "../../../../src/constants"; +import { getUrlWithTransactionIdAndSubmissionId } from "../../../../src/utils/url"; +import { CREATED_RESOURCE, PSC_VERIFICATION_ID, TRANSACTION_ID } from "../../../mocks/pscVerification.mock"; +import { getPscVerification } from "../../../../src/services/pscVerificationService"; +import { URLSearchParams } from "url"; + +jest.mock("../../../../src/services/pscVerificationService", () => ({ + getPscVerification: () => ({ + httpStatusCode: HttpStatusCode.Ok, + resource: CREATED_RESOURCE + }) +})); + +const mockGetPscVerification = getPscVerification as jest.Mock; + +describe.skip("psc type view", () => { + + beforeEach(() => { + middlewareMocks.mockSessionMiddleware.mockClear(); + }); + + afterEach(() => { + expect(middlewareMocks.mockSessionMiddleware).toHaveBeenCalledTimes(1); + jest.clearAllMocks(); + }); + + /** + * This test does not appear to be needed. Will skip it for now until there has been another discussion around it. + */ + it.each([[undefined, undefined], ["individual", "pscType=individual"], ["rle", "pscType=rle"]])("Should render the Psc Type page with a success status code and %s radio button checked", async (expectedSelection, expectedQuery) => { + const queryParams = new URLSearchParams(expectedQuery); + const uriWithQuery = `${PrefixedUrls.PSC_TYPE}?${queryParams}`; + const uri = getUrlWithTransactionIdAndSubmissionId(uriWithQuery, TRANSACTION_ID, PSC_VERIFICATION_ID); + + const resp = await request(app).get(uri); + + const $ = cheerio.load(resp.text); + + expect(resp.status).toBe(HttpStatusCode.Ok); + expect($("input[name=pscType]:checked").val()).toBe(expectedSelection); + }); + +}); diff --git a/test/routers/handlers/psc-type/pscType.unit.ts b/test/routers/handlers/psc-type/pscType.unit.ts index 448e287d..b809fdb4 100644 --- a/test/routers/handlers/psc-type/pscType.unit.ts +++ b/test/routers/handlers/psc-type/pscType.unit.ts @@ -48,27 +48,30 @@ describe("psc type handler", () => { }); describe("executePost", () => { - const req = httpMocks.createRequest({ - method: "POST", - url: Urls.PSC_TYPE, - params: { - transactionId: TRANSACTION_ID, - submissionId: PSC_VERIFICATION_ID - }, - query: { - lang: "en", - companyNumber: COMPANY_NUMBER - }, - body: { - pscType: "individual" - } - }); + it("should return the correct next page", async () => { + + const req = httpMocks.createRequest({ + method: "POST", + url: Urls.PSC_TYPE, + params: { + transactionId: TRANSACTION_ID, + submissionId: PSC_VERIFICATION_ID + }, + query: { + lang: "en", + companyNumber: COMPANY_NUMBER + }, + body: { + pscType: "individual" + } + }); - const res = httpMocks.createResponse({}); - const handler = new PscTypeHandler(); + const res = httpMocks.createResponse({}); + const handler = new PscTypeHandler(); - const redirectUrl = handler.executePost(req, res); + const model = await handler.executePost(req, res); - expect(redirectUrl).toBe(`/persons-with-significant-control-verification/transaction/${TRANSACTION_ID}/submission/${PSC_VERIFICATION_ID}/individual/psc-list?lang=en&pscType=individual`); + expect(model.viewData.nextPageUrl).toBe(`/persons-with-significant-control-verification/transaction/${TRANSACTION_ID}/submission/${PSC_VERIFICATION_ID}/individual/psc-list?lang=en&pscType=individual`); + }); }); }); diff --git a/test/routers/pscType.int.ts b/test/routers/pscType.int.ts index fa4918fd..307349a4 100644 --- a/test/routers/pscType.int.ts +++ b/test/routers/pscType.int.ts @@ -64,7 +64,7 @@ describe("PscType router/handler integration tests", () => { describe("POST method", () => { - it.each([["individual", PrefixedUrls.INDIVIDUAL_PSC_LIST], ["rle", PrefixedUrls.RLE_LIST], ["default", PrefixedUrls.INDIVIDUAL_PSC_LIST]])( + it.each([["individual", PrefixedUrls.INDIVIDUAL_PSC_LIST], ["rle", PrefixedUrls.RLE_LIST]])( "Should redirect the post request to %s list page if selected", async (selectedType, expectedPage) => { @@ -80,6 +80,25 @@ describe("PscType router/handler integration tests", () => { .expect("Location", expectedRedirectUrl); } ); + + it("Should render same page with errors in pscType not selected", + async () => { + const expectedPageUrl = `${PrefixedUrls.PSC_TYPE.replace(":transactionId", TRANSACTION_ID).replace(":submissionId", PSC_VERIFICATION_ID)}?companyNumber=${COMPANY_NUMBER}&lang=en&pscType=undefined`; + + const posted = await request(app) + .post(getUrlWithTransactionIdAndSubmissionId(PrefixedUrls.PSC_TYPE, TRANSACTION_ID, PSC_VERIFICATION_ID)) + .send({ pscType: undefined }) + .set({ "Content-Type": "application/json" }) + // if present in the request the "companyNumber" query param is passed on to the redirect URL + .query({ companyNumber: COMPANY_NUMBER, lang: "en", pscType: undefined }) + .expect(HttpStatusCode.Ok); + + const $ = cheerio.load(posted.text); + + expect($("div.govuk-form-group--error")).toBeDefined(); + expect($("a[href='#err-id-pscType']").text()).toBe("Select if you're providing verification details for a PSC or RLE"); + } + ); }); }); diff --git a/test/routers/pscTypeRouter.int.ts b/test/routers/pscTypeRouter.int.ts new file mode 100644 index 00000000..8b0b83a9 --- /dev/null +++ b/test/routers/pscTypeRouter.int.ts @@ -0,0 +1,68 @@ +import middlewareMocks from "../mocks/allMiddleware.mock"; +import request from "supertest"; +import { PrefixedUrls } from "../../src/constants"; +import app from "../../src/app"; +import { COMPANY_NUMBER, CREATED_RESOURCE, PSC_VERIFICATION_ID, TRANSACTION_ID } from "../mocks/pscVerification.mock"; +import { HttpStatusCode } from "axios"; +import { getUrlWithTransactionIdAndSubmissionId } from "../../src/utils/url"; +import { getPscVerification } from "../../src/services/pscVerificationService"; + +jest.mock("../../src/services/pscVerificationService", () => ({ + getPscVerification: () => ({ + httpStatusCode: HttpStatusCode.Ok, + resource: CREATED_RESOURCE + }) +})); + +const mockGetPscVerification = getPscVerification as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("psc type tests", () => { + + beforeEach(() => { + middlewareMocks.mockSessionMiddleware.mockClear(); + }); + + afterEach(() => { + expect(middlewareMocks.mockSessionMiddleware).toHaveBeenCalledTimes(1); + }); + + it("Should render the PSC Type page with a successful status code", async () => { + const resp = await request(app).get(PrefixedUrls.PSC_TYPE); + expect(resp.status).toBe(200); + }); + + it.each([["individual", PrefixedUrls.INDIVIDUAL_PSC_LIST], ["rle", PrefixedUrls.RLE_LIST]])( + "Should redirect to %s list page if selected", + async (selectedType, expectedPage) => { + + const expectedRedirectUrl = `${expectedPage.replace(":transactionId", TRANSACTION_ID).replace(":submissionId", PSC_VERIFICATION_ID)}?companyNumber=${COMPANY_NUMBER}&lang=en&pscType=${selectedType}`; + await request(app) + .post(getUrlWithTransactionIdAndSubmissionId(PrefixedUrls.PSC_TYPE, TRANSACTION_ID, PSC_VERIFICATION_ID)) + .send({ pscType: selectedType }) + .set({ "Content-Type": "application/json" }) + .query({ companyNumber: COMPANY_NUMBER, lang: "en", pscType: selectedType }) + .expect(HttpStatusCode.Found) + .expect("Location", expectedRedirectUrl); + } + ); + + it("Should fail validation and re-load PSC Type page with errors if no valid selection is made", async () => { + + const selectedType = "invalid-option"; + const expectedRedirectUrl = `${PrefixedUrls.INDIVIDUAL_PSC_LIST.replace(":transactionId", TRANSACTION_ID).replace(":submissionId", PSC_VERIFICATION_ID)}?companyNumber=${COMPANY_NUMBER}&lang=en&pscType=${selectedType}`; + const resp = await request(app) + .post(getUrlWithTransactionIdAndSubmissionId(PrefixedUrls.PSC_TYPE, TRANSACTION_ID, PSC_VERIFICATION_ID)) + .send({ pscType: selectedType }) + .set({ "Content-Type": "application/json" }) + .query({ companyNumber: COMPANY_NUMBER, lang: "en", pscType: selectedType }); + + expect(resp.statusCode).toBe(HttpStatusCode.Ok); + expect(resp.text).toContain("There is a problem"); + expect(resp.text).toContain("Select if you're providing verification details for a PSC or RLE"); + + }); +});