diff --git a/.changeset/fifty-wolves-repeat.md b/.changeset/fifty-wolves-repeat.md new file mode 100644 index 0000000..7cbdbbf --- /dev/null +++ b/.changeset/fifty-wolves-repeat.md @@ -0,0 +1,5 @@ +--- +"@form-wizard-framework/eslint-config": major +--- + +adds .jest.js files to also use the same eslint rules for test files diff --git a/.changeset/thick-vans-chew.md b/.changeset/thick-vans-chew.md new file mode 100644 index 0000000..49e799b --- /dev/null +++ b/.changeset/thick-vans-chew.md @@ -0,0 +1,5 @@ +--- +"@form-wizard-framework/address-lookup": patch +--- + +add support for Postcode API diff --git a/package-lock.json b/package-lock.json index e05c930..ffcefdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1303,6 +1303,10 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@form-wizard-framework/address-lookup": { + "resolved": "packages/adress-lookup", + "link": true + }, "node_modules/@form-wizard-framework/emailer": { "resolved": "packages/emailer", "link": true @@ -2457,6 +2461,11 @@ "node": ">=0.10.0" } }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -5415,6 +5424,12 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5551,6 +5566,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -5780,6 +5801,21 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/nock": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.0.tgz", + "integrity": "sha512-HHqYQ6mBeiMc+N038w8LkMpDCRquCHWeNmN3v6645P3NhN2+qXOBqvPqo7Rt1VyCMzKhJ733wZqw5B7cQVFNPg==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.21", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6307,6 +6343,15 @@ "node": ">= 6" } }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -7902,9 +7947,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/adress-lookup": { + "name": "@form-wizard-framework/address-lookup", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "debug": "^4.3.4", + "underscore": "^1.13.6" + }, + "devDependencies": { + "@form-wizard-framework/eslint-config": "^0.1.0", + "eslint": "^8.36.0", + "hmpo-model": "^4.3.0", + "jest": "^29.5.0", + "nock": "^13.3.0" + } + }, "packages/emailer": { "name": "@form-wizard-framework/emailer", - "version": "0.0.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -7933,7 +7995,7 @@ }, "packages/file-upload": { "name": "@form-wizard-framework/file-upload", - "version": "0.0.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "busboy-body-parser": "^0.3.2", @@ -8992,6 +9054,19 @@ "integrity": "sha512-lxJ9R5ygVm8ZWgYdUweoq5ownDlJ4upvoWmO4eLxBYHdMo+vZ/Rx0EN6MbKWDJOSUGrqJy2Gt+Dyv/VKml0fjg==", "dev": true }, + "@form-wizard-framework/address-lookup": { + "version": "file:packages/adress-lookup", + "requires": { + "@form-wizard-framework/eslint-config": "^0.1.0", + "async": "^3.2.4", + "debug": "^4.3.4", + "eslint": "^8.36.0", + "hmpo-model": "^4.3.0", + "jest": "^29.5.0", + "nock": "^13.3.0", + "underscore": "^1.13.6" + } + }, "@form-wizard-framework/emailer": { "version": "file:packages/emailer", "requires": { @@ -9924,6 +9999,11 @@ "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", "dev": true }, + "async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, "available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -12130,6 +12210,12 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -12235,6 +12321,12 @@ "p-locate": "^5.0.0" } }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, "lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -12411,6 +12503,18 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "nock": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.0.tgz", + "integrity": "sha512-HHqYQ6mBeiMc+N038w8LkMpDCRquCHWeNmN3v6645P3NhN2+qXOBqvPqo7Rt1VyCMzKhJ733wZqw5B7cQVFNPg==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.21", + "propagate": "^2.0.0" + } + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -12797,6 +12901,12 @@ "sisteransi": "^1.0.5" } }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", diff --git a/packages/adress-lookup/.eslintrc.js b/packages/adress-lookup/.eslintrc.js new file mode 100644 index 0000000..5114c82 --- /dev/null +++ b/packages/adress-lookup/.eslintrc.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = { + extends: ['@form-wizard-framework/eslint-config'], +}; diff --git a/packages/adress-lookup/index.js b/packages/adress-lookup/index.js new file mode 100644 index 0000000..d75dfb3 --- /dev/null +++ b/packages/adress-lookup/index.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = { + nl: require('./lib/nl'), +}; diff --git a/packages/adress-lookup/lib/nl/index.js b/packages/adress-lookup/lib/nl/index.js new file mode 100644 index 0000000..5521a3f --- /dev/null +++ b/packages/adress-lookup/lib/nl/index.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = { + validators: require('./validators'), + postcodeApi: require('./postcode-api'), +}; diff --git a/packages/adress-lookup/lib/nl/postcode-api/index.js b/packages/adress-lookup/lib/nl/postcode-api/index.js new file mode 100644 index 0000000..6f6a7f3 --- /dev/null +++ b/packages/adress-lookup/lib/nl/postcode-api/index.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = { + mixin: require('./mixin'), + model: require('./model'), +}; diff --git a/packages/adress-lookup/lib/nl/postcode-api/mixin.js b/packages/adress-lookup/lib/nl/postcode-api/mixin.js new file mode 100644 index 0000000..2754a31 --- /dev/null +++ b/packages/adress-lookup/lib/nl/postcode-api/mixin.js @@ -0,0 +1,296 @@ +'use strict'; + +const _ = require('underscore'); +const async = require('async'); +const PostcodeApiModel = require('./model'); +const validators = require('../validators'); + +const POSTCODE_PART_KEY = 'postcode'; +const NUMBER_PART_KEY = 'number'; +const EXTENSION_PART_KEY = 'extension'; +const ADDRESS_LOOKUP_PARTS = [ + POSTCODE_PART_KEY, + NUMBER_PART_KEY, + EXTENSION_PART_KEY, +]; + +module.exports = (Controller) => + class extends Controller { + configure(req, res, next) { + const modelConfig = req.form.options.addressLookup; + + if (!modelConfig) { + throw new Error( + 'Configuration for the postcode lookup model must be supplied' + ); + } + + if (!modelConfig.url) { + throw new Error( + 'Base url for the postcode lookup api must be supplied' + ); + } + + if (!modelConfig.apiKey) { + throw new Error( + 'An API key for the postcode lookup api must be supplied' + ); + } + + req.form.options.addressFields = _.keys( + _.pick( + req.form.options.fields, + (field) => + field.validate === 'postcode-lookup' || + _.contains(field.validate, 'postcode-lookup') + ) + ); + + _.forEach(req.form.options.addressFields, (fieldName) => + this.configureLookupField(req, fieldName) + ); + + req.postcodeApiModel = new PostcodeApiModel({}, modelConfig); + + super.configure(req, res, next); + } + + configureLookupField(req, fieldName) { + const addressField = req.form.options.fields[fieldName]; + const isRequired = _.contains(addressField.validate, 'required'); + + if (Array.isArray(addressField.validate)) { + addressField.validate = addressField.validate.filter( + (x) => x !== 'postcode-lookup' + ); + } + + if (addressField.validate === 'postcode-lookup') { + delete addressField.validate; + } + + ADDRESS_LOOKUP_PARTS.forEach((part) => { + let field = req.form.options.fields[fieldName + '-' + part]; + + field = _.extend( + { + errorGroup: fieldName + '-' + part, + hintId: fieldName + '-hint', + contentKey: fieldName + '-' + part, + autocomplete: + addressField.autocomplete && + (addressField.autocomplete === 'off' + ? 'off' + : addressField.autocomplete + '-' + part), + dependent: addressField.dependent, + labelClassName: 'form-label', + }, + field + ); + + if (!_.isArray(field.validate)) field.validate = [field.validate]; + + if (part === 'postcode') { + field.validate.unshift(validators.postcode); + } + + // only make part required if date field is required, but not if it is an address extension + if (isRequired && part !== 'extension') + field.validate.unshift('required'); + + field.validate = field.validate.filter( + (validation) => validation != null + ); + + req.form.options.fields[fieldName + '-' + part] = field; + }); + } + + getValues(req, res, callback) { + super.getValues(req, res, (err, values) => { + if (err) return callback(err); + let errorValues = req.sessionModel.get('errorValues') || {}; + req.form.options.addressFields.forEach((fieldName) => { + if (!values[fieldName]) return; + + ADDRESS_LOOKUP_PARTS.forEach((part) => { + values[fieldName + '-' + part] = + errorValues[fieldName + '-' + part] || values[fieldName][part]; + if (values[fieldName + '-' + part] == null) { + delete values[fieldName + '-' + part]; + } + }); + + if (req.form.options.addressLookup.concatenateExtension) { + const extensionKey = fieldName + '-' + EXTENSION_PART_KEY; + const numberKey = fieldName + '-' + NUMBER_PART_KEY; + + values[numberKey] = values[numberKey] + values[extensionKey]; + + delete values[extensionKey]; + } + }); + callback(null, values); + }); + } + + process(req, res, next) { + _.forEach(req.form.options.addressFields, (fieldName) => + this.processAddressField(req, fieldName) + ); + super.process(req, res, next); + } + + processAddressField(req, fieldName) { + let body = req.form.values; + body[fieldName] = Object.assign({}, body.fieldName); + + ADDRESS_LOOKUP_PARTS.forEach((part) => { + const partFieldName = fieldName + '-' + part; + + if (part === POSTCODE_PART_KEY) { + body[partFieldName] = this._processPostcode(body[partFieldName]); + } + + if (part === NUMBER_PART_KEY) { + const { number, extension } = this._processNumberAndExtension( + body[partFieldName] + ); + body[partFieldName] = number; + + if (!_.isEmpty(extension)) { + const extensionKey = fieldName + '-' + EXTENSION_PART_KEY; + body[extensionKey] = extension; + } + } + + if (body[partFieldName]) { + body[fieldName][part] = body[partFieldName]; + } + }); + } + + validateFields(req, res, callback) { + super.validateFields(req, res, (errors) => { + async.forEach( + req.form.options.addressFields, + (fieldName, cb) => { + this.validateAddressField(req, fieldName, errors, cb); + }, + () => { + callback(errors); + } + ); + }); + } + + validateAddressField(req, fieldName, errors, callback) { + const possibleErrorKeys = ADDRESS_LOOKUP_PARTS.map( + (part) => fieldName + '-' + part + ); + + const requiredErrors = _.pick( + errors, + (error, key) => + error.type === 'required' && possibleErrorKeys.includes(key) + ); + + const postcodeErrors = _.pick( + errors, + (error, key) => + error.type === 'postcode' && possibleErrorKeys.includes(key) + ); + + if (!_.isEmpty(requiredErrors) || !_.isEmpty(postcodeErrors)) { + callback(); + return; + } + + const values = req.form.values[fieldName]; + + if (!values) { + callback(); + return; + } + + req.postcodeApiModel.set(values); + req.postcodeApiModel.fetch((err, data) => { + if (err) { + errors[fieldName] = this._parseError(req, fieldName, err); + ADDRESS_LOOKUP_PARTS.forEach((part) => { + if (part !== 'extension') { + errors[fieldName + '-' + part] = this._parseError( + req, + fieldName, + err + ); + } + }); + callback(); + return; + } + this._saveResults(req, fieldName, data); + callback(); + }); + } + + _parseError(req, fieldName, fetchError) { + const status = fetchError.status; + + let type = 'unknownError'; + + if (status == 404) { + type = 'notFound'; + } + + if (status == 400) { + type = 'invalidFormat'; + } + + return new this.Error( + fieldName, + { + type, + field: fieldName + '-' + 'postcode', + errorGroup: fieldName, + }, + req + ); + } + + _saveResults(req, fieldName, values) { + req.form.values[fieldName] = Object.assign( + req.form.values[fieldName], + values + ); + } + + _processPostcode(value) { + return value ? value.replace(' ', '').toUpperCase() : ''; + } + + _processNumberAndExtension(value) { + const numberAndExtension = value.match(/^(\d+)(\D.*)?$/); + + if (!numberAndExtension) { + return { + number: value, + extension: '', + }; + } + + return { + number: numberAndExtension[1], + extension: numberAndExtension[2] || '', + }; + } + + saveValues(req, res, next) { + _.forEach(req.form.options.addressFields, (fieldName) => { + ADDRESS_LOOKUP_PARTS.forEach((part) => { + delete req.form.values[fieldName + '-' + part]; + }); + }); + super.saveValues(req, res, next); + } + }; diff --git a/packages/adress-lookup/lib/nl/postcode-api/model.js b/packages/adress-lookup/lib/nl/postcode-api/model.js new file mode 100644 index 0000000..d0ccc2e --- /dev/null +++ b/packages/adress-lookup/lib/nl/postcode-api/model.js @@ -0,0 +1,40 @@ +'use strict'; + +const HmpoModel = require('hmpo-model'); + +class PostcodeApiModel extends HmpoModel { + constructor(attributes, options) { + if (!options || typeof options !== 'object') { + throw new Error('Postcode lookup model: options must be provided'); + } + + if (!options.url) { + throw new Error( + 'Postcode lookup model: an url must be provided as options' + ); + } + + if (!options.apiKey && !(options.headers && options.headers['X-Api-Key'])) { + throw new Error( + 'Postcode lookup model: an api key must be provided as options (apiKey) or as header (X-Api-Key)' + ); + } + + if (options.apiKey) { + options.headers = Object.assign({}, options.headers, { + 'X-Api-Key': options.apiKey, + }); + } + + super(attributes, options); + } + + url() { + const postcode = this.get('postcode'); + const number = this.get('number'); + + return this.options.url + '/' + postcode + '/' + number; + } +} + +module.exports = PostcodeApiModel; diff --git a/packages/adress-lookup/lib/nl/validators.js b/packages/adress-lookup/lib/nl/validators.js new file mode 100644 index 0000000..b75a0d4 --- /dev/null +++ b/packages/adress-lookup/lib/nl/validators.js @@ -0,0 +1,10 @@ +'use strict'; + +const validators = { + postcode(value) { + let regex = /^[1-9][0-9]{3}[\s]?[A-Za-z]{2}$/i; + return regex.test(value); + }, +}; + +module.exports = validators; diff --git a/packages/adress-lookup/package.json b/packages/adress-lookup/package.json new file mode 100644 index 0000000..4560c83 --- /dev/null +++ b/packages/adress-lookup/package.json @@ -0,0 +1,24 @@ +{ + "name": "@form-wizard-framework/address-lookup", + "version": "1.0.0", + "main": "index.js", + "author": "Pandu Supriyono ", + "scripts": { + "test": "jest test --coverage --setupFiles ./test/setup.jest.js", + "test:watch": "npm run test -- --watchAll --coverage", + "lint": "eslint ." + }, + "license": "MIT", + "devDependencies": { + "@form-wizard-framework/eslint-config": "^0.1.0", + "eslint": "^8.36.0", + "hmpo-model": "^4.3.0", + "jest": "^29.5.0", + "nock": "^13.3.0" + }, + "dependencies": { + "async": "^3.2.4", + "debug": "^4.3.4", + "underscore": "^1.13.6" + } +} diff --git a/packages/adress-lookup/test/lib/nl/postcode-api/mixin.spec.js b/packages/adress-lookup/test/lib/nl/postcode-api/mixin.spec.js new file mode 100644 index 0000000..68efcb5 --- /dev/null +++ b/packages/adress-lookup/test/lib/nl/postcode-api/mixin.spec.js @@ -0,0 +1,795 @@ +'use strict'; + +const addressLookupMixin = require('../../../../lib/nl/postcode-api/mixin'); +const _ = require('underscore'); +const PostcodeApiModel = require('../../../../lib/nl/postcode-api/model'); +const validators = require('../../../../lib/nl/validators'); + +describe('Postcode API address lookup mixin', () => { + let BaseController, Controller, instance; + let req, res, next; + let options; + + beforeEach(() => { + options = { + route: '/index', + template: 'index', + addressLookup: { + apiKey: '123', + url: 'http://mock.test', + }, + fields: { + address1: { + autocomplete: 'myaddress', + validate: ['required', 'postcode-lookup'], + }, + address2: { + autocomplete: 'off', + validate: 'postcode-lookup', + }, + 'address2-number': { + autocomplete: 'mycomplete', + validate: 'part-validator', + }, + }, + }; + + req = { + form: { + options, + }, + sessionModel: { + get: jest.fn(), + }, + }; + res = {}; + next = jest.fn(); + BaseController = class {}; + Controller = addressLookupMixin(BaseController); + instance = new Controller(); + instance.Error = class { + constructor(key, options) { + this.key = key; + _.extend(this, options); + } + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('exports a function', () => { + expect(typeof addressLookupMixin).toBe('function'); + }); + + it('should extend the base controller', () => { + expect(instance).toBeInstanceOf(BaseController); + }); + + describe('configure', () => { + beforeEach(() => { + BaseController.prototype.configure = jest.fn(); + instance.configureLookupField.prototype = jest.fn(); + }); + + it('should set a list of address field pertaining to the lookup', () => { + instance.configure(req, res, next); + expect(options.addressFields).toEqual(['address1', 'address2']); + }); + + it('should configure address fields on ecah address field', () => { + const spy = jest.spyOn(instance, 'configureLookupField'); + instance.configure(req, res, next); + expect(spy).toHaveBeenNthCalledWith(1, req, 'address1'); + expect(spy).toHaveBeenNthCalledWith(2, req, 'address2'); + }); + + it('should call the super configure method', () => { + instance.configure(req, res, next); + expect(BaseController.prototype.configure).toHaveBeenCalledWith( + req, + res, + next + ); + }); + + it('should throw if a model config is not supplied', () => { + delete req.form.options.addressLookup; + expect(() => instance.configure(req, res, next)).toThrow( + 'Configuration for the postcode lookup model must be supplied' + ); + }); + + it('should throw if a model url is not supplied', () => { + delete req.form.options.addressLookup.url; + expect(() => instance.configure(req, res, next)).toThrow( + 'Base url for the postcode lookup api must be supplied' + ); + }); + + it('should throw if a model api key is not supplied', () => { + delete req.form.options.addressLookup.apiKey; + expect(() => instance.configure(req, res, next)).toThrow( + 'An API key for the postcode lookup api must be supplied' + ); + }); + + it('should set up a postcode lookup model', () => { + instance.configure(req, res, next); + expect(req.postcodeApiModel).toBeInstanceOf(PostcodeApiModel); + }); + }); + + describe('configureLookupField', () => { + it('should add lookup parts for the lookup field', () => { + instance.configureLookupField(req, 'address1'); + const fields = _.keys(req.form.options.fields); + expect(fields).toEqual( + expect.arrayContaining([ + 'address1-postcode', + 'address1-number', + 'address1-extension', + ]) + ); + }); + + it('should add a postcode validator to the postcode part', () => { + instance.configureLookupField(req, 'address2'); + const postcodeField = req.form.options.fields['address2-postcode']; + expect(postcodeField.validate).toEqual( + expect.arrayContaining([validators.postcode]) + ); + }); + + it('should not add a postcode validator to the non-postcode parts', () => { + instance.configureLookupField(req, 'address2'); + const numberField = req.form.options.fields['address2-number']; + expect(numberField.validate).not.toEqual( + expect.arrayContaining([instance.postcode]) + ); + }); + + it('should prepend required validator if the address field is required', () => { + instance.configureLookupField(req, 'address1'); + const postcodeField = req.form.options.fields['address1-postcode']; + expect(postcodeField.validate[0]).toEqual('required'); + }); + + it('should never require the address extension to be required', () => { + instance.configureLookupField(req, 'address1'); + const extensionField = req.form.options.fields['address1-extension']; + expect(extensionField.validate[0]).not.toEqual( + expect.arrayContaining(['required']) + ); + }); + + it('should add the relevant error group to the address fields', () => { + instance.configureLookupField(req, 'address1'); + const parts = ['postcode', 'number', 'extension']; + const errorGroups = parts.map( + (part) => req.form.options.fields['address1-' + part].errorGroup + ); + expect(errorGroups).toEqual([ + 'address1-postcode', + 'address1-number', + 'address1-extension', + ]); + }); + + it('should set autocomplete values of the parent address field', () => { + instance.configureLookupField(req, 'address1'); + expect(req.form.options.fields['address1-postcode'].autocomplete).toEqual( + 'myaddress-postcode' + ); + expect(req.form.options.fields['address1-number'].autocomplete).toEqual( + 'myaddress-number' + ); + expect( + req.form.options.fields['address1-extension'].autocomplete + ).toEqual('myaddress-extension'); + }); + + it('should override autocomplete values for specific parts when set', () => { + instance.configureLookupField(req, 'address2'); + expect(req.form.options.fields['address2-postcode'].autocomplete).toEqual( + 'off' + ); + expect(req.form.options.fields['address2-number'].autocomplete).toEqual( + 'mycomplete' + ); + expect( + req.form.options.fields['address2-extension'].autocomplete + ).toEqual('off'); + }); + + it('should not populate autcomplete if not specified', () => { + delete options.fields.address1.autocomplete; + instance.configureLookupField(req, 'address1'); + expect( + req.form.options.fields['address1-postcode'].autocomplete + ).toBeUndefined(); + expect( + req.form.options.fields['address1-number'].autocomplete + ).toBeUndefined(); + expect( + req.form.options.fields['address1-extension'].autocomplete + ).toBeUndefined(); + }); + + it('should delete the postcode-lookup validation key from the parent validator if it is an array', () => { + instance.configureLookupField(req, 'address1'); + + expect(req.form.options.fields.address1.validate).toEqual(['required']); + }); + + it('should delete the postcode-lookup validation key from the parent validator if it a string', () => { + instance.configureLookupField(req, 'address2'); + + expect(req.form.options.fields.address2.validate).toBeUndefined(); + }); + }); + + describe('getValues', () => { + beforeEach(() => { + options.addressFields = ['address1', 'address2']; + BaseController.prototype.getValues = jest + .fn() + .mockImplementation((req, res, cb) => { + cb(null, { + address1: { + postcode: '2517KC', + number: '8', + }, + }); + }); + }); + + it('should have the lookup parts get the values out of the address field', () => { + return new Promise((resolve, reject) => { + instance.getValues(req, res, (err, values) => { + if (err) { + reject(err); + } + try { + expect(values).toEqual({ + 'address1-number': '8', + 'address1-postcode': '2517KC', + address1: { + number: '8', + postcode: '2517KC', + }, + }); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('should have the lookup parts get values out of the individual part fields first', () => { + req.sessionModel.get = jest.fn().mockReturnValue({ + 'address1-number': '70', + 'address1-postcode': '2511BT', + }); + return new Promise((resolve, reject) => { + instance.getValues(req, res, (err, values) => { + if (err) { + reject(err); + } + try { + expect(values).toEqual({ + 'address1-number': '70', + 'address1-postcode': '2511BT', + address1: { + number: '8', + postcode: '2517KC', + }, + }); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('should populate blank values if the value does not exist', () => { + BaseController.prototype.getValues = jest + .fn() + .mockImplementation((req, res, cb) => { + cb(null, {}); + }); + return new Promise((resolve, reject) => { + instance.getValues(req, res, (err, values) => { + if (err) { + reject(err); + } + try { + expect(values).toEqual({}); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('can concatenate the number with extension', () => { + BaseController.prototype.getValues = jest + .fn() + .mockImplementation((req, res, cb) => { + cb(null, { + address1: { + postcode: '2517KC', + number: '8', + extension: '-b', + }, + }); + }); + + req.form.options.addressLookup.concatenateExtension = true; + + return new Promise((resolve, reject) => { + instance.getValues(req, res, (err, values) => { + if (err) { + reject(err); + } + try { + expect(values).toEqual({ + 'address1-number': '8-b', + 'address1-postcode': '2517KC', + address1: { + number: '8', + postcode: '2517KC', + extension: '-b', + }, + }); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + }); + + describe('process', () => { + beforeEach(() => { + options.addressFields = ['address1', 'address2']; + instance.processAddressField = jest.fn(); + BaseController.prototype.process = jest.fn(); + }); + + it('should run processAddressField for each date field', () => { + instance.process(req, res, next); + expect(instance.processAddressField).toHaveBeenCalledTimes(2); + expect(instance.processAddressField).toHaveBeenNthCalledWith( + 1, + req, + 'address1' + ); + expect(instance.processAddressField).toHaveBeenNthCalledWith( + 2, + req, + 'address2' + ); + }); + + it('should call the super process method', () => { + instance.process(req, res, next); + expect(BaseController.prototype.process).toHaveBeenCalledWith( + req, + res, + next + ); + }); + }); + + describe('processAddressField', () => { + beforeEach(() => { + req.form.values = { + 'address1-postcode': '2517KC', + 'address1-number': '8', + }; + }); + + it('should use separate input fields for postcode and number', () => { + instance.processAddressField(req, 'address1'); + expect(req.form.values['address1']).toEqual({ + number: '8', + postcode: '2517KC', + }); + }); + + it('should process postcodes', () => { + req.form.values['addres1-postcode'] = '2517 kc'; + instance.processAddressField(req, 'address1'); + expect(req.form.values['address1']).toEqual({ + number: '8', + postcode: '2517KC', + }); + }); + + it('can split numbers from extensions', () => { + req.form.values = { + 'address1-postcode': '2517KC', + 'address1-number': '8a', + }; + + instance.processAddressField(req, 'address1'); + expect(req.form.values['address1']).toEqual({ + number: '8', + extension: 'a', + postcode: '2517KC', + }); + }); + + it('prioritizes parsed extensions', () => { + req.form.values = { + 'address1-postcode': '2517KC', + 'address1-number': '8a', + 'address1-extension': 'b', + }; + + instance.processAddressField(req, 'address1'); + expect(req.form.values['address1']).toEqual({ + number: '8', + extension: 'a', + postcode: '2517KC', + }); + }); + }); + + describe('validateFields', () => { + let errors; + + beforeEach(() => { + errors = {}; + options.addressFields = ['address1', 'address2']; + BaseController.prototype.validateFields = jest + .fn() + .mockImplementation((req, res, cb) => { + cb(errors); + }); + instance.validateAddressField = jest.fn(); + }); + + it('should call the super validateFields method', () => { + instance.validateFields(req, res, next); + expect(BaseController.prototype.validateFields).toHaveBeenCalledWith( + req, + res, + expect.any(Function) + ); + }); + + it('should call validateAddressField for each address field', () => { + instance.validateFields(req, res, next); + expect(instance.validateAddressField).toHaveBeenNthCalledWith( + 1, + req, + 'address1', + {}, + expect.any(Function) + ); + expect(instance.validateAddressField).toHaveBeenNthCalledWith( + 2, + req, + 'address2', + {}, + expect.any(Function) + ); + }); + + it('calls the callback when all address fields have been iterated', () => { + instance.validateFields(req, res, (fieldErrors) => { + expect(instance.validateAddressField).toHaveBeenCalledTimes(2); + expect(fieldErrors).toEqual(errors); + }); + }); + }); + + describe('validateAddressField', () => { + beforeEach(() => { + req.form.values = { + address1: { + postcode: '2517KC', + number: '8', + extension: 'B', + }, + }; + + req.postcodeApiModel = { + get: jest.fn(), + set: jest.fn(), + fetch: jest.fn().mockImplementation((cb) => cb(null)), + }; + }); + + it('calls the postcode lookup model', () => { + return new Promise((resolve, reject) => { + instance.validateAddressField(req, 'address1', {}, () => { + try { + expect(req.postcodeApiModel.set).toHaveBeenCalledWith({ + postcode: '2517KC', + number: '8', + extension: 'B', + }); + + expect(req.postcodeApiModel.fetch).toHaveBeenCalled(); + + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('sets the address form value as the api call result', () => { + const apiResult = { + postcode: '1234AB', + number: 8, + street: 'Kerkstraat', + city: 'Den Haag', + municipality: 'Den Haag', + province: 'Zuid-Holland', + }; + + req.postcodeApiModel.fetch = jest.fn().mockImplementation((cb) => { + cb(null, apiResult); + }); + + return new Promise((resolve, reject) => { + instance.validateAddressField(req, 'address1', {}, () => { + try { + expect(req.form.values.address1).toEqual( + Object.assign(apiResult, { + extension: 'B', + }) + ); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('parses api not found errors', () => { + req.postcodeApiModel.fetch = jest.fn().mockImplementation((cb) => { + cb({ + status: 404, + }); + }); + + const errors = {}; + + return new Promise((resolve, reject) => { + instance.validateAddressField(req, 'address1', errors, () => { + try { + expect(errors).toEqual( + expect.objectContaining({ + address1: { + key: 'address1', + type: 'notFound', + field: 'address1-postcode', + errorGroup: 'address1', + }, + }) + ); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('parses api invalid format errors', () => { + req.postcodeApiModel.fetch = jest.fn().mockImplementation((cb) => { + cb({ + status: 400, + }); + }); + + const errors = {}; + + return new Promise((resolve, reject) => { + instance.validateAddressField(req, 'address1', errors, () => { + try { + expect(errors).toEqual( + expect.objectContaining({ + address1: { + key: 'address1', + type: 'invalidFormat', + field: 'address1-postcode', + errorGroup: 'address1', + }, + }) + ); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('parses api unknown errors', () => { + req.postcodeApiModel.fetch = jest.fn().mockImplementation((cb) => { + cb({ + status: 500, + }); + }); + + const errors = {}; + + return new Promise((resolve, reject) => { + instance.validateAddressField(req, 'address1', errors, () => { + try { + expect(errors).toEqual( + expect.objectContaining({ + address1: { + key: 'address1', + type: 'unknownError', + field: 'address1-postcode', + errorGroup: 'address1', + }, + }) + ); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('adds an error key to every address part except address extension', () => { + req.postcodeApiModel.fetch = jest.fn().mockImplementation((cb) => { + cb({ + status: 500, + }); + }); + + const errors = {}; + + return new Promise((resolve, reject) => { + instance.validateAddressField(req, 'address1', errors, () => { + try { + const errorKeys = _.keys(errors); + expect(errorKeys).toEqual( + expect.arrayContaining([ + 'address1', + 'address1-postcode', + 'address1-number', + ]) + ); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('short circuits if there are no values', () => { + req.form.values.address1 = undefined; + + const errors = {}; + + return new Promise((resolve, reject) => { + instance.validateAddressField(req, 'address1', errors, () => { + try { + expect(req.postcodeApiModel.fetch).not.toHaveBeenCalled(); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('short circuits if required errors are found', () => { + const errors = { + 'address1-postcode': { + type: 'required', + }, + }; + + return new Promise((resolve, reject) => { + instance.validateAddressField(req, 'address1', errors, () => { + try { + expect(req.postcodeApiModel.fetch).not.toHaveBeenCalled(); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + + it('does not short circuit if the required errors are not related to the address field', () => { + const errors = { + unrelated: { + type: 'required', + }, + }; + + return new Promise((resolve, reject) => { + instance.validateAddressField(req, 'address1', errors, () => { + try { + expect(req.postcodeApiModel.fetch).toHaveBeenCalled(); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + }); + + describe('saveValues', () => { + beforeEach(() => { + BaseController.prototype.saveValues = jest.fn(); + options.addressFields = ['address1', 'address2']; + + req.form.values = { + address1: { + postcode: '6545CA', + number: 29, + street: 'Waldeck Pyrmontsingel', + city: 'Nijmegen', + municipality: 'Nijmegen', + province: 'Gelderland', + }, + 'address1-postcode': '6545CA', + 'address1-number': '29', + address2: { + postcode: '1021JT', + number: 19, + street: 'Hamerstraat', + city: 'Amsterdam', + municipality: 'Amsterdam', + province: 'Noord-Holland', + }, + other: 'value', + }; + }); + + it('removes the part values', () => { + instance.saveValues(req, res, next); + expect(req.form.values).toEqual({ + address1: { + postcode: '6545CA', + number: 29, + street: 'Waldeck Pyrmontsingel', + city: 'Nijmegen', + municipality: 'Nijmegen', + province: 'Gelderland', + }, + address2: { + postcode: '1021JT', + number: 19, + street: 'Hamerstraat', + city: 'Amsterdam', + municipality: 'Amsterdam', + province: 'Noord-Holland', + }, + other: 'value', + }); + }); + + it('should call the super saveValues method', () => { + instance.saveValues(req, res, next); + expect(BaseController.prototype.saveValues).toHaveBeenCalledWith( + req, + res, + next + ); + }); + }); +}); diff --git a/packages/adress-lookup/test/lib/nl/postcode-api/model.spec.js b/packages/adress-lookup/test/lib/nl/postcode-api/model.spec.js new file mode 100644 index 0000000..b9032fe --- /dev/null +++ b/packages/adress-lookup/test/lib/nl/postcode-api/model.spec.js @@ -0,0 +1,99 @@ +'use strict'; + +const PostcodeLookupModel = require('../../../../lib/nl/postcode-api/model'); +const HmpoRemoteModel = require('hmpo-model'); +const nock = require('nock'); + +describe('postcode lookup model', () => { + let model; + + const mockBaseUrl = 'http://mock.test'; + + const defaultTestOptions = { + url: mockBaseUrl, + apiKey: 'apiKey', + }; + + const okResponse = { + postcode: '6545CA', + number: 29, + street: 'Binderskampweg', + city: 'Nijmegen', + municipality: 'Nijmegen', + province: 'Gelderland', + location: { + type: 'Point', + coordinates: [5.858910083770752, 51.84376540294041], + }, + }; + + beforeEach(() => { + model = new PostcodeLookupModel({}, defaultTestOptions); + PostcodeLookupModel.prototype.setLogger = jest.fn(); + + nock(mockBaseUrl).get('/6545CA/29').reply(200, okResponse); + }); + + afterEach(() => { + jest.clearAllMocks(); + nock.cleanAll(); + }); + + it('should be an instance of RemoteModel', () => { + model = new PostcodeLookupModel({}, defaultTestOptions); + expect(model).toBeInstanceOf(HmpoRemoteModel); + }); + + it('should throw an error if no options are provided', () => { + expect(() => new PostcodeLookupModel({})).toThrow( + 'Postcode lookup model: options must be provided' + ); + }); + + it('should throw an error if no url is provided in options', () => { + expect(() => new PostcodeLookupModel({}, { apiKey: 'apiKey' })).toThrow( + 'Postcode lookup model: an url must be provided as options' + ); + }); + + it('should throw an error if no api key is provided in options or header', () => { + expect(() => new PostcodeLookupModel({}, { url: mockBaseUrl })).toThrow( + 'Postcode lookup model: an api key must be provided as options (apiKey) or as header (X-Api-Key)' + ); + }); + + it('should not throw if an api key is provied as header', () => { + expect( + () => + new PostcodeLookupModel( + {}, + { + url: mockBaseUrl, + headers: { + 'X-Api-Key': 'apiKey', + }, + } + ) + ).not.toThrow(); + }); + + it('should fetch the API successfully', () => { + return new Promise((resolve, reject) => { + model.set('postcode', '6545CA'); + model.set('number', 29); + + model.fetch((err, data) => { + if (err) { + reject(err); + return; + } + try { + expect(data).toStrictEqual(okResponse); + resolve(); + } catch (err) { + resolve(err); + } + }); + }); + }); +}); diff --git a/packages/adress-lookup/test/lib/nl/validators.spec.js b/packages/adress-lookup/test/lib/nl/validators.spec.js new file mode 100644 index 0000000..e272d6c --- /dev/null +++ b/packages/adress-lookup/test/lib/nl/validators.spec.js @@ -0,0 +1,31 @@ +'use strict'; + +const validators = require('../../../lib/nl/validators'); + +describe('validators', () => { + describe('postcode format validation rules', () => { + it('returns true if the postcode is correct', () => { + expect(validators.postcode('2517KC')).toBeTruthy(); + }); + + it('returns true with a space', () => { + expect(validators.postcode('2517 KC')).toBeTruthy(); + }); + + it('returns true in lowercase', () => { + expect(validators.postcode('2517kc')).toBeTruthy(); + }); + + it('returns true in mixed case', () => { + expect(validators.postcode('2517Kc')).toBeTruthy(); + }); + + it('returns false when not 4 digits', () => { + expect(validators.postcode('257Kc')).toBeFalsy(); + }); + + it('returns false when not 2 alpha characters', () => { + expect(validators.postcode('2517K')).toBeFalsy(); + }); + }); +}); diff --git a/packages/adress-lookup/test/setup.jest.js b/packages/adress-lookup/test/setup.jest.js new file mode 100644 index 0000000..4b16c64 --- /dev/null +++ b/packages/adress-lookup/test/setup.jest.js @@ -0,0 +1,4 @@ +'use strict'; + +console.log = jest.fn(); +console.warn = jest.fn(); diff --git a/packages/eslint-config/.eslintrc.js b/packages/eslint-config/.eslintrc.js index b4a5a85..753a2f5 100644 --- a/packages/eslint-config/.eslintrc.js +++ b/packages/eslint-config/.eslintrc.js @@ -19,7 +19,7 @@ module.exports = { env: { jest: true, }, - files: ['**/*.spec.js'], + files: ['**/*.spec.js', '**/*.jest.js'], plugins: ['jest'], extends: ['plugin:jest/recommended'], },