diff --git a/packages/gafl-webapp-service/src/locales/cy.json b/packages/gafl-webapp-service/src/locales/cy.json index fa2942b23b..b9fcd790bd 100644 --- a/packages/gafl-webapp-service/src/locales/cy.json +++ b/packages/gafl-webapp-service/src/locales/cy.json @@ -272,9 +272,16 @@ "disability_concession_title_you": "Ydych chi’n derbyn unrhyw un o’r canlynol?", "dob_day": "Diwrnod", "dob_entry_hint": "Er enghraifft, 23 11 1979", - "dob_error_format_max": "Mae'n rhaid i’r dyddiad geni fod yn y gorffennol", - "dob_error_format_min": "Nodwch eich dyddiad geni a chynnwys y diwrnod, y mis a’r flwyddyn", - "dob_error_format": "Nodwch ddyddiad geni deiliad y drwydded a chynnwys diwrnod, mis a blwyddyn", + "dob_error_date_real": "Mae’n rhaid i’r dyddiad geni fod yn ddyddiad dilys", + "dob_error_missing_day_and_month": "Mae’n rhaid i’r dyddiad geni gynnwys diwrnod a mis", + "dob_error_missing_day_and_year": "Mae’n rhaid i’r dyddiad geni gynnwys diwrnod a blwyddyn", + "dob_error_missing_month_and_year": "Mae’n rhaid i’r dyddiad geni gynnwys mis a blwyddyn", + "dob_error_missing_day": "Mae’n rhaid i’r dyddiad geni gynnwys diwrnod", + "dob_error_missing_month": "Mae’n rhaid i’r dyddiad geni gynnwys mis", + "dob_error_missing_year": "Mae’n rhaid i’r dyddiad geni gynnwys blwyddyn", + "dob_error_non_numeric": "Rhowch rifau yn unig", + "dob_error_year_min": "Mae’r dyddiad geni yn rhy bell yn ôl", + "dob_error_year_max": "Mae'n rhaid i’r dyddiad geni fod yn y gorffennol", "dob_error": "Rhowch y dyddiad geni", "dob_month": "Mis", "dob_privacy_link_prefix": "Os nad ydych yn darparu dyddiad geni cywir, gallai hynny achosi oedi wrth adnewyddu trwydded, neu olygu nad yw’r drwydded yn ddilys. Darllenwch am ", @@ -318,15 +325,19 @@ "header_service_name_title": " - Cael trwydded bysgota â gwialen", "identification": "Rhif adnabod", "identify_body_protect_info": "Er mwyn dod o hyd i fanylion eich trwydded, bydd angen i ni wybod pwy ydych chi. Mae hyn yn ein helpu i ddiogelu eich gwybodaeth bersonol.", + "identify_error_date_real": "Mae’n rhaid i’r dyddiad geni fod yn ddyddiad dilys", "identify_error_empty_postcode": "Nid ydych wedi nodi cod post", "identify_error_empty": "Rhowch chwe nodyn olaf eich trwydded", - "identify_error_enter_bday_max": "Mae’n rhaid i’ch dyddiad geni fod yn y gorffennol", - "identify_error_enter_bday_min": "Mae eich dyddiad geni yn rhy bell yn ôl", - "identify_error_enter_bday": "Nodwch eich dyddiad geni a chynnwys y diwrnod, y mis a’r flwyddyn", "identify_error_invalid_1": "Nid oes gennym gofnod o rif trwydded sy'n gorffen gyda ", "identify_error_invalid_2": " sy’n cyd-fynd â'r manylion hyn.", + "identify_error_missing_day": "Mae’n rhaid i’r dyddiad geni gynnwys diwrnod", + "identify_error_missing_month": "Mae’n rhaid i’r dyddiad geni gynnwys mis", + "identify_error_missing_year": "Mae’n rhaid i’r dyddiad geni gynnwys blwyddyn", + "identify_error_non_numeric": "Rhowch rifau yn unig", "identify_error_pattern_postcode": "Your postcode doesn’t look right. Check and enter again", "identify_error_pattern": "Nid yw chwe nodyn olaf eich trwydded yn edrych yn gywir. Gwiriwch a rhowch gynnig arall arni", + "identify_error_year_min": "Mae’r dyddiad geni yn rhy bell yn ôl", + "identify_error_year_max": "Mae'n rhaid i’r dyddiad geni fod yn y gorffennol", "identify_label_last_six_hint": "Er enghraifft F4A315", "identify_label_last_six": "Chwe nodyn olaf eich trwydded.", "identify_label_licence_ending": "Rhif trwydded yn gorffen gyda’r canlynol", @@ -407,8 +418,16 @@ "licence_num": "Rhif trwydded", "licence_start_days": " diwrnod nesaf", "licence_start_enter_todays_date": "Rhowch ddyddiad heddiw os ydych chi am i’r drwydded 1 diwrnod neu 8 diwrnod ddechrau yn hwyrach heddiw.", + "licence_start_error_date_real": "Mae’n rhaid i ddyddiad dechrau’r drwydded fod yn ddyddiad dilys", + "licence_start_error_missing_day_and_month": "Mae’n rhaid i ddyddiad dechrau’r drwydded gynnwys diwrnod a mis", + "licence_start_error_missing_day_and_year": "Mae’n rhaid i ddyddiad dechrau’r drwydded gynnwys diwrnod a blwyddyn", + "licence_start_error_missing_month_and_year": "Mae’n rhaid i ddyddiad dechrau’r drwydded gynnwys mis a blwyddyn", + "licence_start_error": "Rhowch ddyddiad dechrau’r drwydded", + "licence_start_error_missing_day": "Mae’n rhaid i ddyddiad dechrau’r drwydded gynnwys diwrnod", + "licence_start_error_missing_month": "Mae’n rhaid i ddyddiad dechrau’r drwydded gynnwys mis", + "licence_start_error_missing_year": "Mae’n rhaid i ddyddiad dechrau’r drwydded gynnwys blwyddyn", + "licence_start_error_non_numeric": "Rhowch rifau yn unig", "licence_start_error_choose_when": "Dewiswch pryd y dylai'r drwydded ddechrau", - "licence_start_error_format": "Nodwch y dyddiad y mae angen i'r drwydded ddechrau a chynnwys diwrnod, mis a blwyddyn", "licence_start_error_within": "Nodwch ddyddiad o fewn y ", "licence_start_hint": "Rhowch ddyddiad hyd at a chan gynnwys ", "licence_start_later": "Yn hwyrach", diff --git a/packages/gafl-webapp-service/src/locales/en.json b/packages/gafl-webapp-service/src/locales/en.json index d5fc5b9f92..ca26e7c886 100644 --- a/packages/gafl-webapp-service/src/locales/en.json +++ b/packages/gafl-webapp-service/src/locales/en.json @@ -272,10 +272,19 @@ "disability_concession_title_you": "Do you receive any of the following?", "dob_day": "day", "dob_entry_hint": "For example, 23 11 1979", - "dob_error_format_max": "The date of birth must be in the past", - "dob_error_format_min": "Enter the date of birth and include a day, month and year", - "dob_error_format": "Enter the licence holder’s date of birth and include a day, month and year", - "dob_error": "Enter the date of birth", + + "dob_error_date_real": "Date of birth must be a real date", + "dob_error_missing_day_and_month": "Date of birth must include a day and month", + "dob_error_missing_day_and_year": "Date of birth must include a day and year", + "dob_error_missing_month_and_year": "Date of birth must include a month and year", + "dob_error_missing_day": "Date of birth must include a day", + "dob_error_missing_month": "Date of birth must include a month", + "dob_error_missing_year": "Date of birth must include a year", + "dob_error_non_numeric": "Enter only numbers", + "dob_error_year_min": "Date of birth is too long ago", + "dob_error_year_max": "The date of birth must be in the past", + "dob_error": "Enter a date of birth", + "dob_month": "month", "dob_privacy_link_prefix": "If you do not provide a correct date of birth, this may cause delays when a licence is renewed or mean that a licence is not valid. Read about ", "dob_privacy_link": "how we use personal information (opens in new tab)", @@ -318,15 +327,19 @@ "header_service_name_title": " - Get a rod fishing licence", "identification": "Identification", "identify_body_protect_info": "To find your licence details we first need to identify you. This helps us protect your personal information.", + "identify_error_date_real": "Date of birth must be a real date", "identify_error_empty_postcode": "You did not enter a postcode", "identify_error_empty": "Enter the last six characters of your licence number", - "identify_error_enter_bday_max": "Your date of birth must be in the past", - "identify_error_enter_bday_min": "Your date of birth is too long ago", - "identify_error_enter_bday": "Enter your date of birth and include a day, month and year", "identify_error_invalid_1": "We do not have any record of a licence number ending ", "identify_error_invalid_2": " matching these details.", + "identify_error_missing_day": "Date of birth must include a day", + "identify_error_missing_month": "Date of birth must include a month", + "identify_error_missing_year": "Date of birth must include a year", + "identify_error_non_numeric": "Enter only numbers", "identify_error_pattern_postcode": "Your postcode doesn’t look right. Check and enter again", "identify_error_pattern": "The last six characters of your licence number don’t look right. Check and enter again", + "identify_error_year_min": "Date of birth is too long ago", + "identify_error_year_max": "The date of birth must be in the past", "identify_label_last_six_hint": "For example F4A315", "identify_label_last_six": "The last six characters of your licence number", "identify_label_licence_ending": "The licence number ending", @@ -407,8 +420,16 @@ "licence_num": "Licence number", "licence_start_days": " days", "licence_start_enter_todays_date": "Enter today’s date if you want the 1-day or 8-day licence to start later today.", + "licence_start_error_date_real": "Licence start date must be a real date", + "licence_start_error_missing_day_and_month": "Licence start date must include a day and month", + "licence_start_error_missing_day_and_year": "Licence start date must include a day and year", + "licence_start_error_missing_month_and_year": "Licence start date must include a month and year", + "licence_start_error_missing_day": "Licence start date must include a day", + "licence_start_error_missing_month": "Licence start date must include a month", + "licence_start_error_missing_year": "Licence start date must include a year", + "licence_start_error_non_numeric": "Enter only numbers", + "licence_start_error": "Enter a licence start date", "licence_start_error_choose_when": "Choose when the licence should start", - "licence_start_error_format": "Enter the date the licence needs to start, include a day, month and year", "licence_start_error_within": "Enter a date within the next ", "licence_start_hint": "Enter a date up to and including ", "licence_start_later": "Later", diff --git a/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/__tests__/route.spec.js b/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/__tests__/route.spec.js index 43ffc49a6c..7dc8cd1fcd 100644 --- a/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/__tests__/route.spec.js +++ b/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/__tests__/route.spec.js @@ -1,15 +1,30 @@ -import { getData, validator } from '../route' +import { getData } from '../route' import pageRoute from '../../../../routes/page-route.js' import { nextPage } from '../../../../routes/next-page.js' -import { LICENCE_FOR } from '../../../../uri.js' +import { DATE_OF_BIRTH, LICENCE_FOR } from '../../../../uri.js' +import { dateOfBirthValidator, getDateErrorFlags } from '../../../../schema/validators/validators.js' -jest.mock('../../../../routes/next-page.js', () => ({ - nextPage: jest.fn() -})) +jest.mock('../../../../routes/next-page.js') jest.mock('../../../../routes/page-route.js') +jest.mock('../../../../schema/validators/validators.js') +jest.mock('../../../../uri.js', () => ({ + ...jest.requireActual('../../../../uri.js'), + DATE_OF_BIRTH: { + page: Symbol('date-of-birth-page'), + uri: Symbol('/date-of-birth') + }, + LICENCE_TO_START: { + page: Symbol('licence-to-start-page'), + uri: Symbol('/licence-to-start') + } +})) describe('name > route', () => { - const mockRequest = (statusGet = () => {}, transactionGet = () => {}) => ({ + const mockRequest = ({ + pageGet = async () => {}, + statusGet = async () => ({ [LICENCE_FOR.page]: true }), + transactionGet = async () => ({ isLicenceForYou: null }) + } = {}) => ({ cache: () => ({ helpers: { transaction: { @@ -17,6 +32,9 @@ describe('name > route', () => { }, status: { getCurrentPermission: statusGet + }, + page: { + getCurrentPermission: pageGet } } }) @@ -24,52 +42,89 @@ describe('name > route', () => { describe('getData', () => { it('should return isLicenceForYou as true, if isLicenceForYou is true on the transaction cache', async () => { - const transaction = () => ({ + const transactionGet = async () => ({ isLicenceForYou: true }) - const status = () => ({ + const statusGet = async () => ({ [LICENCE_FOR.page]: true }) - const result = await getData(mockRequest(status, transaction)) + + const result = await getData(mockRequest({ statusGet, transactionGet })) expect(result.isLicenceForYou).toBeTruthy() }) it('should return isLicenceForYou as false, if isLicenceForYou is false on the transaction cache', async () => { - const transaction = () => ({ + const transactionGet = async () => ({ isLicenceForYou: false }) - const status = () => ({ + const statusGet = async () => ({ [LICENCE_FOR.page]: true }) - const result = await getData(mockRequest(status, transaction)) + const result = await getData(mockRequest({ statusGet, transactionGet })) expect(result.isLicenceForYou).toBeFalsy() }) + + it.each([ + ['full-date', 'object.missing'], + ['day', 'any.required'] + ])('should add error details ({%s: %s}) to the page data', async (errorKey, errorValue) => { + const pageGet = async () => ({ + error: { [errorKey]: errorValue } + }) + + const result = await getData(mockRequest({ pageGet })) + expect(result.error).toEqual({ errorKey, errorValue }) + }) + + it('omits error if there is no error', async () => { + const result = await getData(mockRequest()) + expect(result.error).toBeUndefined() + }) + + it('adds return value of getErrorFlags to the page data', async () => { + const errorFlags = { unique: Symbol('error-flags') } + getDateErrorFlags.mockReturnValueOnce(errorFlags) + const result = await getData(mockRequest()) + expect(result).toEqual(expect.objectContaining(errorFlags)) + }) + + it('passes error to getErrorFlags', async () => { + const error = Symbol('error') + await getData(mockRequest({ pageGet: async () => ({ error }) })) + expect(getDateErrorFlags).toHaveBeenCalledWith(error) + }) + + it('passes correct page name when getting page cache', async () => { + const pageGet = jest.fn(() => ({})) + await getData(mockRequest({ pageGet })) + expect(pageGet).toHaveBeenCalledWith(DATE_OF_BIRTH.page) + }) }) describe('redirectToStartOfJourney', () => { it('should throw a redirect if not been to LICENCE_FOR page', async () => { - const transaction = () => ({ + const transactionGet = async () => ({ isLicenceForYou: true }) - const status = () => ({ + const statusGet = async () => ({ [LICENCE_FOR.page]: false }) - const func = () => getData(mockRequest(status, transaction)) + const func = () => getData(mockRequest({ statusGet, transactionGet })) await expect(func).rejects.toThrowRedirectTo(LICENCE_FOR.uri) }) it('should not throw a redirect if not been to LICENCE_FOR page', async () => { - const transaction = () => ({ + const transactionGet = async () => ({ isLicenceForYou: true }) - const status = () => ({ + const statusGet = async () => ({ [LICENCE_FOR.page]: true }) let error try { - await getData(mockRequest(status, transaction)) + await getData(mockRequest({ statusGet, transactionGet })) } catch (e) { error = e } @@ -79,8 +134,8 @@ describe('name > route', () => { }) describe('default', () => { - it('should call the pageRoute with date-of-birth, /buy/date-of-birth, validator and nextPage', async () => { - expect(pageRoute).toBeCalledWith('date-of-birth', '/buy/date-of-birth', validator, nextPage, getData) + it('should call the pageRoute with date-of-birth, /buy/date-of-birth, dateOfBirthValidator and nextPage', async () => { + expect(pageRoute).toBeCalledWith(DATE_OF_BIRTH.page, DATE_OF_BIRTH.uri, dateOfBirthValidator, nextPage, getData) }) }) }) diff --git a/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/date-of-birth.njk b/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/date-of-birth.njk index 0a29acedb0..65512b9fd4 100644 --- a/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/date-of-birth.njk +++ b/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/date-of-birth.njk @@ -9,11 +9,37 @@ {% set errorMap = { - 'date-of-birth': { - 'date.format': { ref: '#date-of-birth-day', text: mssgs.dob_error_format }, - 'date.max': { ref: '#date-of-birth-day', text: mssgs.dob_error_format_max }, - 'date.min': { ref: '#date-of-birth-day', text: mssgs.dob_error_format_min } - } + 'full-date': { + 'object.missing': { ref: '#date-of-birth-day', text: mssgs.dob_error } + }, + 'day-and-month': { + 'object.missing': { ref: '#date-of-birth-day', text: mssgs.dob_error_missing_day_and_month } + }, + 'day-and-year': { + 'object.missing': { ref: '#date-of-birth-day', text: mssgs.dob_error_missing_day_and_year } + }, + 'month-and-year': { + 'object.missing': { ref: '#date-of-birth-month', text: mssgs.dob_error_missing_month_and_year } + }, + 'day': { + 'any.required': { ref: '#date-of-birth-day', text: mssgs.dob_error_missing_day } + }, + 'month': { + 'any.required': { ref: '#date-of-birth-month', text: mssgs.dob_error_missing_month } + }, + 'year': { + 'any.required': { ref: '#date-of-birth-year', text: mssgs.dob_error_missing_year } + }, + 'non-numeric': { + 'number.base': { ref: '#date-of-birth-day', text: mssgs.dob_error_non_numeric } + }, + 'invalid-date': { + 'any.custom': { ref: '#date-of-birth-day', text: mssgs.dob_error_date_real } + }, + 'date-range': { + 'date.min': { ref: '#date-of-birth-day', text: mssgs.dob_error_year_min }, + 'date.max': { ref: '#date-of-birth-day', text: mssgs.dob_error_year_max } + } } %} @@ -21,21 +47,21 @@ { label: mssgs.dob_day, name: 'day', - classes: "govuk-input--width-2", + classes: "govuk-input--width-2 govuk-input--error" if data.isDayError else "govuk-input--width-2", value: payload['date-of-birth-day'], attributes: { maxlength : 2 } }, { label: mssgs.dob_month, name: 'month', - classes: "govuk-input--width-2", + classes: "govuk-input--width-2 govuk-input--error" if data.isMonthError else "govuk-input--width-2", value: payload['date-of-birth-month'], attributes: { maxlength : 2 } }, { label: mssgs.dob_year, name: 'year', - classes: "govuk-input--width-4", + classes: "govuk-input--width-4 govuk-input--error" if data.isYearError else "govuk-input--width-4", value: payload['date-of-birth-year'], attributes: { maxlength : 4 } } @@ -56,6 +82,6 @@ id: "date-of-birth", namePrefix: "date-of-birth", items: dateInputItems, - errorMessage: { text: mssgs.dob_error } if error + errorMessage: { text: errorMap[data.error.errorKey][data.error.errorValue].text } if data.error }) }} {% endblock %} diff --git a/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/route.js b/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/route.js index c20b48634d..1e0b00bc1b 100644 --- a/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/route.js +++ b/packages/gafl-webapp-service/src/pages/concessions/date-of-birth/route.js @@ -1,19 +1,8 @@ import { DATE_OF_BIRTH, LICENCE_FOR } from '../../../uri.js' -import Joi from 'joi' import pageRoute from '../../../routes/page-route.js' -import { validation } from '@defra-fish/business-rules-lib' import { nextPage } from '../../../routes/next-page.js' import GetDataRedirect from '../../../handlers/get-data-redirect.js' - -export const validator = payload => { - const dateOfBirth = `${payload['date-of-birth-year']}-${payload['date-of-birth-month']}-${payload['date-of-birth-day']}` - Joi.assert( - { 'date-of-birth': dateOfBirth }, - Joi.object({ - 'date-of-birth': validation.contact.createBirthDateValidator(Joi) - }) - ) -} +import { dateOfBirthValidator, getDateErrorFlags } from '../../../schema/validators/validators.js' const redirectToStartOfJourney = status => { if (!status[LICENCE_FOR.page]) { @@ -24,10 +13,17 @@ const redirectToStartOfJourney = status => { export const getData = async request => { const { isLicenceForYou } = await request.cache().helpers.transaction.getCurrentPermission() const status = await request.cache().helpers.status.getCurrentPermission() + const page = await request.cache().helpers.page.getCurrentPermission(DATE_OF_BIRTH.page) + const pageData = { isLicenceForYou, ...getDateErrorFlags(page?.error) } redirectToStartOfJourney(status) - return { isLicenceForYou } + if (page?.error) { + const [errorKey] = Object.keys(page.error) + const errorValue = page.error[errorKey] + pageData.error = { errorKey, errorValue } + } + return pageData } -export default pageRoute(DATE_OF_BIRTH.page, DATE_OF_BIRTH.uri, validator, nextPage, getData) +export default pageRoute(DATE_OF_BIRTH.page, DATE_OF_BIRTH.uri, dateOfBirthValidator, nextPage, getData) diff --git a/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/__tests__/route.spec.js b/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/__tests__/route.spec.js index 3d76e2b007..11519e4f07 100644 --- a/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/__tests__/route.spec.js +++ b/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/__tests__/route.spec.js @@ -1,21 +1,42 @@ +import pageRoute from '../../../../routes/page-route.js' +import { nextPage } from '../../../../routes/next-page.js' import { getData } from '../route' +import { LICENCE_TO_START } from '../../../../uri.js' +import { startDateValidator, getDateErrorFlags } from '../../../../schema/validators/validators.js' -jest.mock('../../../../processors/uri-helper.js') +jest.mock('../../../../routes/next-page.js') +jest.mock('../../../../routes/page-route.js') +jest.mock('../../../../schema/validators/validators.js') +jest.mock('../../../../uri.js', () => ({ + ...jest.requireActual('../../../../uri.js'), + LICENCE_TO_START: { + page: Symbol('licence-to-start-page'), + uri: Symbol('/licence-to-start') + } +})) +jest.mock('../../../../schema/validators/validators.js') describe('licence-to-start > route', () => { - const getMockRequest = (isLicenceForYou = true) => ({ + const getMockRequest = (isLicenceForYou = true, pageGet = () => {}) => ({ cache: () => ({ helpers: { transaction: { getCurrentPermission: () => ({ isLicenceForYou }) + }, + page: { + getCurrentPermission: pageGet } } }) }) describe('getData', () => { + beforeEach(() => { + getDateErrorFlags.mockClear() + }) + it('should return isLicenceForYou as true, if isLicenceForYou is true on the transaction cache', async () => { const request = getMockRequest() const result = await getData(request) @@ -27,5 +48,47 @@ describe('licence-to-start > route', () => { const result = await getData(request) expect(result.isLicenceForYou).toBeFalsy() }) + + it.each([ + ['full-date', 'object.missing'], + ['day', 'any.required'] + ])('should add error details ({%s: %s}) to the page data', async (errorKey, errorValue) => { + const pageGet = async () => ({ + error: { [errorKey]: errorValue } + }) + + const result = await getData(getMockRequest(undefined, pageGet)) + expect(result.error).toEqual({ errorKey, errorValue }) + }) + + it('omits error if there is no error', async () => { + const result = await getData(getMockRequest()) + expect(result.error).toBeUndefined() + }) + + it('passes correct page name when getting page cache', async () => { + const pageGet = jest.fn(() => {}) + await getData(getMockRequest(undefined, pageGet)) + expect(pageGet).toHaveBeenCalledWith(LICENCE_TO_START.page) + }) + + it('adds return value of getErrorFlags to the page data', async () => { + const errorFlags = { unique: Symbol('error-flags') } + getDateErrorFlags.mockReturnValueOnce(errorFlags) + const result = await getData(getMockRequest()) + expect(result).toEqual(expect.objectContaining(errorFlags)) + }) + + it('passes error to getErrorFlags', async () => { + const error = Symbol('error') + await getData(getMockRequest(undefined, async () => ({ error }))) + expect(getDateErrorFlags).toHaveBeenCalledWith(error) + }) + }) + + describe('default', () => { + it('should call the pageRoute with date-of-birth, /buy/date-of-birth, dateOfBirthValidator and nextPage', async () => { + expect(pageRoute).toBeCalledWith(LICENCE_TO_START.page, LICENCE_TO_START.uri, startDateValidator, nextPage, getData) + }) }) }) diff --git a/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/licence-to-start.njk b/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/licence-to-start.njk index b64afbf24d..aa176aced2 100644 --- a/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/licence-to-start.njk +++ b/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/licence-to-start.njk @@ -8,14 +8,37 @@ {% set errorMap = { - 'licence-to-start': { - 'any.required': { ref: '#licence-to-start', text: mssgs.licence_start_error_choose_when } - }, - 'licence-start-date': { - 'date.format': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_format }, - 'date.max': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_within + data.advancedPurchaseMaxDays + mssgs.licence_start_days }, - 'date.min': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_within + data.advancedPurchaseMaxDays + mssgs.licence_start_days } - } + 'full-date': { + 'object.missing': { ref: '#licence-start-date-day', text: mssgs.licence_start_error } + }, + 'day-and-month': { + 'object.missing': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_missing_day_and_month } + }, + 'day-and-year': { + 'object.missing': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_missing_day_and_year } + }, + 'month-and-year': { + 'object.missing': { ref: '#licence-start-date-month', text: mssgs.licence_start_error_missing_month_and_year } + }, + 'day': { + 'any.required': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_missing_day } + }, + 'month': { + 'any.required': { ref: '#licence-start-date-month', text: mssgs.licence_start_error_missing_month } + }, + 'year': { + 'any.required': { ref: '#licence-start-date-year', text: mssgs.licence_start_error_missing_year } + }, + 'non-numeric': { + 'number.base': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_non_numeric } + }, + 'invalid-date': { + 'any.custom': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_date_real } + }, + 'date-range': { + 'date.min': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_within + data.advancedPurchaseMaxDays + mssgs.licence_start_days }, + 'date.max': { ref: '#licence-start-date-day', text: mssgs.licence_start_error_within + data.advancedPurchaseMaxDays + mssgs.licence_start_days } + } } %} @@ -23,21 +46,21 @@ { name: "day", label: mssgs.dob_day, - classes: "govuk-input--width-2", + classes: "govuk-input--width-2 govuk-input--error" if data.isDayError else "govuk-input--width-2", value: payload['licence-start-date-day'], attributes: { maxlength : 2 } }, { name: "month", label: mssgs.dob_month, - classes: "govuk-input--width-2", + classes: "govuk-input--width-2 govuk-input--error" if data.isMonthError else "govuk-input--width-2", value: payload['licence-start-date-month'], attributes: { maxlength : 2 } }, { name: "year", label: mssgs.dob_year, - classes: "govuk-input--width-4", + classes: "govuk-input--width-4 govuk-input--error" if data.isYearError else "govuk-input--width-4", value: payload['licence-start-date-year'], attributes: { maxlength : 4 } } @@ -53,7 +76,7 @@ id: "licence-start-date", namePrefix: "licence-start-date", items: dateInputItems, - errorMessage: { text: mssgs.licence_start_error_within + data.advancedPurchaseMaxDays + mssgs.licence_start_days } if error['licence-start-date'], + errorMessage: { text: errorMap[data.error.errorKey][data.error.errorValue].text } if data.error, hint: { text: mssgs.licence_start_hint + data.maxStartDate } diff --git a/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/route.js b/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/route.js index 31ae6d2ffa..6e063b4881 100644 --- a/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/route.js +++ b/packages/gafl-webapp-service/src/pages/licence-details/licence-to-start/route.js @@ -1,49 +1,31 @@ -import Joi from 'joi' import moment from 'moment-timezone' - -import JoiDate from '@hapi/joi-date' import { START_AFTER_PAYMENT_MINUTES, ADVANCED_PURCHASE_MAX_DAYS, SERVICE_LOCAL_TIME } from '@defra-fish/business-rules-lib' import { LICENCE_TO_START } from '../../../uri.js' import pageRoute from '../../../routes/page-route.js' -import { dateFormats } from '../../../constants.js' import { nextPage } from '../../../routes/next-page.js' - -const JoiX = Joi.extend(JoiDate) - -const validator = payload => { - const licenceStartDate = `${payload['licence-start-date-year']}-${payload['licence-start-date-month']}-${payload['licence-start-date-day']}` - Joi.assert( - { - 'licence-start-date': licenceStartDate, - 'licence-to-start': payload['licence-to-start'] - }, - Joi.object({ - 'licence-to-start': Joi.string().valid('after-payment', 'another-date').required(), - 'licence-start-date': Joi.alternatives().conditional('licence-to-start', { - is: 'another-date', - then: JoiX.date() - .format(dateFormats) - .min(moment().tz(SERVICE_LOCAL_TIME).startOf('day')) - .max(moment().tz(SERVICE_LOCAL_TIME).add(ADVANCED_PURCHASE_MAX_DAYS, 'days')) - .required(), - otherwise: Joi.string().empty('') - }) - }).options({ abortEarly: false, allowUnknown: true }) - ) -} +import { getDateErrorFlags, startDateValidator } from '../../../schema/validators/validators.js' export const getData = async request => { const fmt = 'DD MM YYYY' const { isLicenceForYou } = await request.cache().helpers.transaction.getCurrentPermission() - - return { + const page = await request.cache().helpers.page.getCurrentPermission(LICENCE_TO_START.page) + const pageData = { isLicenceForYou, exampleStartDate: moment().tz(SERVICE_LOCAL_TIME).add(1, 'days').format(fmt), minStartDate: moment().tz(SERVICE_LOCAL_TIME).format(fmt), maxStartDate: moment().tz(SERVICE_LOCAL_TIME).add(ADVANCED_PURCHASE_MAX_DAYS, 'days').format(fmt), advancedPurchaseMaxDays: ADVANCED_PURCHASE_MAX_DAYS, - startAfterPaymentMinutes: START_AFTER_PAYMENT_MINUTES + startAfterPaymentMinutes: START_AFTER_PAYMENT_MINUTES, + ...getDateErrorFlags(page?.error) + } + + if (page?.error) { + const [errorKey] = Object.keys(page.error) + const errorValue = page.error[errorKey] + pageData.error = { errorKey, errorValue } } + + return pageData } -export default pageRoute(LICENCE_TO_START.page, LICENCE_TO_START.uri, validator, nextPage, getData) +export default pageRoute(LICENCE_TO_START.page, LICENCE_TO_START.uri, startDateValidator, nextPage, getData) diff --git a/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/identity.next-page.spec.js b/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/identity.next-page.spec.js deleted file mode 100644 index d6a639cbb7..0000000000 --- a/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/identity.next-page.spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import pageRoute from '../../../../routes/page-route.js' -import { addLanguageCodeToUri } from '../../../../processors/uri-helper.js' -require('../route.js') // require rather than import to avoid lint error with unused variable - -jest.mock('../../../../routes/page-route.js', () => jest.fn()) -jest.mock('../../../../uri.js', () => ({ - IDENTIFY: { page: 'identify page', uri: 'identify uri' }, - AUTHENTICATE: { uri: Symbol('authenticate uri') } -})) -jest.mock('../../../../processors/uri-helper.js') - -describe('page route next', () => { - const nextPage = pageRoute.mock.calls[0][3] - beforeEach(jest.clearAllMocks) - - it('passes a function as the nextPage argument', () => { - expect(typeof nextPage).toBe('function') - }) - - it('calls addLanguageCodeToUri', () => { - nextPage() - expect(addLanguageCodeToUri).toHaveBeenCalled() - }) - - it('passes request to addLanguageCodeToUri', () => { - const request = Symbol('request') - nextPage(request) - expect(addLanguageCodeToUri).toHaveBeenCalledWith(request, expect.anything()) - }) - - it('next page returns result of addLanguageCodeToUri', () => { - const expectedResult = Symbol('add language code to uri') - addLanguageCodeToUri.mockReturnValueOnce(expectedResult) - expect(nextPage()).toBe(expectedResult) - }) -}) diff --git a/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/identity.spec.js b/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/identity.spec.js index d4d1f1f18a..ae72140f2f 100644 --- a/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/identity.spec.js +++ b/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/identity.spec.js @@ -116,6 +116,9 @@ describe('The easy renewal identification page', () => { referenceNumber: 'ABC123' }), setCurrentPermission: () => {} + }, + page: { + getCurrentPermission: async () => ({}) } } }) diff --git a/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/route-spec.js b/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/route-spec.js deleted file mode 100644 index 8e66fe6174..0000000000 --- a/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/route-spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import { addLanguageCodeToUri } from '../../../../processors/uri-helper.js' -import { getData } from '../route.js' -import { NEW_TRANSACTION } from '../../../../uri.js' - -jest.mock('../../../../processors/uri-helper.js') - -const getMockRequest = referenceNumber => ({ - cache: () => ({ - helpers: { - status: { - getCurrentPermission: () => ({ - referenceNumber: referenceNumber - }) - } - } - }) -}) - -describe('getData', () => { - it('addLanguageCodeToUri is called with the expected arguments', async () => { - const request = getMockRequest('013AH6') - await getData(request) - expect(addLanguageCodeToUri).toHaveBeenCalledWith(request, NEW_TRANSACTION.uri) - }) - - it('getData returns correct URI', async () => { - const expectedUri = Symbol('decorated uri') - addLanguageCodeToUri.mockReturnValueOnce(expectedUri) - - const result = await getData(getMockRequest('013AH6')) - expect(result.uri.new).toEqual(expectedUri) - }) - - it.each([['09F6VF'], ['013AH6'], ['LK563F']])('getData returns referenceNumber', async referenceNumber => { - const result = await getData(getMockRequest(referenceNumber)) - expect(result.referenceNumber).toEqual(referenceNumber) - }) -}) diff --git a/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/route.spec.js b/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/route.spec.js new file mode 100644 index 0000000000..afe14fdae0 --- /dev/null +++ b/packages/gafl-webapp-service/src/pages/renewals/identify/__tests__/route.spec.js @@ -0,0 +1,126 @@ +import pageRoute from '../../../../routes/page-route.js' +import { addLanguageCodeToUri } from '../../../../processors/uri-helper.js' +import { getData, validator } from '../route.js' +import { IDENTIFY, NEW_TRANSACTION } from '../../../../uri.js' +import { dateOfBirthValidator, getDateErrorFlags } from '../../../../schema/validators/validators.js' + +jest.mock('../../../../routes/page-route.js', () => jest.fn()) +jest.mock('../../../../uri.js', () => ({ + IDENTIFY: { page: 'identify page', uri: 'identify uri' }, + AUTHENTICATE: { uri: Symbol('authenticate uri') }, + NEW_TRANSACTION: { uri: Symbol('new transaction uri') } +})) +jest.mock('../../../../processors/uri-helper.js') +jest.mock('../../../../schema/validators/validators.js') + +describe('getData', () => { + const getMockRequest = (referenceNumber, pageGet = async () => ({})) => ({ + cache: () => ({ + helpers: { + status: { + getCurrentPermission: () => ({ + referenceNumber: referenceNumber + }) + }, + page: { + getCurrentPermission: pageGet + } + } + }) + }) + + it('addLanguageCodeToUri is called with the expected arguments', async () => { + const request = getMockRequest('013AH6') + await getData(request) + expect(addLanguageCodeToUri).toHaveBeenCalledWith(request, NEW_TRANSACTION.uri) + }) + + it('getData returns correct URI', async () => { + const expectedUri = Symbol('decorated uri') + addLanguageCodeToUri.mockReturnValueOnce(expectedUri) + + const result = await getData(getMockRequest('013AH6')) + expect(result.uri.new).toEqual(expectedUri) + }) + + it.each([['09F6VF'], ['013AH6'], ['LK563F']])('getData returns referenceNumber', async referenceNumber => { + const result = await getData(getMockRequest(referenceNumber)) + expect(result.referenceNumber).toEqual(referenceNumber) + }) + + it('adds return value of getErrorFlags to the page data', async () => { + const errorFlags = { unique: Symbol('error-flags') } + getDateErrorFlags.mockReturnValueOnce(errorFlags) + const result = await getData(getMockRequest()) + expect(result).toEqual(expect.objectContaining(errorFlags)) + }) + + it('passes error to getErrorFlags', async () => { + const error = Symbol('error') + await getData(getMockRequest(undefined, async () => ({ error }))) + expect(getDateErrorFlags).toHaveBeenCalledWith(error) + }) + + it('passes correct page name when getting page cache', async () => { + const pageGet = jest.fn(() => ({})) + await getData(getMockRequest(undefined, pageGet)) + expect(pageGet).toHaveBeenCalledWith(IDENTIFY.page) + }) +}) + +describe('default', () => { + it('should call the pageRoute with date-of-birth, /buy/date-of-birth, dateOfBirthValidator and nextPage', async () => { + expect(pageRoute).toBeCalledWith(IDENTIFY.page, IDENTIFY.uri, validator, expect.any(Function), getData) + }) +}) + +describe('page route next', () => { + const nextPage = pageRoute.mock.calls[0][3] + beforeEach(jest.clearAllMocks) + + it('passes a function as the nextPage argument', () => { + expect(typeof nextPage).toBe('function') + }) + + it('calls addLanguageCodeToUri', () => { + nextPage() + expect(addLanguageCodeToUri).toHaveBeenCalled() + }) + + it('passes request to addLanguageCodeToUri', () => { + const request = Symbol('request') + nextPage(request) + expect(addLanguageCodeToUri).toHaveBeenCalledWith(request, expect.anything()) + }) + + it('next page returns result of addLanguageCodeToUri', () => { + const expectedResult = Symbol('add language code to uri') + addLanguageCodeToUri.mockReturnValueOnce(expectedResult) + expect(nextPage()).toBe(expectedResult) + }) +}) + +describe('validator', () => { + const getMockRequest = (postcode = 'AA1 1AA', referenceNumber = 'A1B2C3') => ({ + postcode, + referenceNumber + }) + + it('fails if dateOfBirth validator fails', () => { + const expectedError = new Error('expected error') + dateOfBirthValidator.mockImplementationOnce(() => { + throw expectedError + }) + expect(() => validator(getMockRequest)).toThrow(expectedError) + }) + + it('passes if dateOfBirth validator passes', () => { + expect(() => validator(getMockRequest())).not.toThrow() + }) + + it('passes payload to dateOfBirth validator', () => { + const payload = getMockRequest() + validator(payload) + expect(dateOfBirthValidator).toHaveBeenCalledWith(payload) + }) +}) diff --git a/packages/gafl-webapp-service/src/pages/renewals/identify/identify.njk b/packages/gafl-webapp-service/src/pages/renewals/identify/identify.njk index dd62183b50..d3f91d41c0 100644 --- a/packages/gafl-webapp-service/src/pages/renewals/identify/identify.njk +++ b/packages/gafl-webapp-service/src/pages/renewals/identify/identify.njk @@ -21,14 +21,40 @@ ref: "#ref" } }, - 'date-of-birth': { - 'date.format': { ref: '#date-of-birth-day', text: mssgs.identify_error_enter_bday }, - 'date.max': { ref: '#date-of-birth-day', text: mssgs.identify_error_enter_bday_max }, - 'date.min': { ref: '#date-of-birth-day', text: mssgs.identify_error_enter_bday_min } - }, 'postcode': { 'string.empty': { ref: '#postcode', text: mssgs.identify_error_empty_postcode }, 'string.pattern.base': { ref: '#postcode', text: mssgs.identify_error_pattern_postcode } + }, + 'full-date': { + 'object.missing': { ref: '#date-of-birth-day', text: mssgs.dob_error } + }, + 'day-and-month': { + 'object.missing': { ref: '#date-of-birth-day', text: mssgs.dob_error_missing_day_and_month } + }, + 'day-and-year': { + 'object.missing': { ref: '#date-of-birth-day', text: mssgs.dob_error_missing_day_and_year } + }, + 'month-and-year': { + 'object.missing': { ref: '#date-of-birth-month', text: mssgs.dob_error_missing_month_and_year } + }, + 'day': { + 'any.required': { ref: '#date-of-birth-day', text: mssgs.dob_error_missing_day } + }, + 'month': { + 'any.required': { ref: '#date-of-birth-month', text: mssgs.dob_error_missing_month } + }, + 'year': { + 'any.required': { ref: '#date-of-birth-year', text: mssgs.dob_error_missing_year } + }, + 'non-numeric': { + 'number.base': { ref: '#date-of-birth-day', text: mssgs.dob_error_non_numeric } + }, + 'invalid-date': { + 'any.custom': { ref: '#date-of-birth-day', text: mssgs.dob_error_date_real } + }, + 'date-range': { + 'date.min': { ref: '#date-of-birth-day', text: mssgs.dob_error_year_min }, + 'date.max': { ref: '#date-of-birth-day', text: mssgs.dob_error_year_max } } } %} @@ -37,21 +63,21 @@ { label: mssgs.dob_day, name: "day", - classes: "govuk-input--width-2", + classes: "govuk-input--width-2 govuk-input--error" if data.isDayError else "govuk-input--width-2", value: payload['date-of-birth-day'], attributes: { maxlength : 2 } }, { label: mssgs.dob_month, name: "month", - classes: "govuk-input--width-2", + classes: "govuk-input--width-2 govuk-input--error" if data.isMonthError else "govuk-input--width-2", value: payload['date-of-birth-month'], attributes: { maxlength : 2 } }, { label: mssgs.dob_year, name: "year", - classes: "govuk-input--width-4", + classes: "govuk-input--width-4 govuk-input--error" if data.isYearError else "govuk-input--width-4", value: payload['date-of-birth-year'], attributes: { maxlength : 4 } } diff --git a/packages/gafl-webapp-service/src/pages/renewals/identify/route.js b/packages/gafl-webapp-service/src/pages/renewals/identify/route.js index a873c01e0f..234176ae1c 100644 --- a/packages/gafl-webapp-service/src/pages/renewals/identify/route.js +++ b/packages/gafl-webapp-service/src/pages/renewals/identify/route.js @@ -4,10 +4,12 @@ import Joi from 'joi' import { validation } from '@defra-fish/business-rules-lib' import { addLanguageCodeToUri } from '../../../processors/uri-helper.js' import GetDataRedirect from '../../../handlers/get-data-redirect.js' +import { dateOfBirthValidator, getDateErrorFlags } from '../../../schema/validators/validators.js' export const getData = async request => { // If we are supplied a permission number, validate it or throw 400 const permission = await request.cache().helpers.status.getCurrentPermission() + const page = await request.cache().helpers.page.getCurrentPermission(IDENTIFY.page) if (permission.referenceNumber) { const validatePermissionNumber = validation.permission @@ -23,25 +25,23 @@ export const getData = async request => { referenceNumber: permission.referenceNumber, uri: { new: addLanguageCodeToUri(request, NEW_TRANSACTION.uri) - } + }, + ...getDateErrorFlags(page?.error) } } -const schema = Joi.object({ - referenceNumber: validation.permission.permissionNumberUniqueComponentValidator(Joi), - 'date-of-birth': validation.contact.createBirthDateValidator(Joi), - postcode: validation.contact.createOverseasPostcodeValidator(Joi) -}).options({ abortEarly: false, allowUnknown: true }) +export const validator = payload => { + dateOfBirthValidator(payload) -const validator = async payload => { - const dateOfBirth = `${payload['date-of-birth-year']}-${payload['date-of-birth-month']}-${payload['date-of-birth-day']}` Joi.assert( { - 'date-of-birth': dateOfBirth, postcode: payload.postcode, referenceNumber: payload.referenceNumber }, - schema + Joi.object({ + referenceNumber: validation.permission.permissionNumberUniqueComponentValidator(Joi), + postcode: validation.contact.createOverseasPostcodeValidator(Joi) + }).options({ abortEarly: false }) ) } diff --git a/packages/gafl-webapp-service/src/schema/__tests__/__snapshots__/date.schema.test.js.snap b/packages/gafl-webapp-service/src/schema/__tests__/__snapshots__/date.schema.test.js.snap new file mode 100644 index 0000000000..3619fadce7 --- /dev/null +++ b/packages/gafl-webapp-service/src/schema/__tests__/__snapshots__/date.schema.test.js.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dateSchemaInput matches expected format 1`] = ` +Object { + "day": "1", + "day-and-month": Object { + "day": "1", + "month": "2", + }, + "day-and-year": Object { + "day": "1", + "year": "2023", + }, + "full-date": Object { + "day": "1", + "month": "2", + "year": "2023", + }, + "invalid-date": "2023-02-01", + "month": "2", + "month-and-year": Object { + "month": "2", + "year": "2023", + }, + "non-numeric": Object { + "day": "1", + "month": "2", + "year": "2023", + }, + "year": "2023", +} +`; diff --git a/packages/gafl-webapp-service/src/schema/__tests__/date.schema.test.js b/packages/gafl-webapp-service/src/schema/__tests__/date.schema.test.js new file mode 100644 index 0000000000..6312a65f0a --- /dev/null +++ b/packages/gafl-webapp-service/src/schema/__tests__/date.schema.test.js @@ -0,0 +1,64 @@ +import Joi from 'joi' +import { dateSchemaInput, dateSchema } from '../date.schema.js' + +describe('dateSchemaInput', () => { + it('matches expected format', () => { + expect(dateSchemaInput('1', '2', '2023')).toMatchSnapshot() + }) + + it.each` + desc | day | month | year | result + ${'all empty'} | ${''} | ${''} | ${''} | ${{ 'full-date': { day: undefined, month: undefined, year: undefined } }} + ${'day and month empty'} | ${''} | ${''} | ${'2020'} | ${{ 'day-and-month': { day: undefined, month: undefined } }} + ${'day and year empty'} | ${''} | ${'11'} | ${''} | ${{ 'day-and-year': { day: undefined, year: undefined } }} + ${'month and year empty'} | ${'12'} | ${''} | ${''} | ${{ 'month-and-year': { month: undefined, year: undefined } }} + ${'day empty'} | ${''} | ${'3'} | ${'2021'} | ${{ day: undefined }} + ${'month empty'} | ${'4'} | ${''} | ${'2003'} | ${{ month: undefined }} + ${'year empty'} | ${'15'} | ${'11'} | ${''} | ${{ year: undefined }} + `('maps empty strings to undefined values when $desc', ({ day, month, year, result }) => { + expect(dateSchemaInput(day, month, year)).toEqual(expect.objectContaining(result)) + }) +}) + +describe('dateSchema', () => { + it.each` + payload | expectedError | payloadDesc + ${{}} | ${'full-date'} | ${'empty day, month and year'} + ${{ year: '1' }} | ${'day-and-month'} | ${'empty day and month'} + ${{ month: '2' }} | ${'day-and-year'} | ${'empty day and year'} + ${{ day: '3' }} | ${'month-and-year'} | ${'empty month and year'} + ${{ month: '5', year: '2023' }} | ${'day'} | ${'empty day'} + ${{ day: '12', year: '2024' }} | ${'month'} | ${'empty month'} + ${{ day: '15', month: '3' }} | ${'year'} | ${'empty year'} + ${{ day: 'Ides', month: 'March', year: '44 B.C.' }} | ${'non-numeric.day'} | ${'non-numerics entered'} + ${{ day: 'Thirteenth', month: '11', year: '1978' }} | ${'non-numeric.day'} | ${'non-numeric day'} + ${{ day: '29', month: 'MAR', year: '2002' }} | ${'non-numeric.month'} | ${'non-numeric month '} + ${{ day: '13', month: '1', year: 'Two thousand and five' }} | ${'non-numeric.year'} | ${'non-numeric year'} + ${{ day: '30', month: '2', year: '1994' }} | ${'invalid-date'} | ${'an invalid date - 1994-02-40'} + ${{ day: '1', month: '13', year: '2022' }} | ${'invalid-date'} | ${'an invalid date - 2022-13-01'} + ${{ day: '29', month: '2', year: '2023' }} | ${'invalid-date'} | ${'an invalid date - 1994-02-40'} + ${{ day: '-1.15', month: '18', year: '22.2222' }} | ${'invalid-date'} | ${'an invalid date - 22.2222-18-1.15'} + `('Error has $expectedError in details when payload has $payloadDesc', ({ payload: { day, month, year }, expectedError }) => { + expect(() => { + Joi.assert(dateSchemaInput(day, month, year), dateSchema) + }).toThrow( + expect.objectContaining({ + details: expect.arrayContaining([ + expect.objectContaining({ + path: expectedError.split('.'), + context: expect.objectContaining({ + label: expectedError, + key: expectedError.split('.').pop() + }) + }) + ]) + }) + ) + }) + + it('valid date passes validation', () => { + expect(() => { + Joi.assert(dateSchemaInput('12', '10', '1987'), dateSchema) + }).not.toThrow() + }) +}) diff --git a/packages/gafl-webapp-service/src/schema/date.schema.js b/packages/gafl-webapp-service/src/schema/date.schema.js new file mode 100644 index 0000000000..fa0cade8e5 --- /dev/null +++ b/packages/gafl-webapp-service/src/schema/date.schema.js @@ -0,0 +1,63 @@ +'use strict' +import Joi from 'joi' + +export const dateSchemaInput = (unparsedDay, unparsedMonth, unparsedYear) => { + const day = unparsedDay === '' ? undefined : unparsedDay + const month = unparsedMonth === '' ? undefined : unparsedMonth + const year = unparsedYear === '' ? undefined : unparsedYear + + return { + 'full-date': { day, month, year }, + 'day-and-month': { day, month }, + 'day-and-year': { day, year }, + 'month-and-year': { month, year }, + day, + month, + year, + 'non-numeric': { day, month, year }, + 'invalid-date': `${year}-${(month || '').padStart(2, '0')}-${(day || '').padStart(2, '0')}` + } +} + +export const dateSchema = Joi.object({ + 'full-date': Joi.object() + .keys({ + day: Joi.any(), + month: Joi.any(), + year: Joi.any() + }) + .or('day', 'month', 'year'), + 'day-and-month': Joi.object() + .keys({ + day: Joi.any(), + month: Joi.any() + }) + .or('day', 'month'), + 'day-and-year': Joi.object() + .keys({ + day: Joi.any(), + year: Joi.any() + }) + .or('day', 'year'), + 'month-and-year': Joi.object() + .keys({ + month: Joi.any(), + year: Joi.any() + }) + .or('month', 'year'), + day: Joi.any().required(), + month: Joi.any().required(), + year: Joi.any().required(), + 'non-numeric': Joi.object().keys({ + day: Joi.number(), + month: Joi.number(), + year: Joi.number() + }), + 'invalid-date': Joi.custom((dateToValidate, helpers) => { + if (new Date(dateToValidate).toISOString() !== `${dateToValidate}T00:00:00.000Z`) { + throw helpers.error('invalid-date') + } + + return dateToValidate + }) +}).options({ abortEarly: true }) diff --git a/packages/gafl-webapp-service/src/schema/validators/__tests__/validators.spec.js b/packages/gafl-webapp-service/src/schema/validators/__tests__/validators.spec.js new file mode 100644 index 0000000000..95ef43e7c1 --- /dev/null +++ b/packages/gafl-webapp-service/src/schema/validators/__tests__/validators.spec.js @@ -0,0 +1,208 @@ +import Joi from 'joi' +import { dateOfBirthValidator, startDateValidator, getDateErrorFlags } from '../validators.js' +import moment from 'moment-timezone' +const dateSchema = require('../../date.schema.js') + +const setupMocks = () => { + Joi.originalAssert = Joi.assert + dateSchema.originalDateSchema = dateSchema.dateSchema + dateSchema.originalDateSchemaInput = dateSchema.dateSchemaInput + + Joi.assert = jest.fn() + dateSchema.dateSchema = Symbol('dateSchema') + dateSchema.dateSchemaInput = jest.fn() +} + +const tearDownMocks = () => { + Joi.assert = Joi.originalAssert + dateSchema.dateSchema = dateSchema.originalDateSchema + dateSchema.dateSchemaInput = dateSchema.originalDateSchemaInput +} + +describe('dateOfBirth validator', () => { + beforeEach(jest.clearAllMocks) + + const getSamplePayload = ({ day = '', month = '', year = '' } = {}) => ({ + 'date-of-birth-day': day, + 'date-of-birth-month': month, + 'date-of-birth-year': year + }) + + it('throws an error for anyone over 120 years old', () => { + const invalidDoB = moment().subtract(120, 'years').subtract(1, 'day') + const samplePayload = getSamplePayload({ + day: invalidDoB.format('DD'), + month: invalidDoB.format('MM'), + year: invalidDoB.format('YYYY') + }) + expect(() => dateOfBirthValidator(samplePayload)).toThrow() + }) + + it('validates for anyone 120 years old', () => { + const validDoB = moment().subtract(120, 'years') + const samplePayload = getSamplePayload({ + day: validDoB.format('DD'), + month: validDoB.format('MM'), + year: validDoB.format('YYYY') + }) + expect(() => dateOfBirthValidator(samplePayload)).not.toThrow() + }) + + it.each([ + ['today', moment()], + ['tomorrow', moment().add(1, 'day')], + ['in the future', moment().add(1, 'month')] + ])('throws an error for a date of birth %s', (_desc, invalidDoB) => { + const samplePayload = getSamplePayload({ + day: invalidDoB.format('DD'), + month: invalidDoB.format('MM'), + year: invalidDoB.format('YYYY') + }) + expect(() => dateOfBirthValidator(samplePayload)).toThrow() + }) + + it.each([ + ['1-3-2004', '1', '3', '2004'], + ['12-1-1999', '12', '1', '1999'], + ['1-12-2006', '1', '12', '2006'] + ])('handles single digit date %s', (_desc, day, month, year) => { + const samplePayload = getSamplePayload({ + day, + month, + year + }) + expect(() => dateOfBirthValidator(samplePayload)).not.toThrow() + }) + + it.each([ + ['01', '03', '1994'], + ['10', '12', '2004'] + ])('passes date of birth day (%s), month (%s) and year (%s) to dateSchemaInput', (day, month, year) => { + setupMocks() + dateOfBirthValidator(getSamplePayload({ day, month, year })) + expect(dateSchema.dateSchemaInput).toHaveBeenCalledWith(day, month, year) + tearDownMocks() + }) + + it('passes dateSchemaInput output and dateSchema to Joi.assert', () => { + setupMocks() + const dsi = Symbol('dsi') + dateSchema.dateSchemaInput.mockReturnValueOnce(dsi) + dateOfBirthValidator(getSamplePayload()) + expect(Joi.assert).toHaveBeenCalledWith(dsi, dateSchema.dateSchema) + tearDownMocks() + }) +}) + +describe('startDate validator', () => { + beforeEach(jest.clearAllMocks) + + const getSamplePayload = ({ day = '', month = '', year = '' } = {}) => ({ + 'licence-start-date-day': day, + 'licence-start-date-month': month, + 'licence-start-date-year': year, + 'licence-to-start': 'another-date' + }) + + it('throws an error for a licence starting before today', () => { + const invalidStartDate = moment().subtract(1, 'day') + const samplePayload = getSamplePayload({ + day: invalidStartDate.format('DD'), + month: invalidStartDate.format('MM'), + year: invalidStartDate.format('YYYY') + }) + expect(() => startDateValidator(samplePayload)).toThrow() + }) + + it('throws an error for a licence starting more than 30 days hence', () => { + const invalidStartDate = moment().add(31, 'days') + const samplePayload = getSamplePayload({ + day: invalidStartDate.format('DD'), + month: invalidStartDate.format('MM'), + year: invalidStartDate.format('YYYY') + }) + expect(() => startDateValidator(samplePayload)).toThrow() + }) + + it('validates for a date within the next 30 days', () => { + const validStartDate = moment().add(4, 'days') + const samplePayload = getSamplePayload({ + day: validStartDate.format('DD'), + month: validStartDate.format('MM'), + year: validStartDate.format('YYYY') + }) + expect(() => startDateValidator(samplePayload)).not.toThrow() + }) + + it.each([ + ['1-3-2024', moment('2024-02-28')], + ['9-7-2024', moment('2024-07-08')] + ])('handles single digit date %s', (date, now) => { + jest.useFakeTimers() + jest.setSystemTime(now.toDate()) + + const [day, month, year] = date.split('-') + const samplePayload = getSamplePayload({ + day, + month, + year + }) + expect(() => startDateValidator(samplePayload)).not.toThrow() + jest.useRealTimers() + }) + + it.each([ + ['01', '03', '1994'], + ['10', '12', '2004'] + ])('passes start date day (%s), month (%s) and year (%s) to dateSchemaInput', (day, month, year) => { + setupMocks() + startDateValidator(getSamplePayload({ day, month, year })) + expect(dateSchema.dateSchemaInput).toHaveBeenCalledWith(day, month, year) + tearDownMocks() + }) + + it('passes dateSchemaInput output and dateSchema to Joi.assert', () => { + setupMocks() + const dsi = Symbol('dsi') + dateSchema.dateSchemaInput.mockReturnValueOnce(dsi) + startDateValidator(getSamplePayload()) + expect(Joi.assert).toHaveBeenCalledWith(dsi, dateSchema.dateSchema) + tearDownMocks() + }) + + it('passes validation if licence is set to start after payment', () => { + const samplePayload = { 'licence-to-start': 'after-payment' } + expect(() => startDateValidator(samplePayload)).not.toThrow() + }) + + it('throws an error if licence-to-start is set to an invalid value', () => { + const samplePayload = { 'licence-to-start': '12th-of-never' } + expect(() => startDateValidator(samplePayload)).toThrow() + }) +}) + +describe('getErrorFlags', () => { + it('sets all error flags to be false when there are no errors', () => { + const result = getDateErrorFlags() + expect(result).toEqual({ isDayError: false, isMonthError: false, isYearError: false }) + }) + + it.each([ + ['full-date', { isDayError: true, isMonthError: true, isYearError: true }], + ['day-and-month', { isDayError: true, isMonthError: true, isYearError: false }], + ['month-and-year', { isDayError: false, isMonthError: true, isYearError: true }], + ['day-and-year', { isDayError: true, isMonthError: false, isYearError: true }], + ['day', { isDayError: true, isMonthError: false, isYearError: false }], + ['month', { isDayError: false, isMonthError: true, isYearError: false }], + ['year', { isDayError: false, isMonthError: false, isYearError: true }], + ['invalid-date', { isDayError: true, isMonthError: true, isYearError: true }], + ['date-range', { isDayError: true, isMonthError: true, isYearError: true }], + ['non-numeric', { isDayError: true, isMonthError: true, isYearError: true }] + ])('when error is %s, should set %o in flags', (errorKey, expected) => { + const error = { [errorKey]: 'anything.at.all' } + + const result = getDateErrorFlags(error) + + expect(result).toEqual(expect.objectContaining(expected)) + }) +}) diff --git a/packages/gafl-webapp-service/src/schema/validators/validators.js b/packages/gafl-webapp-service/src/schema/validators/validators.js new file mode 100644 index 0000000000..70fefe6d25 --- /dev/null +++ b/packages/gafl-webapp-service/src/schema/validators/validators.js @@ -0,0 +1,65 @@ +import Joi from 'joi' +import moment from 'moment' +import { ADVANCED_PURCHASE_MAX_DAYS, SERVICE_LOCAL_TIME } from '@defra-fish/business-rules-lib' +import { dateSchema, dateSchemaInput } from '../date.schema.js' + +const MAX_AGE = 120 +const LICENCE_TO_START_FIELD = 'licence-to-start' +const AFTER_PAYMENT = 'after-payment' +const ANOTHER_DATE = 'another-date' + +const validateDate = (day, month, year, minDate, maxDate) => { + Joi.assert(dateSchemaInput(day, month, year), dateSchema) + const dateRange = moment(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`, 'YYYY-MM-DD') + .tz(SERVICE_LOCAL_TIME) + .startOf('day') + .toDate() + Joi.assert({ 'date-range': dateRange }, Joi.object({ 'date-range': Joi.date().min(minDate).max(maxDate) })) +} + +export const dateOfBirthValidator = payload => { + const day = payload['date-of-birth-day'] + const month = payload['date-of-birth-month'] + const year = payload['date-of-birth-year'] + + const minDate = moment().tz(SERVICE_LOCAL_TIME).subtract(MAX_AGE, 'years').startOf('day').toDate() + const maxDate = moment().tz(SERVICE_LOCAL_TIME).subtract(1, 'day').startOf('day').toDate() + validateDate(day, month, year, minDate, maxDate) +} + +export const startDateValidator = payload => { + Joi.assert( + { 'licence-to-start': payload[LICENCE_TO_START_FIELD] }, + Joi.object({ 'licence-to-start': Joi.string().valid(AFTER_PAYMENT, ANOTHER_DATE).required() }) + ) + if (payload[LICENCE_TO_START_FIELD] === ANOTHER_DATE) { + const day = payload['licence-start-date-day'] + const month = payload['licence-start-date-month'] + const year = payload['licence-start-date-year'] + + const minDate = moment().tz(SERVICE_LOCAL_TIME).startOf('day').toDate() + const maxDate = moment().tz(SERVICE_LOCAL_TIME).add(ADVANCED_PURCHASE_MAX_DAYS, 'days').toDate() + validateDate(day, month, year, minDate, maxDate) + } +} + +export const getDateErrorFlags = error => { + const errorFlags = { isDayError: false, isMonthError: false, isYearError: false } + const commonErrors = ['full-date', 'invalid-date', 'date-range', 'non-numeric'] + + if (error) { + const [errorKey] = Object.keys(error) + + if (['day-and-month', 'day-and-year', 'day', ...commonErrors].includes(errorKey)) { + errorFlags.isDayError = true + } + if (['day-and-month', 'month-and-year', 'month', ...commonErrors].includes(errorKey)) { + errorFlags.isMonthError = true + } + if (['day-and-year', 'month-and-year', 'year', ...commonErrors].includes(errorKey)) { + errorFlags.isYearError = true + } + } + + return errorFlags +}