diff --git a/jest.setup.cjs b/jest.setup.cjs index 97146163b..d9e1df3b6 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -12,3 +12,4 @@ process.env.UPLOADER_URL = 'https://test-uploader.cdp-int.defra.cloud' process.env.UPLOADER_BUCKET_NAME = 'dummy-bucket' process.env.GOOGLE_ANALYTICS_TRACKING_ID = 'G-123456789' process.env.SUBMISSION_EMAIL_ADDRESS = 'dummy@defra.gov.uk' +process.env.ORDNANCE_SURVEY_API_KEY = 'dummy' diff --git a/package-lock.json b/package-lock.json index 60d1c34d2..76f5b8792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.559", + "@defra/forms-model": "^3.0.560", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2272,9 +2272,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.559", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.559.tgz", - "integrity": "sha512-dSMrTnhUXnapflHKdeQLMGDwK2QlFhp/08XwzLNHzLHmgx7pqHAgelzVeRsyHtzYDu7B7tF4r5cyR+SxI4UmXw==", + "version": "3.0.560", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.560.tgz", + "integrity": "sha512-NQF3EUJmKBwhCypVftLVg+3ZUt0urp0ZdZNG/NaBGx5VwuhVP/MR+TlcXe44xolu8S1PCwN6RtOxGlawzKh9Ew==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -2358,40 +2358,6 @@ "node": ">=10" } }, - "node_modules/@emnapi/core": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", - "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.4", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", - "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@es-joy/jsdoccomment": { "version": "0.49.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz", @@ -3886,19 +3852,6 @@ "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4161,17 +4114,6 @@ "node": ">=10.13.0" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4922,188 +4864,6 @@ "dev": true, "license": "ISC" }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", @@ -5132,65 +4892,6 @@ "linux" ] }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -8992,21 +8693,6 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -14955,193 +14641,6 @@ "sass-embedded-win32-x64": "1.89.2" } }, - "node_modules/sass-embedded-android-arm": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.89.2.tgz", - "integrity": "sha512-oHAPTboBHRZlDBhyRB6dvDKh4KvFs+DZibDHXbkSI6dBZxMTT+Yb2ivocHnctVGucKTLQeT7+OM5DjWHyynL/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.89.2.tgz", - "integrity": "sha512-+pq7a7AUpItNyPu61sRlP6G2A8pSPpyazASb+8AK2pVlFayCSPAEgpwpCE9A2/Xj86xJZeMizzKUHxM2CBCUxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-riscv64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.89.2.tgz", - "integrity": "sha512-HfJJWp/S6XSYvlGAqNdakeEMPOdhBkj2s2lN6SHnON54rahKem+z9pUbCriUJfM65Z90lakdGuOfidY61R9TYg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-x64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.89.2.tgz", - "integrity": "sha512-BGPzq53VH5z5HN8de6jfMqJjnRe1E6sfnCWFd4pK+CAiuM7iw5Fx6BQZu3ikfI1l2GY0y6pRXzsVLdp/j4EKEA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.89.2.tgz", - "integrity": "sha512-UCm3RL/tzMpG7DsubARsvGUNXC5pgfQvP+RRFJo9XPIi6elopY5B6H4m9dRYDpHA+scjVthdiDwkPYr9+S/KGw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-x64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.89.2.tgz", - "integrity": "sha512-D9WxtDY5VYtMApXRuhQK9VkPHB8R79NIIR6xxVlN2MIdEid/TZWi1MHNweieETXhWGrKhRKglwnHxxyKdJYMnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.89.2.tgz", - "integrity": "sha512-leP0t5U4r95dc90o8TCWfxNXwMAsQhpWxTkdtySDpngoqtTy3miMd7EYNYd1znI0FN1CBaUvbdCMbnbPwygDlA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.89.2.tgz", - "integrity": "sha512-2N4WW5LLsbtrWUJ7iTpjvhajGIbmDR18ZzYRywHdMLpfdPApuHPMDF5CYzHbS+LLx2UAx7CFKBnj5LLjY6eFgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.89.2.tgz", - "integrity": "sha512-Z6gG2FiVEEdxYHRi2sS5VIYBmp17351bWtOCUZ/thBM66+e70yiN6Eyqjz80DjL8haRUegNQgy9ZJqsLAAmr9g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.89.2.tgz", - "integrity": "sha512-nTyuaBX6U1A/cG7WJh0pKD1gY8hbg1m2SnzsyoFG+exQ0lBX/lwTLHq3nyhF+0atv7YYhYKbmfz+sjPP8CZ9lw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-riscv64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.89.2.tgz", - "integrity": "sha512-N6oul+qALO0SwGY8JW7H/Vs0oZIMrRMBM4GqX3AjM/6y8JsJRxkAwnfd0fDyK+aICMFarDqQonQNIx99gdTZqw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/sass-embedded-linux-musl-x64": { "version": "1.89.2", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.89.2.tgz", @@ -15159,23 +14658,6 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded-linux-riscv64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.89.2.tgz", - "integrity": "sha512-g9nTbnD/3yhOaskeqeBQETbtfDQWRgsjHok6bn7DdAuwBsyrR3JlSFyqKc46pn9Xxd9SQQZU8AzM4IR+sY0A0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/sass-embedded-linux-x64": { "version": "1.89.2", "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.89.2.tgz", @@ -15193,40 +14675,6 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded-win32-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.89.2.tgz", - "integrity": "sha512-j96iJni50ZUsfD6tRxDQE2QSYQ2WrfHxeiyAXf41Kw0V4w5KYR/Sf6rCZQLMTUOHnD16qTMVpQi20LQSqf4WGg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-win32-x64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.89.2.tgz", - "integrity": "sha512-cS2j5ljdkQsb4PaORiClaVYynE9OAPZG/XjbOMxpQmjRIf7UroY4PEIH+Waf+y47PfXFX9SyxhYuw2NIKGbEng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/sass-embedded/node_modules/immutable": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.2.tgz", diff --git a/package.json b/package.json index 6461b9cd6..bb3a7f793 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.559", + "@defra/forms-model": "^3.0.560", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/sonar-project.properties b/sonar-project.properties index 66b27ddef..fa4e914a7 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,7 +11,7 @@ sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.sourceEncoding=UTF-8 sonar.sources=src,scripts/generate-schema-docs.js -sonar.exclusions=**/*.test.*,src/server/forms/* +sonar.exclusions=**/*.test.*,src/server/forms/*,**/__stubs__/* sonar.tests=src,test sonar.test.inclusions=**/*.test.* sonar.cpd.exclusions=**/*.test.* diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss index 349c344c2..822869f24 100644 --- a/src/client/stylesheets/application.scss +++ b/src/client/stylesheets/application.scss @@ -12,3 +12,17 @@ .govuk-header__container { border-bottom: 10px solid #003d16; } + +.app-hidden { + display: none; + visibility: hidden; +} + +.govuk-button--link { + @extend %govuk-link; + color: $govuk-link-colour; + border: none; + cursor: pointer; + background-color: transparent; + @include govuk-font($size: 19); +} diff --git a/src/config/index.ts b/src/config/index.ts index e6300f769..96fe12dac 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -252,7 +252,15 @@ export const config = convict({ format: String, default: '', env: 'SUBMISSION_EMAIL_ADDRESS' - } as SchemaObj + } as SchemaObj, + + ordnanceSurveyApiKey: { + doc: 'The ordnance survey api key use by the postcode lookup plugin', + format: String, + nullable: true, + default: undefined, + env: 'ORDNANCE_SURVEY_API_KEY' + } as SchemaObj }) config.validate({ allowed: 'strict' }) diff --git a/src/index.ts b/src/index.ts index e6809c961..fa8799fd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,19 +13,20 @@ process.on('unhandledRejection', (error) => { throw error }) +const port = config.get('port') +const ordnanceSurveyApiKey = config.get('ordnanceSurveyApiKey') + /** * Main entrypoint to the application. */ async function startServer() { - const server = await createServer() + const server = await createServer({ ordnanceSurveyApiKey }) await server.start() process.send?.('online') server.logger.info('Server started successfully') - server.logger.info( - `Access your frontend on http://localhost:${config.get('port')}` - ) + server.logger.info(`Access your frontend on http://localhost:${port}`) } startServer().catch((error: unknown) => { diff --git a/src/server/constants.js b/src/server/constants.js index 42785542d..fd659a09e 100644 --- a/src/server/constants.js +++ b/src/server/constants.js @@ -1,2 +1,4 @@ export const PREVIEW_PATH_PREFIX = '/preview' export const FORM_PREFIX = '' +export const EXTERNAL_STATE_PAYLOAD = 'EXTERNAL_STATE_PAYLOAD' +export const EXTERNAL_STATE_APPENDAGE = 'EXTERNAL_STATE_APPENDAGE' diff --git a/src/server/forms/components.json b/src/server/forms/components.json index b96bea36c..ee9bbe185 100644 --- a/src/server/forms/components.json +++ b/src/server/forms/components.json @@ -120,6 +120,13 @@ "content": "### This is a H3 in markdown\n\n[An internal link](http://localhost:3009/fictional-page)\n\n[An external link](https://defra.gov.uk/fictional-page)", "options": {}, "schema": {} + }, + { + "title": "Summary", + "path": "/summary", + "controller": "SummaryPageController", + "components": [], + "next": [] } ] } diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index 3c20764c8..3a7a74760 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -56,10 +56,26 @@ pages: - name: wZLWPy options: required: true + usePostcodeLookup: true type: UkAddressField - title: Address + title: What is your billing address + shortDescription: Billing address schema: {} - hint: This is a UK address. Users must enter address line 1, town and a postcode + hint: This is a UK billing address. Users must enter address line 1, town and a postcode + - name: dfTGhD + options: {} + schema: {} + type: MultilineTextField + title: Delivery notes + hint: + Enter some instructions for the delivery person + - name: drGHuj + options: + required: true + type: UkAddressField + title: What is your delivery address + schema: {} + hint: This is a UK delivery address. Users must enter address line 1, town and a postcode next: - path: '/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address' section: section diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index 784e19c68..aa5853958 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -91,6 +91,7 @@ describe('UkAddressField', () => { expect(field.keys).toEqual([ 'myComponent', + 'myComponent__uprn', 'myComponent__addressLine1', 'myComponent__addressLine2', 'myComponent__town', @@ -194,7 +195,8 @@ describe('UkAddressField', () => { addressLine2: '', town: '', county: '', - postcode: '' + postcode: '', + uprn: '' }) ) @@ -208,7 +210,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }) ) @@ -218,7 +221,8 @@ describe('UkAddressField', () => { addressLine2: '', // Optional field town: 'Warrington', county: '', // Optional field - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }) ) @@ -233,7 +237,8 @@ describe('UkAddressField', () => { addressLine2: '', town: '', county: '', - postcode: '' + postcode: '', + uprn: '' }) ) @@ -302,7 +307,7 @@ describe('UkAddressField', () => { 'postal-code' ] - ukAddressField.collection.components.forEach((component) => { + ukAddressField.collection.components.slice(1).forEach((component) => { const addressFieldOptions = component.options as TextFieldComponent['options'] @@ -319,7 +324,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '123456789' } it('returns text from state', () => { @@ -481,7 +487,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' } const addressLine1Invalid = @@ -514,7 +521,8 @@ describe('UkAddressField', () => { addressLine2: ' Knutsford Road', town: ' Warrington', county: 'Cheshire', - postcode: ' WA4 1HT' + postcode: ' WA4 1HT', + uprn: '' }), output: { value: getFormData(address), @@ -527,7 +535,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road ', town: 'Warrington ', county: 'Cheshire ', - postcode: 'WA4 1HT ' + postcode: 'WA4 1HT ', + uprn: '' }), output: { value: getFormData(address), @@ -540,7 +549,8 @@ describe('UkAddressField', () => { addressLine2: ' Knutsford Road \n\n', town: ' Warrington \n\n', county: ' Cheshire \n\n', - postcode: ' WA4 1HT \n\n' + postcode: ' WA4 1HT \n\n', + uprn: '' }), output: { value: getFormData(address), @@ -564,7 +574,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), output: { value: getFormData({ @@ -572,7 +583,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), errors: [ expect.objectContaining({ @@ -587,7 +599,8 @@ describe('UkAddressField', () => { addressLine2: addressLine2Invalid, town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), output: { value: getFormData({ @@ -595,7 +608,8 @@ describe('UkAddressField', () => { addressLine2: addressLine2Invalid, town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), errors: [ expect.objectContaining({ @@ -610,7 +624,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: townInvalid, county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), output: { value: getFormData({ @@ -618,7 +633,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: townInvalid, county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), errors: [ expect.objectContaining({ @@ -633,7 +649,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: countyInvalid, - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), output: { value: getFormData({ @@ -641,7 +658,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: countyInvalid, - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), errors: [ expect.objectContaining({ @@ -656,7 +674,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: postcodeInvalid + postcode: postcodeInvalid, + uprn: '' }), output: { value: getFormData({ @@ -664,7 +683,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: postcodeInvalid + postcode: postcodeInvalid, + uprn: '' }), errors: [ expect.objectContaining({ @@ -679,7 +699,8 @@ describe('UkAddressField', () => { addressLine2: '', town: '', county: '', - postcode: postcodeInvalid + postcode: postcodeInvalid, + uprn: '' }), output: { value: getFormData({ @@ -687,7 +708,8 @@ describe('UkAddressField', () => { addressLine2: '', town: '', county: '', - postcode: postcodeInvalid + postcode: postcodeInvalid, + uprn: '' }), errors: [ expect.objectContaining({ @@ -761,7 +783,8 @@ function getFormData(address: FormPayload): FormPayload { myComponent__addressLine2: address.addressLine2, myComponent__town: address.town, myComponent__county: address.county, - myComponent__postcode: address.postcode + myComponent__postcode: address.postcode, + myComponent__uprn: address.uprn } } @@ -769,15 +792,15 @@ function getFormData(address: FormPayload): FormPayload { * UK address session state */ function getFormState(address: FormPayload): FormState { - const [addressLine1, addressLine2, town, county, postcode] = Object.values( - getFormData(address) - ) + const [addressLine1, addressLine2, town, county, postcode, uprn] = + Object.values(getFormData(address)) return { myComponent__addressLine1: addressLine1 ?? null, myComponent__addressLine2: addressLine2 ?? null, myComponent__town: town ?? null, myComponent__county: county ?? null, - myComponent__postcode: postcode ?? null + myComponent__postcode: postcode ?? null, + myComponent__uprn: uprn ?? null } } diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index aa73936f8..66eb2aebf 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -1,5 +1,10 @@ -import { ComponentType, type UkAddressFieldComponent } from '@defra/forms-model' +import { + ComponentType, + type FormComponentsDef, + type UkAddressFieldComponent +} from '@defra/forms-model' import { type ObjectSchema } from 'joi' +import lowerFirst from 'lodash/lowerFirst.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { @@ -8,14 +13,20 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' +import { + type FormRequestPayload, + type FormResponseToolkit +} from '~/src/server/plugins/engine/types/index.js' import { type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, type FormSubmissionError, - type FormSubmissionState + type FormSubmissionState, + type PostcodeLookupExternalArgs } from '~/src/server/plugins/engine/types.js' +import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' export class UkAddressField extends FormComponent { declare options: UkAddressFieldComponent['options'] @@ -23,13 +34,15 @@ export class UkAddressField extends FormComponent { declare stateSchema: ObjectSchema declare collection: ComponentCollection + shortDescription: FormComponentsDef['shortDescription'] + constructor( def: UkAddressFieldComponent, props: ConstructorParameters[1] ) { super(def, props) - const { name, options } = def + const { name, options, shortDescription } = def const isRequired = options.required !== false const hideOptional = !!options.optionalText @@ -37,6 +50,16 @@ export class UkAddressField extends FormComponent { this.collection = new ComponentCollection( [ + { + type: ComponentType.TextField, + name: `${name}__uprn`, + title: 'UPRN', + schema: {}, + options: { + required: false, + classes: 'hidden' + } + }, { type: ComponentType.TextField, name: `${name}__addressLine1`, @@ -103,6 +126,7 @@ export class UkAddressField extends FormComponent { this.options = options this.formSchema = this.collection.formSchema this.stateSchema = this.collection.stateSchema + this.shortDescription = shortDescription } getFormValueFromState(state: FormSubmissionState) { @@ -115,7 +139,9 @@ export class UkAddressField extends FormComponent { return null } - return Object.values(value).filter(Boolean) + return Object.entries(value) + .filter(([key, value]) => key !== 'uprn' && Boolean(value)) + .map(([, value]) => value) } getContextValueFromState(state: FormSubmissionState) { @@ -140,17 +166,34 @@ export class UkAddressField extends FormComponent { getViewErrors( errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { - return this.getErrors(errors)?.filter( + const uniqueErrors = this.getErrors(errors)?.filter( (error, index, self) => index === self.findIndex((err) => err.name === error.name) ) + + // When using postcode lookup, the address fields are hidden + // so we replace any individual validation messages with a single one + if (this.shouldUsePostcodeLookup() && uniqueErrors?.length) { + const { name, shortDescription } = this + + return [ + { + name, + path: [name], + href: `#${name}`, + text: `Enter ${lowerFirst(shortDescription)}` + } + ] + } + + return uniqueErrors } getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const { collection, name, options } = this const viewModel = super.getViewModel(payload, errors) - let { components, fieldset, hint, label } = viewModel + let { fieldset, hint, label } = viewModel fieldset ??= { legend: { @@ -173,12 +216,30 @@ export class UkAddressField extends FormComponent { } } - components = collection.getViewModel(payload, errors) + const components = collection.getViewModel(payload, errors) + + // Hide UPRN + const uprn = components.at(0) + + if (!uprn) { + throw new Error('No UPRN') + } + + uprn.model.formGroup = { classes: 'app-hidden' } + + // Postcode lookup + const usePostcodeLookup = this.shouldUsePostcodeLookup() + + const value = usePostcodeLookup + ? this.getDisplayStringFromState(payload) + : undefined return { ...viewModel, + value, fieldset, - components + components, + usePostcodeLookup } } @@ -193,6 +254,10 @@ export class UkAddressField extends FormComponent { return UkAddressField.getAllPossibleErrors() } + private shouldUsePostcodeLookup() { + return !!(this.options.usePostcodeLookup && this.model.ordnanceSurveyApiKey) + } + /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ @@ -218,9 +283,27 @@ export class UkAddressField extends FormComponent { TextField.isText(value.postcode) ) } + + static dispatcher( + request: FormRequestPayload, + h: FormResponseToolkit, + args: PostcodeLookupExternalArgs + ) { + const { controller, component } = args + + return dispatch(request, h, { + formName: controller.model.name, + componentName: component.name, + componentHint: component.hint, + componentTitle: component.title || controller.title, + step: args.actionArgs.step, + sourceUrl: args.sourceUrl + }) + } } export interface UkAddressState extends Record { + uprn: string addressLine1: string addressLine2: string town: string diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index dba5fa230..aace62854 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -21,7 +21,8 @@ export const configureEnginePlugin = async ( controllers, preparePageEventRequestOptions, onRequest, - saveAndExit + saveAndExit, + ordnanceSurveyApiKey }: RouteConfig = {}, cache?: CacheService ): Promise<{ @@ -38,7 +39,7 @@ export const configureEnginePlugin = async ( model = new FormModel( definition, - { basePath: initialBasePath }, + { basePath: initialBasePath, ordnanceSurveyApiKey }, services, controllers ) @@ -63,7 +64,8 @@ export const configureEnginePlugin = async ( preparePageEventRequestOptions, onRequest, baseUrl: 'http://localhost:3009', // always runs locally - saveAndExit + saveAndExit, + ordnanceSurveyApiKey } } } diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index e6fa7506b..f3802afb4 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -77,6 +77,7 @@ export class FormModel { values: FormDefinition basePath: string versionNumber?: number + ordnanceSurveyApiKey?: string conditions: Partial> pages: PageControllerClass[] services: Services @@ -95,7 +96,11 @@ export class FormModel { constructor( def: typeof this.def, - options: { basePath: string; versionNumber?: number }, + options: { + basePath: string + versionNumber?: number + ordnanceSurveyApiKey?: string + }, services: Services = defaultServices, controllers?: Record ) { @@ -150,6 +155,7 @@ export class FormModel { this.values = result.value this.basePath = options.basePath this.versionNumber = options.versionNumber + this.ordnanceSurveyApiKey = options.ordnanceSurveyApiKey this.conditions = {} this.services = services this.controllers = controllers @@ -551,7 +557,9 @@ function validateFormPayload( // Skip validation GET requests or other actions if ( !request.payload || - (action && ![FormAction.Validate, FormAction.SaveAndExit].includes(action)) + (action && + ![FormAction.Validate, FormAction.SaveAndExit].includes(action) && + !action.startsWith(FormAction.External)) ) { return context } diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index 3efb29e87..ac1ad1380 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -25,7 +25,8 @@ const pluginRegistrationOptionsSchema = Joi.object({ preparePageEventRequestOptions: Joi.function().optional(), onRequest: Joi.function().optional(), baseUrl: Joi.string().uri().required(), - saveAndExit: Joi.function().optional() + saveAndExit: Joi.function().optional(), + ordnanceSurveyApiKey: Joi.string().optional() }) /** diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index 42c252ae8..7e20e2696 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -602,6 +602,7 @@ describe('QuestionPageController', () => { addressField__town: 'Town or city', addressField__county: 'Cheshire', addressField__postcode: 'CW1 1AB', + addressField__uprn: '', radiosField: 'privateLimitedCompany', selectField: 910400000, autocompleteField: 910400044, diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index b912a6449..20ef943ae 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -12,6 +12,10 @@ import Boom from '@hapi/boom' import { type RouteOptions } from '@hapi/hapi' import { type ValidationErrorItem } from 'joi' +import { + EXTERNAL_STATE_APPENDAGE, + EXTERNAL_STATE_PAYLOAD +} from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type BackLink } from '~/src/server/plugins/engine/components/types.js' @@ -35,6 +39,7 @@ import { type FormStateValue, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js' import { FormAction, type FormRequest, @@ -492,6 +497,11 @@ export class QuestionPageController extends PageController { ) => { const { collection, viewName, model } = this const { isForceAccess, state, evaluationState } = context + const action = request.payload.action + + if (action?.startsWith(FormAction.External)) { + return this.dispatchExternal(request, h, context) + } /** * If there are any errors, render the page with the parsed errors @@ -515,7 +525,6 @@ export class QuestionPageController extends PageController { await this.setState(request, state) // Check if this is a save-and-exit action - const { action } = request.payload if (action === FormAction.SaveAndExit) { return this.handleSaveAndExit(request, context, h) } @@ -525,6 +534,65 @@ export class QuestionPageController extends PageController { } } + private dispatchExternal( + request: FormRequestPayload, + h: FormResponseToolkit, + context: FormContext + ) { + const { externalComponents } = getComponentsByType() + const action = request.payload.action ?? '' + + // Find the external action and arguments + // `external-{componentName}--{argname1}:{argvalue1}--{argname2}:{argvalue2}` + // E.g. external-abcdef--amount:10--step:manual + const externalActionsWithArgs = action + .slice(`${FormAction.External}-`.length) + .split('--') + + const externalActionArgs = externalActionsWithArgs + .slice(1) + .map((arg) => arg.split(':')) + + const args = Object.fromEntries(externalActionArgs) as Record< + string, + string + > + + const componentName = externalActionsWithArgs[0] + const component = this.model.componentDefMap.get(componentName) + const componentType = component?.type + + if (!componentType) { + throw Boom.internal( + `External component of type ${componentType} not found` + ) + } + + const selectedComponent = externalComponents.get(componentType) + + if (!selectedComponent) { + throw Boom.internal(`External component ${componentName} not found`) + } + + // Stash payload without crumb and action + const stashedPayload = { + ...context.payload, + crumb: undefined, + action: undefined + } + request.yar.flash(EXTERNAL_STATE_PAYLOAD, stashedPayload, true) + + // Clear any previous state appendage + request.yar.clear(EXTERNAL_STATE_APPENDAGE) + + return selectedComponent.dispatcher(request, h, { + component, + controller: this, + sourceUrl: request.url.toString(), + actionArgs: args + }) + } + proceed( request: FormContextRequest, h: FormResponseToolkit, diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index a20451f16..14b915bf5 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -15,6 +15,7 @@ import { getRoutes as getRepeaterItemDeleteRoutes } from '~/src/server/plugins/e import { getRoutes as getRepeaterSummaryRoutes } from '~/src/server/plugins/engine/routes/repeaters/summary.js' import { type PluginOptions } from '~/src/server/plugins/engine/types.js' import { registerVision } from '~/src/server/plugins/engine/vision.js' +import { postcodeLookupPlugin } from '~/src/server/plugins/postcode-lookup/index.js' import { type FormRequestPayloadRefs, type FormRequestRefs @@ -35,7 +36,8 @@ export const plugin = { nunjucks: nunjucksOptions, viewContext, preparePageEventRequestOptions, - onRequest + onRequest, + ordnanceSurveyApiKey } = options const cacheService = @@ -45,6 +47,16 @@ export const plugin = { await registerVision(server, options) + // Register the postcode lookup plugin only if we have an OS api key + if (ordnanceSurveyApiKey) { + await server.register({ + plugin: postcodeLookupPlugin, + options: { + ordnanceSurveyApiKey + } + }) + } + server.expose('baseLayoutPath', nunjucksOptions.baseLayoutPath) server.expose('viewContext', viewContext) server.expose('cacheService', cacheService) diff --git a/src/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts index 826ccef48..45cb49128 100644 --- a/src/server/plugins/engine/routes/index.test.ts +++ b/src/server/plugins/engine/routes/index.test.ts @@ -25,6 +25,7 @@ describe('redirectOrMakeHandler', () => { const mockRequest: AnyFormRequest = { server: mockServer, app: {}, + yar: { flash: () => [] }, params: { path: 'test-path' }, query: {} } as unknown as AnyFormRequest diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 37225060a..e4e765706 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -6,7 +6,15 @@ import { } from '@hapi/hapi' import { isEqual } from 'date-fns' -import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' +import { + EXTERNAL_STATE_APPENDAGE, + EXTERNAL_STATE_PAYLOAD, + PREVIEW_PATH_PREFIX +} from '~/src/server/constants.js' +import { + FormComponent, + isFormState +} from '~/src/server/plugins/engine/components/FormComponent.js' import { checkEmailAddressForLiveFormSubmission, checkFormStatus, @@ -22,7 +30,10 @@ import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNu import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { type AnyFormRequest, + type ExternalStateAppendage, type FormContext, + type FormPayload, + type FormSubmissionState, type OnRequestCallback, type PluginOptions } from '~/src/server/plugins/engine/types.js' @@ -66,6 +77,8 @@ export async function redirectOrMakeHandler( }) } + state = await importExternalComponentState(request, page, state) + const flash = cacheService.getFlash(request) const context = model.getFormContext(request, state, flash?.errors) const relevantPath = page.getRelevantPath(request, context) @@ -95,11 +108,66 @@ export async function redirectOrMakeHandler( return proceed(request, h, page.getHref(relevantPath)) } +async function importExternalComponentState( + request: AnyFormRequest, + page: PageControllerClass, + state: FormSubmissionState +): Promise { + const externalComponentData = request.yar.flash(EXTERNAL_STATE_APPENDAGE) + + if (Array.isArray(externalComponentData)) { + return state + } + + const typedStateAppendage = externalComponentData as ExternalStateAppendage + const componentName = typedStateAppendage.component + const stateAppendage = typedStateAppendage.data + const component = request.app.model?.componentMap.get(componentName) + + if (!component) { + throw new Error(`Component ${componentName} not found in form`) + } + + if (!(component instanceof FormComponent)) { + throw new TypeError( + `Component ${componentName} is not a FormComponent and does not support isState` + ) + } + + const isStateValid = component.isState(stateAppendage) + + if (!isStateValid) { + throw new Error(`State for component ${componentName} is invalid`) + } + + const componentState = isFormState(stateAppendage) + ? Object.fromEntries( + Object.entries(stateAppendage).map(([key, value]) => [ + `${componentName}__${key}`, + value + ]) + ) + : { [componentName]: stateAppendage } + + // Save the external component state immediately + const updatedState = await page.mergeState(request, state, componentState) + + // Merge the stashed payload into the local state + const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD) + const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload) + + return { ...stashedPayload, ...updatedState } +} + export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong const prefix = server.realm.modifiers.route.prefix ?? '' - const { services = defaultServices, controllers } = options + const { + services = defaultServices, + controllers, + ordnanceSurveyApiKey + } = options const { formsService } = services @@ -166,7 +234,7 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { // Construct the form model const model = new FormModel( definition, - { basePath, versionNumber }, + { basePath, versionNumber, ordnanceSurveyApiKey }, services, controllers ) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 00f0e394f..b2dde4e23 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -4,7 +4,8 @@ import { type FormVersionMetadata, type Item, type List, - type Page + type Page, + type UkAddressFieldComponent } from '@defra/forms-model' import { type PluginProperties, @@ -28,6 +29,7 @@ import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' +import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/index.js' import { type FileStatus, type FormAdapterSubmissionSchemaVersion, @@ -378,6 +380,23 @@ export type SaveAndExitHandler = ( context: FormContext ) => ResponseObject +export interface ExternalArgs { + component: ComponentDef + controller: QuestionPageController + sourceUrl: string + actionArgs: Record +} + +export interface PostcodeLookupExternalArgs extends ExternalArgs { + component: UkAddressFieldComponent + actionArgs: { step: string } +} + +export interface ExternalStateAppendage { + component: string + data: FormStateValue | FormState +} + export interface PluginOptions { model?: FormModel services?: Services @@ -395,6 +414,7 @@ export interface PluginOptions { preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" + ordnanceSurveyApiKey?: string } export interface FormAdapterSubmissionMessageMeta { diff --git a/src/server/plugins/engine/validationHelpers.ts b/src/server/plugins/engine/validationHelpers.ts new file mode 100644 index 000000000..268cf5792 --- /dev/null +++ b/src/server/plugins/engine/validationHelpers.ts @@ -0,0 +1,48 @@ +import { type ResponseObject } from '@hapi/hapi' + +import * as Components from '~/src/server/plugins/engine/components/index.js' +import { + type FormRequestPayload, + type FormResponseToolkit +} from '~/src/server/plugins/engine/types/index.js' +import { type ExternalArgs } from '~/src/server/plugins/engine/types.js' + +// Type guard for ExternalComponent +export function isExternalComponent( + component: unknown +): component is ExternalComponent { + return typeof (component as ExternalComponent).dispatcher === 'function' +} + +// External components are guaranteed to have a dispatcher method +export interface ExternalComponent { + dispatcher( + request: FormRequestPayload, + h: FormResponseToolkit, + args: ExternalArgs + ): ResponseObject +} + +/** + * Returns internal and external components from a componentMap, regardless of error state. + * @returns An object containing internalComponents and externalComponents arrays + */ +export function getComponentsByType(): { + internalComponents: Map + externalComponents: Map +} { + const internalComponents = new Map() + const externalComponents = new Map() + + const componentMap = new Map(Object.entries(Components)) + + for (const [name, component] of componentMap.entries()) { + if (isExternalComponent(component)) { + externalComponents.set(name, component) + } else { + internalComponents.set(name, component) + } + } + + return { internalComponents, externalComponents } +} diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index 12bd4965f..e2aabbe7e 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -1,10 +1,18 @@ {% from "partials/components.html" import componentList %} {% from "govuk/components/fieldset/macro.njk" import govukFieldset %} {% from "govuk/components/hint/macro.njk" import govukHint %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/inset-text/macro.njk" import govukInsetText %} {% macro UkAddressField(component) %} {% set fieldset = component.model.fieldset %} - {% set addressFieldHtml = componentList(component.model.components) %} + {% set usePostcodeLookup = component.model.usePostcodeLookup %} + + {% set addressFieldHtml %} +
+ {{ componentList(component.model.components) }} +
+ {% endset %} {% if component.model.hint %} {% set addressHintHtml %} @@ -17,9 +25,45 @@ {% set addressFieldHtml = addressHintHtml + addressFieldHtml %} {% endif %} - {{ govukFieldset({ - legend: fieldset.legend, - attributes: fieldset.attributes, - html: addressFieldHtml - }) if fieldset else addressFieldHtml }} +
+ {{ govukFieldset({ + legend: fieldset.legend, + attributes: fieldset.attributes, + html: addressFieldHtml + }) if fieldset else addressFieldHtml }} + + {% if usePostcodeLookup %} + {% set value = component.model.value %} + + {% if value %} + {% set insetHtml %} + Selected address: +

+ {{ value }} +

+

+ +

+ {% endset %} + + {{ govukInsetText({ + html: insetHtml, + classes: "govuk-!-margin-top-2" + }) }} + {% else %} +
+ {{ govukButton({ + text: "Find an address", + attributes: { + name: "action", + value: "external-" + component.model.name + }, + classes: "govuk-button--secondary govuk-!-margin-right-1 govuk-!-margin-bottom-0" + }) }} +

or

+
+ {% endif %} + {% endif %} +
{% endmacro %} diff --git a/src/server/plugins/engine/vision.ts b/src/server/plugins/engine/vision.ts index 048aa9481..99a976b2e 100644 --- a/src/server/plugins/engine/vision.ts +++ b/src/server/plugins/engine/vision.ts @@ -13,6 +13,7 @@ import { prepareNunjucksEnvironment } from '~/src/server/plugins/engine/index.js' import { type PluginOptions } from '~/src/server/plugins/engine/types.js' +import { VIEW_PATH as POSTCODE_LOOKUP_VIEW_PATH } from '~/src/server/plugins/postcode-lookup/index.js' export async function registerVision( server: Server, @@ -24,10 +25,15 @@ export async function registerVision( ) const viewPathResolved = join(packageRoot, VIEW_PATH) + const postcodeLookupPathResolved = join( + packageRoot, + POSTCODE_LOOKUP_VIEW_PATH + ) const paths = [ ...pluginOptions.nunjucks.paths, viewPathResolved, + postcodeLookupPathResolved, join(govukFrontendPath, 'dist') ] diff --git a/src/server/plugins/postcode-lookup/index.js b/src/server/plugins/postcode-lookup/index.js new file mode 100644 index 000000000..f9c4ad72b --- /dev/null +++ b/src/server/plugins/postcode-lookup/index.js @@ -0,0 +1,21 @@ +import { getRoutes } from '~/src/server/plugins/postcode-lookup/routes/index.js' + +export const VIEW_PATH = 'src/server/plugins/postcode-lookup/views' + +/** + * @satisfies {NamedPlugin} + */ +export const postcodeLookupPlugin = { + name: '@defra/forms-engine-plugin/postcode-lookup', + dependencies: ['@hapi/vision'], + multiple: false, + register(server, options) { + // @ts-expect-error - Request typing + server.route(getRoutes(options)) + } +} + +/** + * @import { NamedPlugin } from '@hapi/hapi' + * @import { PostcodeLookupConfiguration } from '~/src/server/plugins/postcode-lookup/types.js' + */ diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js new file mode 100644 index 000000000..34593c18a --- /dev/null +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -0,0 +1,549 @@ +import Joi from 'joi' + +import * as service from '~/src/server/plugins/postcode-lookup/service.js' +import { crumbSchema } from '~/src/server/schemas/index.js' + +// Field names/ids +const postcodeQueryFieldName = 'postcodeQuery' +const buildingNameQueryFieldName = 'buildingNameQuery' +const uprnFieldName = 'uprn' + +const line1FieldName = 'addressLine1' +const line2FieldName = 'addressLine2' +const townFieldName = 'town' +const countyFieldName = 'county' +const postcodeFieldName = 'postcode' + +const selectLabelText = 'Select an address' + +const GOVUK_MARGIN_RIGHT_1 = 'govuk-!-margin-right-1' + +export const steps = { + // Step 1: Postcode/building name input + details: 'details', + // Step 2: Select address + select: 'select', + // Step 3: Manual address + manual: 'manual' +} + +export const JOURNEY_BASE_URL = '/postcode-lookup' + +/** + * Build form errors + * @param {Error} [err] + */ +function buildErrors(err) { + const hasErrors = Joi.isError(err) && err.details.length > 0 + + if (!hasErrors) { + return {} + } + + /** + * Get error by path + * @param {string} fieldName + */ + const getError = (fieldName) => { + return err.details.find((item) => item.path[0] === fieldName) + } + + const postcodeQueryError = getError(postcodeQueryFieldName) + const buildingNameQueryError = getError(buildingNameQueryFieldName) + const uprnError = getError(uprnFieldName) + const line1Error = getError(line1FieldName) + const line2Error = getError(line2FieldName) + const townError = getError(townFieldName) + const countyError = getError(countyFieldName) + const postcodeError = getError(postcodeFieldName) + + /** + * @type {{ text: string, href: string }[]} + */ + const errors = [] + + /** + * Push error + * @param {string} fieldName - the field name + * @param {ValidationErrorItem} [item] - the joi validation error + */ + const pushError = (fieldName, item) => { + if (item) { + errors.push({ + text: item.message, + href: `#${fieldName}` + }) + } + } + + pushError(postcodeQueryFieldName, postcodeQueryError) + pushError(buildingNameQueryFieldName, buildingNameQueryError) + pushError(uprnFieldName, uprnError) + pushError(line1FieldName, line1Error) + pushError(line2FieldName, line2Error) + pushError(townFieldName, townError) + pushError(countyFieldName, countyError) + pushError(postcodeFieldName, postcodeError) + + return { + errors, + postcodeQueryError, + buildingNameQueryError, + uprnError, + line1Error, + line2Error, + townError, + countyError, + postcodeError + } +} + +/** + * Search ordnance survey for addresses + * @param {string} postcodeQuery + * @param {string} buildingNameQuery + * @param {string} apiKey + */ +async function getAddresses(postcodeQuery, buildingNameQuery, apiKey) { + const addresses = await service.search( + postcodeQuery, + buildingNameQuery, + apiKey + ) + const addressCount = addresses.length + const singleAddress = addressCount === 1 ? addresses.at(0) : undefined + const hasAddresses = addressCount > 0 + const hasMultipleAddresses = addressCount > 1 + + return { + hasAddresses, + hasMultipleAddresses, + singleAddress, + addresses, + addressCount + } +} + +/** + * Get the details view fields + * @param {PostcodeLookupDetailsData | undefined} details + * @param {OptionalValidationErrorItem} postcodeQueryError + * @param {OptionalValidationErrorItem} buildingNameQueryError + */ +function getDetailsFields(details, postcodeQueryError, buildingNameQueryError) { + return { + [postcodeQueryFieldName]: { + id: postcodeQueryFieldName, + name: postcodeQueryFieldName, + label: { + text: 'Postcode' + }, + hint: { + text: 'For example, AA3 1AB' + }, + value: details?.postcodeQuery, + errorMessage: postcodeQueryError && { text: postcodeQueryError.message } + }, + [buildingNameQueryFieldName]: { + id: buildingNameQueryFieldName, + name: buildingNameQueryFieldName, + label: { + text: 'Building name or number (optional)' + }, + hint: { + text: 'For example, 15 or Prospect Cottage' + }, + value: details?.buildingNameQuery, + errorMessage: buildingNameQueryError && { + text: buildingNameQueryError.message + } + } + } +} + +/** + * Get the select view fields + * @param {PostcodeLookupDetailsData} details + * @param {boolean} hasMultipleAddresses + * @param {Address | undefined} singleAddress + * @param {PostcodeLookupSelectPayload | undefined} payload + * @param {OptionalValidationErrorItem} uprnError + * @param {Address[]} addresses + */ +function getSelectFields( + details, + hasMultipleAddresses, + singleAddress, + payload, + uprnError, + addresses +) { + return { + [postcodeQueryFieldName]: { + id: postcodeQueryFieldName, + name: postcodeQueryFieldName, + type: 'hidden', + value: details.postcodeQuery + }, + [buildingNameQueryFieldName]: { + id: buildingNameQueryFieldName, + name: buildingNameQueryFieldName, + type: 'hidden', + value: details.buildingNameQuery + }, + [uprnFieldName]: { + id: uprnFieldName, + name: uprnFieldName, + label: hasMultipleAddresses + ? { + text: selectLabelText + } + : undefined, + value: singleAddress ? singleAddress.uprn : payload?.uprn, + errorMessage: uprnError && { text: uprnError.message }, + items: hasMultipleAddresses + ? [{ text: selectLabelText, value: '' }].concat( + addresses.map((item) => ({ + text: item.formatted, + value: item.uprn + })) + ) + : undefined, + type: singleAddress ? 'hidden' : undefined + } + } +} + +/** + * Get the manual view fields + * @param {PostcodeLookupManualPayload | undefined} payload + * @param {OptionalValidationErrorItem} line1Error + * @param {OptionalValidationErrorItem} line2Error + * @param {OptionalValidationErrorItem} townError + * @param {OptionalValidationErrorItem} countyError + * @param {OptionalValidationErrorItem} postcodeError + */ +function getManualFields( + payload, + line1Error, + line2Error, + townError, + countyError, + postcodeError +) { + return { + [line1FieldName]: { + id: line1FieldName, + name: line1FieldName, + label: { + text: 'Address line 1' + }, + value: payload?.addressLine1, + errorMessage: line1Error && { text: line1Error.message } + }, + [line2FieldName]: { + id: line2FieldName, + name: line2FieldName, + label: { + text: 'Address line 2 (optional)' + }, + value: payload?.addressLine2, + errorMessage: line2Error && { text: line2Error.message } + }, + [townFieldName]: { + id: townFieldName, + name: townFieldName, + label: { + text: 'Town or city' + }, + classes: 'govuk-!-width-two-thirds', + value: payload?.town, + errorMessage: townError && { text: townError.message } + }, + [countyFieldName]: { + id: countyFieldName, + name: countyFieldName, + label: { + text: 'County (optional)' + }, + value: payload?.county, + errorMessage: countyError && { text: countyError.message } + }, + [postcodeFieldName]: { + id: postcodeFieldName, + name: postcodeFieldName, + label: { + text: 'Postcode' + }, + classes: 'govuk-input--width-10', + value: payload?.postcode, + errorMessage: postcodeError && { text: postcodeError.message } + } + } +} + +export const stepSchema = Joi.string() + .valid(...Object.keys(steps)) + .required() + +const sharedPayloadSchemaKeys = { + crumb: crumbSchema, + step: stepSchema +} + +/** + * Postcode lookup details form payload schema + * @type {ObjectSchema} + */ +export const detailsPayloadSchema = Joi.object() + .keys({ + ...sharedPayloadSchemaKeys, + [postcodeQueryFieldName]: Joi.string() + .pattern(/^[a-zA-Z]{1,2}\d[a-zA-Z\d]?\s?\d[a-zA-Z]{2}$/) + .trim() + .required() + .messages({ + 'string.pattern.base': + 'Enter a valid postcode or enter an address manually', + '*': 'Enter a postcode' + }), + [buildingNameQueryFieldName]: Joi.string() + .trim() + .required() + .allow('') + .trim() + }) + .required() + +/** + * Postcode lookup select form payload schema + * @type {ObjectSchema} + */ +export const selectPayloadSchema = Joi.object() + .keys({ + ...sharedPayloadSchemaKeys, + [uprnFieldName]: Joi.string().required().messages({ + '*': selectLabelText + }) + }) + .required() + +/** + * Postcode lookup manual form payload schema + * @type {ObjectSchema} + */ +export const manualPayloadSchema = Joi.object() + .keys({ + ...sharedPayloadSchemaKeys, + [line1FieldName]: Joi.string().trim().required().messages({ + '*': 'Enter address line 1' + }), + [line2FieldName]: Joi.string().trim().allow('').required(), + [townFieldName]: Joi.string().trim().required().messages({ + '*': 'Enter town or city' + }), + [countyFieldName]: Joi.string().trim().allow('').required(), + [postcodeFieldName]: Joi.string().trim().required().messages({ + '*': 'Enter postcode' + }) + }) + .required() + +/** + * Get the postcode lookup href + * @param {string} [step] - the postcode lookup step + */ +function getHref(step) { + const query = step ? `?step=${step}` : '' + + return `${JOURNEY_BASE_URL}${query}` +} + +/** + * The postcode lookup details form view model + * @param {PostcodeLookupSessionData} data + * @param {PostcodeLookupDetailsData} [payload] + * @param {Error} [err] + */ +export function detailsViewModel(data, payload, err) { + const { componentTitle: pageTitle, formName, sourceUrl } = data.initial + + const backLink = { + href: sourceUrl + } + + const { errors, postcodeQueryError, buildingNameQueryError } = + buildErrors(err) + + // Model fields + const fields = getDetailsFields( + payload ?? data.details, + postcodeQueryError, + buildingNameQueryError + ) + + // Model buttons + const continueButton = { + text: 'Find address', + classes: GOVUK_MARGIN_RIGHT_1 + } + const manualLink = { + text: 'enter address manually', + href: getHref(steps.manual) + } + + return { + step: steps.details, + showTitle: true, + name: formName, + serviceUrl: sourceUrl, + pageTitle, + backLink, + errors, + fields, + buttons: { continueButton, manualLink } + } +} + +/** + * The postcode lookup select form view model + * @param {{ session: PostcodeLookupSessionData, apiKey: string }} data + * @param {PostcodeLookupSelectPayload} [payload] + * @param {Error} [err] + */ +export async function selectViewModel(data, payload, err) { + const { session, apiKey } = data + const { details, initial } = session + const { postcodeQuery, buildingNameQuery } = details + + // Search for addresses + const { + hasAddresses, + hasMultipleAddresses, + singleAddress, + addresses, + addressCount + } = await getAddresses(postcodeQuery, buildingNameQuery, apiKey) + + const title = hasAddresses ? initial.componentTitle : 'No address found' + const formPath = initial.sourceUrl + const href = getHref() + + const backLink = { href } + + const { errors, uprnError } = buildErrors(err) + + // Model fields + const fields = getSelectFields( + details, + hasMultipleAddresses, + singleAddress, + payload, + uprnError, + addresses + ) + + const searchAgainLink = { + text: 'Search again', + href + } + + // Model buttons + const continueButton = { + href: hasAddresses ? undefined : href, + text: hasAddresses ? 'Use this address' : 'Search again', + classes: GOVUK_MARGIN_RIGHT_1 + } + const manualLink = { + text: 'enter address manually', + href: `${href}?step=${steps.manual}` + } + + return { + step: steps.select, + showTitle: true, + name: title, + serviceUrl: formPath, + pageTitle: title, + backLink, + errors, + searchAgainLink, + fields, + details, + addressCount, + singleAddress, + hasAddresses, + hasMultipleAddresses, + buttons: { continueButton, manualLink } + } +} + +/** + * The postcode lookup manual form view model + * @param {PostcodeLookupSessionData} data + * @param {PostcodeLookupManualPayload} [payload] + * @param {Error} [err] + */ +export function manualViewModel(data, payload, err) { + const { componentTitle, sourceUrl, componentHint } = data.initial + const formPath = sourceUrl + const href = getHref() + + const backLink = { + href + } + + const { + errors, + line1Error, + line2Error, + townError, + countyError, + postcodeError + } = buildErrors(err) + + // Model hint + const hint = componentHint && { + text: componentHint + } + + // Model fields + const fields = getManualFields( + payload, + line1Error, + line2Error, + townError, + countyError, + postcodeError + ) + + // Model buttons + const continueButton = { + text: 'Use this address', + classes: GOVUK_MARGIN_RIGHT_1 + } + const detailsLink = { + text: 'find an address instead', + href + } + + return { + step: steps.manual, + showTitle: true, + name: componentTitle, + serviceUrl: formPath, + pageTitle: componentTitle, + backLink, + errors, + hint, + fields, + buttons: { continueButton, detailsLink } + } +} + +/** @typedef { ValidationErrorItem | undefined } OptionalValidationErrorItem */ + +/** + * @import { ObjectSchema, ValidationErrorItem } from 'joi' + * @import { Address, PostcodeLookupDetailsData, PostcodeLookupDetailsPayload, PostcodeLookupManualPayload, PostcodeLookupSelectPayload, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js' + */ diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js new file mode 100644 index 000000000..7252e5fec --- /dev/null +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -0,0 +1,258 @@ +import Boom from '@hapi/boom' +import { StatusCodes } from 'http-status-codes' +import Joi from 'joi' + +import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js' +import { + JOURNEY_BASE_URL, + detailsPayloadSchema, + detailsViewModel, + manualPayloadSchema, + manualViewModel, + selectPayloadSchema, + selectViewModel, + stepSchema, + steps +} from '~/src/server/plugins/postcode-lookup/models/index.js' +import * as service from '~/src/server/plugins/postcode-lookup/service.js' + +const viewName = 'postcode-lookup-details' + +/** + * Get the session state associated with this journey + * @param {PostcodeLookupRequest} request + */ +function getSessionState(request) { + /** + * @type {PostcodeLookupSessionData | undefined} + */ + const state = request.yar.get(JOURNEY_BASE_URL) + + if (!state) { + throw Boom.internal(`No postcode lookup data found for ${JOURNEY_BASE_URL}`) + } + + return state +} + +/** + * Flash form component state + * @param {PostcodeLookupRequest} request - the request + * @param {string} componentName - the component name + * @param {Address | PostcodeLookupManualPayload} address - the address from ordnance survey or manually entered + */ +function flashComponentState(request, componentName, address) { + const addressState = { + addressLine1: address.addressLine1, + addressLine2: address.addressLine2, + town: address.town, + county: address.county, + postcode: address.postcode, + uprn: 'uprn' in address && address.uprn ? address.uprn : undefined + } + + /** + * @type {ExternalStateAppendage} + */ + const appendage = { + component: componentName, + data: addressState + } + + request.yar.flash(EXTERNAL_STATE_APPENDAGE, appendage, true) +} + +/** + * Initialises and dispatches the request to the postcode lookup journey + * @param {FormRequestPayload} request - the source page + * @param {FormResponseToolkit} h - the source page + * @param {PostcodeLookupDispatchData} initial - the source data + */ +export function dispatch(request, h, initial) { + /** + * @type {PostcodeLookupSessionData} + */ + const data = { + initial, + details: { postcodeQuery: '', buildingNameQuery: '' } + } + + request.yar.set(JOURNEY_BASE_URL, data) + + const query = initial.step ? `?step=${initial.step}` : '' + + return h.redirect(`${JOURNEY_BASE_URL}${query}`).code(StatusCodes.SEE_OTHER) +} + +/** + * Gets the postcode lookup routes + * @param {PostcodeLookupConfiguration} options - ordnance survey api key + */ +export function getRoutes(options) { + return [getRoute(), postRoute(options)] +} + +/** + * @returns {ServerRoute} + */ +function getRoute() { + return { + method: 'GET', + path: JOURNEY_BASE_URL, + handler(request, h) { + const { query } = request + const { step } = query + const session = getSessionState(request) + + const model = + step === steps.manual + ? manualViewModel(session) + : detailsViewModel(session) + + return h.view(viewName, model) + }, + options: { + validate: { + query: Joi.object() + .keys({ + step: Joi.string().allow(steps.details, steps.manual).optional() + }) + .optional() + } + } + } +} + +/** + * @param {PostcodeLookupConfiguration} options + * @returns {ServerRoute} + */ +function postRoute(options) { + return { + method: 'POST', + path: JOURNEY_BASE_URL, + async handler(request, h) { + const { payload } = request + const { step } = payload + + switch (step) { + case steps.details: { + return detailsPostHandler(request, h, options) + } + case steps.select: { + return selectPostHandler(request, h, options) + } + case steps.manual: { + return manualPostHandler(request, h) + } + default: + throw Boom.badRequest(`Invalid step ${step}`) + } + }, + options: { + validate: { + payload: Joi.object() + .keys({ + step: stepSchema + }) + .unknown(true) + } + } + } +} + +/** + * Post handler for the details step + * @param {PostcodeLookupPostRequest} request + * @param {ResponseToolkit} h + * @param {PostcodeLookupConfiguration} options + */ +async function detailsPostHandler(request, h, options) { + const { payload } = request + const session = getSessionState(request) + const { ordnanceSurveyApiKey: apiKey } = options + const { value: details, error } = detailsPayloadSchema.validate(payload) + + let model + + if (error) { + model = detailsViewModel(session, details, error) + + return h.view(viewName, model) + } + + const { postcodeQuery, buildingNameQuery } = details + session.details = { postcodeQuery, buildingNameQuery } + + // Store the updated session + request.yar.set(JOURNEY_BASE_URL, session) + + model = await selectViewModel({ session, apiKey }) + + return h.view(viewName, model) +} + +/** + * Post handler for the select step + * @param {PostcodeLookupPostRequest} request + * @param {ResponseToolkit} h + * @param {PostcodeLookupConfiguration} options + */ +async function selectPostHandler(request, h, options) { + const { payload } = request + const session = getSessionState(request) + const { ordnanceSurveyApiKey: apiKey } = options + const { value: select, error } = selectPayloadSchema.validate(payload) + + if (error) { + const model = await selectViewModel({ session, apiKey }, select, error) + + return h.view(viewName, model) + } + + const addresses = await service.searchByUPRN(select.uprn, apiKey) + const property = addresses.at(0) + + if (!property) { + throw Boom.internal(`UPRN ${property} not found`) + } + + const { componentName, sourceUrl } = session.initial + flashComponentState(request, componentName, property) + + // Redirect back to the source form page + return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER) +} + +/** + * Post handler for the manual step + * @param {PostcodeLookupPostRequest} request + * @param {ResponseToolkit} h + */ +function manualPostHandler(request, h) { + const { payload } = request + const session = getSessionState(request) + + const { value: manual, error } = manualPayloadSchema.validate(payload, { + abortEarly: false + }) + + if (error) { + const model = manualViewModel(session, manual, error) + + return h.view(viewName, model) + } + + const { componentName, sourceUrl } = session.initial + flashComponentState(request, componentName, manual) + + // Redirect back to the source form page + return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER) +} + +/** + * @import { ResponseToolkit, ServerRoute } from '@hapi/hapi' + * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupPostRequest, PostcodeLookupConfiguration, PostcodeLookupDispatchData, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js' + * @import { FormRequestPayload, FormResponseToolkit } from '~/src/server/routes/types.js' + * @import { ExternalStateAppendage } from '~/src/server/plugins/engine/types.js' + */ diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js new file mode 100644 index 000000000..d8614bcd1 --- /dev/null +++ b/src/server/plugins/postcode-lookup/service.js @@ -0,0 +1,188 @@ +import { getErrorMessage } from '@defra/forms-model' +import Boom from '@hapi/boom' + +import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { getJson } from '~/src/server/services/httpService.js' + +const logger = createLogger() + +/** + * Returns an empty result set + */ +function empty() { + return [] +} + +/** + * Logs OS places errors + * @param {unknown} err - the error + * @param {string} endpoint - the OS api endpoint + */ +function logErrorAndReturnEmpty(err, endpoint) { + const msg = `${getErrorMessage(err)} ${(Boom.isBoom(err) && err.data?.payload?.error?.message) ?? ''}` + + logger.error(err, `Exception occured calling OS places ${endpoint} - ${msg}}`) + + return empty() +} + +/** + * Fetch data from OS API + * @param {string} url - the url to get address json data from + * @param {string} endpoint - the url endpoint description for logging + */ +async function getAddressData(url, endpoint) { + const getJsonByType = + /** @type {typeof getJson} */ (getJson) + + try { + const response = await getJsonByType(url) + + if (response.error) { + return logErrorAndReturnEmpty(response.error, endpoint) + } + + const results = response.payload.results + + if (!Array.isArray(results)) { + return empty() + } + + return results.map((result) => formatAddress(result.DPA)) + } catch (err) { + return logErrorAndReturnEmpty(err, endpoint) + } +} + +/** + * OS places search + * @param {string} query - the search term + * @param {string} apiKey - the OS api key + */ +export async function searchByQuery(query, apiKey) { + const endpoint = 'find' + const url = `https://api.os.uk/search/places/v1/${endpoint}?query=${encodeURIComponent(query)}&key=${apiKey}` + + return getAddressData(url, endpoint) +} + +/** + * OS postcode search + * @param {string} postcode - the postcode + * @param {string} apiKey - the OS api key + */ +export async function searchByPostcode(postcode, apiKey) { + const endpoint = 'postcode' + const url = `https://api.os.uk/search/places/v1/${endpoint}?postcode=${encodeURIComponent(postcode.replaceAll(/\s/g, ''))}&key=${apiKey}` + + return getAddressData(url, endpoint) +} + +/** + * OS UPRN search + * @param {string} uprn - the unique property reference number + * @param {string} apiKey - the OS api key + */ +export async function searchByUPRN(uprn, apiKey) { + const endpoint = 'uprn' + const url = `https://api.os.uk/search/places/v1/${endpoint}?uprn=${uprn}&key=${apiKey}` + + return getAddressData(url, endpoint) +} + +/** + * OS postcode and building name search + * @param {string} postcodeQuery - the postcode query + * @param {string} buildingNameQuery - the building name query + * @param {string} apiKey - the OS api key + */ +export async function search(postcodeQuery, buildingNameQuery, apiKey) { + let addresses = await searchByPostcode(postcodeQuery, apiKey) + + if (buildingNameQuery) { + addresses = addresses.filter((item) => + item.address.includes(buildingNameQuery.toUpperCase()) + ) + } + + return addresses +} + +/** + * Converts a delivery point address to an address + * Taken from http://github.com/dwp/find-an-address-plugin/blob/main/utils/getData.js + * @param {DeliveryPointAddress} dpa + */ +function formatAddress(dpa) { + const addressLine1 = formatAddressLine1(dpa) + const addressLine2 = formatAddressLine2(dpa) + const town = titleCase(dpa.POST_TOWN || '') + const postcode = dpa.POSTCODE || '' + const lines = [addressLine1, addressLine2, town] + const formatted = `${lines.filter((i) => !!i).join(', ')}, ${postcode}` + + /** + * @type {Address} + */ + const address = { + uprn: dpa.UPRN, + address: dpa.ADDRESS, + addressLine1, + addressLine2, + town, + county: '', + postcode, + formatted + } + + return address +} + +/** + * @param {DeliveryPointAddress} dpa + */ +function formatAddressLine1(dpa) { + return titleCase( + dpa.ORGANISATION_NAME || + dpa.SUB_BUILDING_NAME || + dpa.BUILDING_NAME || + dpa.BUILDING_NUMBER + ? [ + dpa.ORGANISATION_NAME || '', + dpa.SUB_BUILDING_NAME || '', + dpa.BUILDING_NAME || '', + dpa.BUILDING_NUMBER || '' + ] + .filter((item) => !!item) + .join(' ') + : '' + ) +} + +/** + * @param {DeliveryPointAddress} dpa + */ +function formatAddressLine2(dpa) { + return titleCase( + dpa.THOROUGHFARE_NAME || dpa.DEPENDENT_LOCALITY + ? [dpa.THOROUGHFARE_NAME || '', dpa.DEPENDENT_LOCALITY || ''] + .filter((item) => !!item) + .join(', ') + : '' + ) +} + +/** + * Title case address + * @param {string} address + */ +function titleCase(address) { + return address + .split(' ') + .map((item) => item.charAt(0).toUpperCase() + item.slice(1).toLowerCase()) + .join(' ') +} + +/** + * @import { Address, DeliveryPointAddress, DeliveryPointAddressResult } from '~/src/server/plugins/postcode-lookup/types.js' + */ diff --git a/src/server/plugins/postcode-lookup/service.test.js b/src/server/plugins/postcode-lookup/service.test.js new file mode 100644 index 000000000..57f301385 --- /dev/null +++ b/src/server/plugins/postcode-lookup/service.test.js @@ -0,0 +1,177 @@ +import Boom from '@hapi/boom' + +import * as service from '~/src/server/plugins/postcode-lookup/service.js' +import { result as postcodeResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js' +import { result as queryResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/query.js' +import { result as uprnResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js' +import { getJson } from '~/src/server/services/httpService.js' + +jest.mock('~/src/server/services/httpService.ts') + +describe('Postcode lookup service', () => { + describe('searchByPostcode', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: postcodeResult, + error: undefined + }) + + const results = await service.searchByPostcode('NW1 6XE', 'apikey') + + expect(results).toHaveLength(10) + expect(results.at(0)).toEqual({ + address: "EMILIA'S CRAFTED PASTA, 215, BAKER STREET, LONDON, NW1 6XE", + addressLine1: "Emilia's Crafted Pasta 215", + addressLine2: 'Baker Street', + county: '', + formatted: "Emilia's Crafted Pasta 215, Baker Street, London, NW1 6XE", + postcode: 'NW1 6XE', + town: 'London', + uprn: '10033619968' + }) + }) + + it('should return an empty response when an error is encountered', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 300, + headers: {} + }), + payload: undefined, + error: new Error('Unknown error') + }) + + const results = await service.searchByPostcode('NW1 6XE', 'apikey') + + expect(results).toHaveLength(0) + expect(results).toEqual([]) + }) + + it('should return an empty response when a non 200 response is encountered', async () => { + jest + .mocked(getJson) + .mockRejectedValueOnce( + Boom.badRequest( + 'OS API error', + new Error('Invalid postcode segments') + ) + ) + + const results = await service.searchByPostcode( + 'invalid postcode', + 'apikey' + ) + + expect(results).toHaveLength(0) + expect(results).toEqual([]) + }) + + it('should return an empty response when no results are returned', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: { results: undefined }, + error: undefined + }) + + const results = await service.searchByPostcode('NW1 6XE', 'apikey') + + expect(results).toHaveLength(0) + expect(results).toEqual([]) + }) + }) + + describe('searchByUPRN', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: uprnResult, + error: undefined + }) + + const results = await service.searchByUPRN('100023071949', 'apikey') + + expect(results).toHaveLength(1) + expect(results.at(0)).toEqual({ + address: 'SHERLOCK HOLMES MUSEUM, 221B, BAKER STREET, LONDON, NW1 6XE', + addressLine1: 'Sherlock Holmes Museum 221b', + addressLine2: 'Baker Street', + county: '', + formatted: 'Sherlock Holmes Museum 221b, Baker Street, London, NW1 6XE', + postcode: 'NW1 6XE', + town: 'London', + uprn: '100023071949' + }) + }) + }) + + describe('searchByQuery', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: queryResult, + error: undefined + }) + + const results = await service.searchByQuery( + 'Prime minister downing', + 'apikey' + ) + + expect(results).toHaveLength(5) + expect(results.at(0)).toEqual({ + address: 'BAKER STREET COTTAGE, BAKER STREET, FROME, BA11 3BL', + addressLine1: 'Baker Street Cottage', + addressLine2: 'Baker Street', + town: 'Frome', + county: '', + formatted: 'Baker Street Cottage, Baker Street, Frome, BA11 3BL', + postcode: 'BA11 3BL', + uprn: '250034655' + }) + }) + }) + + describe('search', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: postcodeResult, + error: undefined + }) + + const results = await service.search('NW1 6XE', 'Emilia', 'apikey') + + expect(results).toHaveLength(1) + expect(results.at(0)).toEqual({ + address: "EMILIA'S CRAFTED PASTA, 215, BAKER STREET, LONDON, NW1 6XE", + addressLine1: "Emilia's Crafted Pasta 215", + addressLine2: 'Baker Street', + county: '', + formatted: "Emilia's Crafted Pasta 215, Baker Street, London, NW1 6XE", + postcode: 'NW1 6XE', + town: 'London', + uprn: '10033619968' + }) + }) + }) +}) + +/** + * @import { IncomingMessage } from 'node:http' + */ diff --git a/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js b/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js new file mode 100644 index 000000000..bcbcc3a92 --- /dev/null +++ b/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js @@ -0,0 +1,382 @@ +export const result = { + header: { + uri: 'https://api.os.uk/search/places/v1/postcode?postcode=NW1%206XE', + query: 'postcode=NW1 6XE', + offset: 0, + totalresults: 10, + format: 'JSON', + dataset: 'DPA', + lr: 'EN,CY', + maxresults: 100, + epoch: '121', + lastupdate: '2025-10-14', + output_srs: 'EPSG:27700' + }, + results: [ + { + DPA: { + UPRN: '10033619968', + UDPRN: '50825076', + ADDRESS: "EMILIA'S CRAFTED PASTA, 215, BAKER STREET, LONDON, NW1 6XE", + ORGANISATION_NAME: "EMILIA'S CRAFTED PASTA", + BUILDING_NUMBER: '215', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '2', + X_COORDINATE: 527870.4, + Y_COORDINATE: 182081.17, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'CR07', + CLASSIFICATION_CODE_DESCRIPTION: 'Restaurant / Cafeteria', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158430', + WARD_CODE: 'E05013805', + PARENT_UPRN: '10033659670', + LAST_UPDATE_DATE: '20/01/2025', + ENTRY_DATE: '13/02/2013', + BLPU_STATE_DATE: '13/02/2013', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1H' + } + }, + { + DPA: { + UPRN: '10033619969', + UDPRN: '50825094', + ADDRESS: 'FLAT 1-86, 219, BAKER STREET, LONDON, NW1 6XE', + SUB_BUILDING_NAME: 'FLAT 1-86', + BUILDING_NUMBER: '219', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527868.08, + Y_COORDINATE: 182090.78, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'PP', + CLASSIFICATION_CODE_DESCRIPTION: 'Property Shell', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158430', + WARD_CODE: 'E05013805', + PARENT_UPRN: '10033659670', + LAST_UPDATE_DATE: '20/01/2025', + ENTRY_DATE: '13/02/2013', + BLPU_STATE_DATE: '13/02/2013', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '7J' + } + }, + { + DPA: { + UPRN: '10033625299', + UDPRN: '54761212', + ADDRESS: + 'PARKVIEW ESTATES MANAGEMENT LTD, 219, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'PARKVIEW ESTATES MANAGEMENT LTD', + BUILDING_NUMBER: '219', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527868.08, + Y_COORDINATE: 182090.78, + STATUS: 'HISTORICAL', + LOGICAL_STATUS_CODE: '8', + CLASSIFICATION_CODE: 'CL06', + CLASSIFICATION_CODE_DESCRIPTION: + 'Indoor / Outdoor Leisure / Sporting Activity / Centre', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '4', + BLPU_STATE_CODE_DESCRIPTION: 'No longer existing', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158430', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '22/02/2024', + ENTRY_DATE: '20/10/2014', + BLPU_STATE_DATE: '25/11/2022', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1G' + } + }, + { + DPA: { + UPRN: '100022723861', + UDPRN: '17646245', + ADDRESS: '235, BAKER STREET, LONDON, NW1 6XE', + BUILDING_NUMBER: '235', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527850.0, + Y_COORDINATE: 182134.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'PP', + CLASSIFICATION_CODE_DESCRIPTION: 'Property Shell', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158434', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '10/02/2016', + ENTRY_DATE: '19/03/2001', + BLPU_STATE_DATE: '19/03/2001', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '2H' + } + }, + { + DPA: { + UPRN: '100023072608', + UDPRN: '17646236', + ADDRESS: '237, BAKER STREET, LONDON, NW1 6XE', + BUILDING_NUMBER: '237', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527850.0, + Y_COORDINATE: 182139.0, + STATUS: 'HISTORICAL', + LOGICAL_STATUS_CODE: '8', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '4', + BLPU_STATE_CODE_DESCRIPTION: 'No longer existing', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158435', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '22/02/2024', + ENTRY_DATE: '19/03/2001', + BLPU_STATE_DATE: '01/10/2013', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '2G' + } + }, + { + DPA: { + UPRN: '100023071949', + UDPRN: '17646242', + ADDRESS: 'SHERLOCK HOLMES MUSEUM, 221B, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'SHERLOCK HOLMES MUSEUM', + BUILDING_NAME: '221B', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '2', + X_COORDINATE: 527847.0, + Y_COORDINATE: 182144.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158436', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '23/09/2018', + ENTRY_DATE: '19/03/2001', + BLPU_STATE_DATE: '19/03/2001', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1F' + } + }, + { + DPA: { + UPRN: '10033605426', + UDPRN: '17646231', + ADDRESS: 'LONDON BEATLES STORE, 231-233, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'LONDON BEATLES STORE', + BUILDING_NAME: '231-233', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527854.0, + Y_COORDINATE: 182123.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158432', + WARD_CODE: 'E05013805', + PARENT_UPRN: '100023072617', + LAST_UPDATE_DATE: '10/02/2016', + ENTRY_DATE: '25/02/2009', + BLPU_STATE_DATE: '25/02/2009', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1A' + } + }, + { + DPA: { + UPRN: '10033529292', + UDPRN: '17646238', + ADDRESS: 'TOTAL CHI, 241-243, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'TOTAL CHI', + BUILDING_NAME: '241-243', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '2', + X_COORDINATE: 527844.0, + Y_COORDINATE: 182155.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158438', + WARD_CODE: 'E05013805', + PARENT_UPRN: '100023071621', + LAST_UPDATE_DATE: '23/09/2018', + ENTRY_DATE: '27/04/2003', + BLPU_STATE_DATE: '27/04/2003', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1B' + } + }, + { + DPA: { + UPRN: '10033529295', + UDPRN: '52154612', + ADDRESS: 'FLAT 1-5, 245-247, BAKER STREET, LONDON, NW1 6XE', + SUB_BUILDING_NAME: 'FLAT 1-5', + BUILDING_NAME: '245-247', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527839.0, + Y_COORDINATE: 182163.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'PP', + CLASSIFICATION_CODE_DESCRIPTION: 'Property Shell', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158439', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '10/02/2016', + ENTRY_DATE: '27/04/2003', + BLPU_STATE_DATE: '27/04/2003', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1E' + } + }, + { + DPA: { + UPRN: '10033625010', + UDPRN: '17646244', + ADDRESS: 'THE VOLUNTEER, 245-247, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'THE VOLUNTEER', + BUILDING_NAME: '245-247', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527839.0, + Y_COORDINATE: 182163.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'CR06', + CLASSIFICATION_CODE_DESCRIPTION: 'Public House / Bar / Nightclub', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158439', + WARD_CODE: 'E05013805', + PARENT_UPRN: '10033529295', + LAST_UPDATE_DATE: '17/05/2019', + ENTRY_DATE: '02/10/2014', + BLPU_STATE_DATE: '02/10/2014', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1R' + } + } + ] +} diff --git a/src/server/plugins/postcode-lookup/test/__stubs__/query.js b/src/server/plugins/postcode-lookup/test/__stubs__/query.js new file mode 100644 index 000000000..3fbfd2383 --- /dev/null +++ b/src/server/plugins/postcode-lookup/test/__stubs__/query.js @@ -0,0 +1,200 @@ +export const result = { + header: { + uri: 'https://api.os.uk/search/places/v1/find?query=baker%20street', + query: 'query=baker street', + offset: 0, + totalresults: 10, + format: 'JSON', + dataset: 'DPA', + lr: 'EN,CY', + maxresults: 100, + matchprecision: 1, + epoch: '121', + lastupdate: '2025-10-14', + output_srs: 'EPSG:27700' + }, + results: [ + { + DPA: { + UPRN: '250034655', + UDPRN: '1216958', + ADDRESS: 'BAKER STREET COTTAGE, BAKER STREET, FROME, BA11 3BL', + BUILDING_NAME: 'BAKER STREET COTTAGE', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'FROME', + POSTCODE: 'BA11 3BL', + RPC: '1', + X_COORDINATE: 377184.0, + Y_COORDINATE: 148060.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD04', + CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', + LOCAL_CUSTODIAN_CODE: 3300, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'SOMERSET', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000015533989', + WARD_CODE: 'E05014362', + PARISH_CODE: 'E04008560', + LAST_UPDATE_DATE: '31/03/2023', + ENTRY_DATE: '29/03/2000', + BLPU_STATE_DATE: '29/03/2000', + LANGUAGE: 'EN', + MATCH: 0.4, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '1L' + } + }, + { + DPA: { + UPRN: '10033336368', + UDPRN: '52500297', + ADDRESS: 'BAKER STREET CINEMA, BAKER STREET, ABERGAVENNY, NP7 5BB', + ORGANISATION_NAME: 'BAKER STREET CINEMA', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'ABERGAVENNY', + POSTCODE: 'NP7 5BB', + RPC: '2', + X_COORDINATE: 329751.36, + Y_COORDINATE: 214353.3, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'CL07CI', + CLASSIFICATION_CODE_DESCRIPTION: 'Cinema', + LOCAL_CUSTODIAN_CODE: 6840, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'MONMOUTHSHIRE', + COUNTRY_CODE: 'W', + COUNTRY_CODE_DESCRIPTION: 'This record is within Wales', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000021003409', + WARD_CODE: 'W05001775', + PARISH_CODE: 'W04001057', + PARENT_UPRN: '10033345846', + LAST_UPDATE_DATE: '25/02/2025', + ENTRY_DATE: '11/11/2005', + BLPU_STATE_DATE: '07/07/2010', + LANGUAGE: 'EN', + MATCH: 0.4, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '2A' + } + }, + { + DPA: { + UPRN: '10033336368', + UDPRN: '52500297', + ADDRESS: 'BAKER STREET CINEMA, BAKER STREET, Y FENNI, NP7 5BB', + ORGANISATION_NAME: 'BAKER STREET CINEMA', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'Y FENNI', + POSTCODE: 'NP7 5BB', + RPC: '2', + X_COORDINATE: 329751.36, + Y_COORDINATE: 214353.3, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'CL07CI', + CLASSIFICATION_CODE_DESCRIPTION: 'Cinema', + LOCAL_CUSTODIAN_CODE: 6840, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'MONMOUTHSHIRE', + COUNTRY_CODE: 'W', + COUNTRY_CODE_DESCRIPTION: 'This record is within Wales', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000021003409', + WARD_CODE: 'W05001775', + PARISH_CODE: 'W04001057', + PARENT_UPRN: '10033345846', + LAST_UPDATE_DATE: '25/02/2025', + ENTRY_DATE: '11/11/2005', + BLPU_STATE_DATE: '07/07/2010', + LANGUAGE: 'CY', + MATCH: 0.4, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '2A' + } + }, + { + DPA: { + UPRN: '100100269282', + UDPRN: '17105375', + ADDRESS: '6, BAKER STREET, Y FENNI, NP7 5BB', + BUILDING_NUMBER: '6', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'Y FENNI', + POSTCODE: 'NP7 5BB', + RPC: '2', + X_COORDINATE: 329754.0, + Y_COORDINATE: 214382.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD03', + CLASSIFICATION_CODE_DESCRIPTION: 'Semi-Detached', + LOCAL_CUSTODIAN_CODE: 6840, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'MONMOUTHSHIRE', + COUNTRY_CODE: 'W', + COUNTRY_CODE_DESCRIPTION: 'This record is within Wales', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000021003407', + WARD_CODE: 'W05001775', + PARISH_CODE: 'W04001057', + LAST_UPDATE_DATE: '17/10/2016', + ENTRY_DATE: '10/05/2001', + BLPU_STATE_DATE: '06/09/2016', + LANGUAGE: 'CY', + MATCH: 0.4, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '1L' + } + }, + { + DPA: { + UPRN: '100100269283', + UDPRN: '17105376', + ADDRESS: '8, BAKER STREET, Y FENNI, NP7 5BB', + BUILDING_NUMBER: '8', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'Y FENNI', + POSTCODE: 'NP7 5BB', + RPC: '1', + X_COORDINATE: 329753.0, + Y_COORDINATE: 214380.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD03', + CLASSIFICATION_CODE_DESCRIPTION: 'Semi-Detached', + LOCAL_CUSTODIAN_CODE: 6840, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'MONMOUTHSHIRE', + COUNTRY_CODE: 'W', + COUNTRY_CODE_DESCRIPTION: 'This record is within Wales', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000021003408', + WARD_CODE: 'W05001775', + PARISH_CODE: 'W04001057', + LAST_UPDATE_DATE: '17/10/2016', + ENTRY_DATE: '10/05/2001', + BLPU_STATE_DATE: '06/09/2016', + LANGUAGE: 'CY', + MATCH: 0.4, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '1N' + } + } + ] +} diff --git a/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js b/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js new file mode 100644 index 000000000..32893812a --- /dev/null +++ b/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js @@ -0,0 +1,53 @@ +export const result = { + header: { + uri: 'https://api.os.uk/search/places/v1/uprn?uprn=100023071949', + query: 'uprn=100023071949', + offset: 0, + totalresults: 1, + format: 'JSON', + dataset: 'DPA', + lr: 'EN,CY', + maxresults: 100, + epoch: '121', + lastupdate: '2025-10-14', + output_srs: 'EPSG:27700' + }, + results: [ + { + DPA: { + UPRN: '100023071949', + UDPRN: '17646242', + ADDRESS: 'SHERLOCK HOLMES MUSEUM, 221B, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'SHERLOCK HOLMES MUSEUM', + BUILDING_NAME: '221B', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '2', + X_COORDINATE: 527847.0, + Y_COORDINATE: 182144.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158436', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '23/09/2018', + ENTRY_DATE: '19/03/2001', + BLPU_STATE_DATE: '19/03/2001', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1F' + } + } + ] +} diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js new file mode 100644 index 000000000..49ca24a11 --- /dev/null +++ b/src/server/plugins/postcode-lookup/types.js @@ -0,0 +1,143 @@ +/** + * @typedef {{ + * ordnanceSurveyApiKey: string + * }} PostcodeLookupConfiguration + */ + +/** + * @typedef {{ + * name: string + * step?: string + * }} PostcodeLookupDispatchArgs + */ + +/** + * @typedef {{ + * sourceUrl: string, + * formName: string + * componentName: string + * componentTitle: string, + * componentHint?: string + * step?: string + * }} PostcodeLookupDispatchData + */ + +/** + * @typedef {{ + * initial: PostcodeLookupDispatchData + * details: PostcodeLookupDetailsData + * }} PostcodeLookupSessionData + */ + +// +// Model types +// + +/** + * The postcode lookup details form view model data + * @typedef {object} PostcodeLookupDetailsData + * @property {string} postcodeQuery - postcode query + * @property {string} buildingNameQuery - Building name or number query + */ + +// +// Route types +// + +/** + * Postcode lookup query params + * @typedef {object} PostcodeLookupQuery + * @property {string} [step] - step + */ + +/** + * @typedef {object} PostcodeLookupDetailsPayloadProperties + * @property {string} step - step + */ + +/** + * @typedef {PostcodeLookupDetailsData & PostcodeLookupDetailsPayloadProperties} PostcodeLookupDetailsPayload + */ + +/** + * @typedef {object} PostcodeLookupSelectPayload + * @property {string} step - step + * @property {string} uprn - postcode + */ + +/** + * Postcode lookup get request + * @typedef {object} PostcodeLookupGetRequestRefs + * @property {PostcodeLookupQuery} Query - Request query + */ + +/** + * Postcode lookup post request + * @typedef {object} PostcodeLookupPostRequestRefs + * @property {PostcodeLookupDetailsPayload | PostcodeLookupSelectPayload} Payload - Request payload + */ + +/** + * @typedef {PostcodeLookupGetRequestRefs | PostcodeLookupPostRequestRefs} PostcodeLookupRequestRefs + * @typedef {Request} PostcodeLookupGetRequest + * @typedef {Request} PostcodeLookupPostRequest + * @typedef {PostcodeLookupGetRequest | PostcodeLookupPostRequest} PostcodeLookupRequest + */ + +/** + * @typedef {object} PostcodeLookupManualPayload + * @property {string} addressLine1 - The address line 1 + * @property {string} addressLine2 - The address line 2 + * @property {string} town - The address town or city + * @property {string} county - The address county + * @property {string} postcode - The address postcode + */ + +// +// Service types +// + +/** + * @typedef {object} Address + * @property {string} uprn - The unique property reference + * @property {string} address - The full address + * @property {string} addressLine1 - Address line 1 + * @property {string} addressLine2 - Address line 2 + * @property {string} town - Address town + * @property {string} county - Address county + * @property {string} postcode - Address postcode + * @property {string} formatted - The full formatted address + */ + +/** + * OS places address response + * @typedef {object} DeliveryPointAddress + * @property {string} UPRN - Unique property reference number + * @property {string} UDPRN - Unique delivery point Reference Number + * @property {string} ADDRESS - Address + * @property {string} ORGANISATION_NAME - Organisation name + * @property {string} SUB_BUILDING_NAME - Sub building name + * @property {string} BUILDING_NAME - Building name + * @property {string} BUILDING_NUMBER - Building number + * @property {string} THOROUGHFARE_NAME - Throughfare name + * @property {string} DEPENDENT_LOCALITY - Dependent locality + * @property {string} POST_TOWN - Post town + * @property {string} POSTCODE - Postcode + */ + +/** + * OS places DPA response + * @typedef {object} DeliveryPointAddressItem + * @property {DeliveryPointAddress} DPA - Delivery point address + */ + +/** + * OS places DPA response + * @typedef {object} DeliveryPointAddressResult + * @property {DeliveryPointAddressItem[]} [results] - Delivery point address results + */ + +/** + * @import { Request } from '@hapi/hapi' + * @import { FormPayload } from '~/src/server/plugins/engine/types.js' + */ diff --git a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html new file mode 100644 index 000000000..6e3d98482 --- /dev/null +++ b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html @@ -0,0 +1,83 @@ +{% extends baseLayoutPath %} + +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/select/macro.njk" import govukSelect %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/inset-text/macro.njk" import govukInsetText %} +{% from "govuk/components/hint/macro.njk" import govukHint %} +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} + +{% block content %} +
+
+ {% if errors %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: errors + }) }} + {% endif %} + + {% include "partials/heading.html" %} + +
+ + + + {% switch step %} + {% case "details" %} + + {{ govukInput(fields.postcodeQuery) }} + {{ govukInput(fields.buildingNameQuery) }} + {% case "select" %} + + + {%- set detailsHtml -%} + {{ details.postcodeQuery }}{% if details.buildingNameQuery %} and {{ details.buildingNameQuery }}{% endif %} + {%- endset -%} + + {% if hasAddresses %} +

+ {{addressCount}} address{{ "es" if hasMultipleAddresses }} found for {{ detailsHtml | safe }}. {{ searchAgainLink.text }} +

+ {% endif %} + + {% if hasMultipleAddresses %} + + {{ govukSelect(fields.uprn) }} + {% elif singleAddress %} + + {{ govukInput(fields.uprn) }} + {{ govukInsetText({ + text: singleAddress.formatted + }) }} + {% else %} + +

We could not find an address that matches {{ detailsHtml | safe }}.

+ {% endif %} + {% case "manual" %} + + {% if hint %} + {{ govukHint(hint) }} + {% endif %} + + {{ govukInput(fields.addressLine1) }} + {{ govukInput(fields.addressLine2) }} + {{ govukInput(fields.town) }} + {{ govukInput(fields.county) }} + {{ govukInput(fields.postcode) }} + {% endswitch %} + +
+ {{ govukButton(buttons.continueButton) }} + + {% if buttons.manualLink %} +

or {{buttons.manualLink.text}}

+ {% elif buttons.detailsLink %} +

or {{buttons.detailsLink.text}}

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/src/server/postcode-lookup.test.ts b/src/server/postcode-lookup.test.ts new file mode 100644 index 000000000..e8d468fcd --- /dev/null +++ b/src/server/postcode-lookup.test.ts @@ -0,0 +1,64 @@ +import { type Server } from '@hapi/hapi' + +import { createServer } from '~/src/server/index.js' +import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' +import * as defaultServices from '~/src/server/plugins/engine/services/index.js' +import * as fixtures from '~/test/fixtures/index.js' + +jest.mock('~/src/server/plugins/engine/services/formsService.js') +jest.mock('~/src/server/plugins/engine/services/uploadService.js') + +describe('Postcode lookup plugin', () => { + let server: Server + + beforeEach(() => { + jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + }) + + afterAll(async () => { + await server.stop() + }) + + describe('Plugin registration', () => { + test('Registers plugin with ordnance survey key', async () => { + server = await createServer({ + services: defaultServices, + ordnanceSurveyApiKey: 'dummy' + }) + await server.initialize() + + expect(server.registrations).toHaveProperty( + '@defra/forms-engine-plugin/postcode-lookup', + { + name: '@defra/forms-engine-plugin/postcode-lookup', + options: { ordnanceSurveyApiKey: 'dummy' }, + version: undefined + } + ) + + expect( + server.table().find((route) => route.path === '/postcode-lookup') + ).toBeDefined() + }) + + test('Does not register plugin when no ordnance survey key is provided', async () => { + server = await createServer({ + services: defaultServices + }) + await server.initialize() + + expect(server.registrations).not.toHaveProperty( + '@defra/forms-engine-plugin/postcode-lookup', + { + name: '@defra/forms-engine-plugin/postcode-lookup', + options: { ordnanceSurveyApiKey: 'dummy' }, + version: undefined + } + ) + + expect( + server.table().find((route) => route.path === '/postcode-lookup') + ).toBeUndefined() + }) + }) +}) diff --git a/src/server/routes/types.ts b/src/server/routes/types.ts index 704fccac6..81ad60a12 100644 --- a/src/server/routes/types.ts +++ b/src/server/routes/types.ts @@ -48,10 +48,16 @@ export enum FormAction { Delete = 'delete', AddAnother = 'add-another', Send = 'send', - SaveAndExit = 'save-and-exit' + SaveAndExit = 'save-and-exit', + External = 'external' } export enum FormStatus { Draft = 'draft', Live = 'live' } + +export enum ExternalActions { + PostcodeLookup = 'postcode-lookup', + AnotherExternalAction = 'another-external-action' +} diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts index f181e7f01..b4e22af31 100644 --- a/src/server/schemas/index.ts +++ b/src/server/schemas/index.ts @@ -8,13 +8,11 @@ export const stateSchema = Joi.string() .required() export const actionSchema = Joi.string() - .valid( - FormAction.Continue, - FormAction.Validate, - FormAction.Delete, - FormAction.AddAnother, - FormAction.Send, - FormAction.SaveAndExit + .pattern(new RegExp(`^${FormAction.External}-[a-zA-Z-:]*$`)) + .allow( + ...Object.values(FormAction).filter( + (value) => value !== FormAction.External + ) ) .default(FormAction.Validate) .optional() diff --git a/src/server/types.ts b/src/server/types.ts index b5179ae00..b20881f28 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -53,6 +53,7 @@ export interface RouteConfig { onRequest?: OnRequestCallback saveAndExit?: PluginOptions['saveAndExit'] cacheServiceCreator?: (server: Server) => CacheService + ordnanceSurveyApiKey?: string } export interface OutputService { diff --git a/test/form/definitions/postcode-lookup.js b/test/form/definitions/postcode-lookup.js new file mode 100644 index 000000000..68cd472d9 --- /dev/null +++ b/test/form/definitions/postcode-lookup.js @@ -0,0 +1,49 @@ +import { + ComponentType, + ControllerType, + Engine, + SchemaVersion +} from '@defra/forms-model' + +export default /** @satisfies {FormDefinition} */ ({ + name: 'UkAddressField with Postcode lookup', + engine: Engine.V2, + schema: SchemaVersion.V2, + startPage: '/address', + pages: [ + { + title: 'Address', + path: '/address', + components: [ + { + type: ComponentType.UkAddressField, + title: 'What is your address?', + name: 'ybMHIv', + shortDescription: 'Address', + hint: '', + options: { + required: true, + usePostcodeLookup: true + }, + id: 'ebc6cc6d-2596-4860-b62d-98510b277ac4' + } + ], + next: [], + id: 'c7ab16e8-819a-43bd-83fa-14c479d23961' + }, + { + title: 'Summary', + path: '/summary', + controller: ControllerType.Summary, + components: [], + id: '8d2aba52-314e-4a5b-b502-47e205877de5' + } + ], + conditions: [], + sections: [], + lists: [] +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */ diff --git a/test/form/fields-optional.test.js b/test/form/fields-optional.test.js index 8df8bd1ef..61c8c1833 100644 --- a/test/form/fields-optional.test.js +++ b/test/form/fields-optional.test.js @@ -95,7 +95,8 @@ describe('Form fields (optional)', () => { addressField__addressLine2: '', addressField__town: '', addressField__county: '', - addressField__postcode: '' + addressField__postcode: '', + addressField__uprn: '' } } }, diff --git a/test/form/fields-required.test.js b/test/form/fields-required.test.js index acf08d397..b2b146446 100644 --- a/test/form/fields-required.test.js +++ b/test/form/fields-required.test.js @@ -111,14 +111,16 @@ describe('Form fields (required)', () => { addressField__addressLine2: '', addressField__town: '', addressField__county: '', - addressField__postcode: '' + addressField__postcode: '', + addressField__uprn: '' }, valid: { addressField__addressLine1: 'Richard Fairclough House', addressField__addressLine2: 'Knutsford Road', addressField__town: 'Warrington', addressField__county: 'Cheshire', - addressField__postcode: 'WA4 1HT' + addressField__postcode: 'WA4 1HT', + addressField__uprn: '' } } }, diff --git a/test/form/govuk-notify.test.js b/test/form/govuk-notify.test.js index 4faa98f55..7a4b1b3b7 100644 --- a/test/form/govuk-notify.test.js +++ b/test/form/govuk-notify.test.js @@ -358,6 +358,7 @@ describe('Submission journey test', () => { addressField__town: 'Town or city', addressField__county: 'Cheshire', addressField__postcode: 'CW1 1AB', + addressField__uprn: '', radiosField: 'privateLimitedCompany', selectField: '910400000', autocompleteField: '910400044', diff --git a/test/form/postcode-lookup.test.js b/test/form/postcode-lookup.test.js new file mode 100644 index 000000000..cb1570564 --- /dev/null +++ b/test/form/postcode-lookup.test.js @@ -0,0 +1,605 @@ +import { join } from 'node:path' + +import { within } from '@testing-library/dom' +import { StatusCodes } from 'http-status-codes' + +import { FORM_PREFIX } from '~/src/server/constants.js' +import { createServer } from '~/src/server/index.js' +import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' +import { + search, + searchByUPRN +} from '~/src/server/plugins/postcode-lookup/service.js' +import * as fixtures from '~/test/fixtures/index.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' +import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' +const basePath = `${FORM_PREFIX}/postcode-lookup` + +jest.mock('~/src/server/plugins/engine/services/formsService.js') +jest.mock('~/src/server/plugins/postcode-lookup/service.js') + +/** + * + * @param {Server} server + */ +async function initialiseJourney(server) { + const response = await server.inject({ + url: `${basePath}/address` + }) + + // Extract the session cookie + const csrfToken = getCookie(response, 'crumb') + const headers = getCookieHeader(response, ['session', 'crumb']) + + return { csrfToken, response, headers } +} + +describe('Postcode lookup form pages', () => { + /** @type {Server} */ + let server + + beforeAll(async () => { + server = await createServer({ + formFileName: 'postcode-lookup.js', + formFilePath: join(import.meta.dirname, 'definitions'), + enforceCsrf: true, + ordnanceSurveyApiKey: 'dummy' + }) + + await server.initialize() + }) + + beforeEach(() => { + jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + }) + + it('should render the source form page with a postcode lookup buttons', async () => { + const { container } = await renderResponse(server, { + url: `${basePath}/address` + }) + + const $actionButton = container.getByRole('button', { + name: 'Find an address' + }) + + expect($actionButton).toBeInTheDocument() + expect($actionButton.getAttribute('name')).toBe('action') + expect($actionButton.getAttribute('value')).toBe('external-ybMHIv') + + const $manualButton = container.getByRole('button', { + name: 'enter address manually' + }) + + expect($manualButton).toBeInTheDocument() + expect($manualButton.getAttribute('name')).toBe('action') + expect($manualButton.getAttribute('value')).toBe( + 'external-ybMHIv--step:manual' + ) + }) + + it('should return a single validation message', async () => { + const { csrfToken, headers } = await initialiseJourney(server) + + const payload = { + crumb: csrfToken + } + + const { response, container } = await renderResponse(server, { + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $errorSummary = container.getByRole('alert') + const $heading = within($errorSummary).getByRole('heading', { + name: 'There is a problem', + level: 2 + }) + expect($heading).toBeInTheDocument() + + const $errorItems = within($errorSummary).getAllByRole('listitem') + expect($errorItems).toHaveLength(1) + expect($errorItems[0]).toHaveTextContent('Enter address') + }) + + it('should dispatch to details page on POST', async () => { + let { csrfToken, response, headers } = await initialiseJourney(server) + + const payload = { + action: 'external-ybMHIv', + crumb: csrfToken + } + + response = await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/postcode-lookup') + }) + + it('should dispatch to manual page on POST with step arg', async () => { + let { csrfToken, response, headers } = await initialiseJourney(server) + + const payload = { + action: 'external-ybMHIv--step:manual', + crumb: csrfToken + } + + response = await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/postcode-lookup?step=manual') + }) + + it('should render the details page', async () => { + let { csrfToken, response, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + const payload = { + action: 'external-ybMHIv', + crumb: csrfToken + } + + response = await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/postcode-lookup') + + headers = getCookieHeader(response, ['session']) + + response = await server.inject({ + url: '/postcode-lookup', + method: 'GET', + headers + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + + it('should render the manual page', async () => { + let { csrfToken, response, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + const payload = { + action: 'external-ybMHIv--step:manual', + crumb: csrfToken + } + + response = await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/postcode-lookup?step=manual') + + headers = getCookieHeader(response, ['session']) + + response = await server.inject({ + url: '/postcode-lookup?step=manual', + method: 'GET', + headers + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + + it('should render validation errors after POST when no postcode is provided', async () => { + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-ybMHIv', + crumb: csrfToken + } + }) + + const { response, container } = await renderResponse(server, { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'details', + postcodeQuery: '', + buildingNameQuery: '', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + const $errorSummary = container.getByRole('alert') + + const $heading = within($errorSummary).getByRole('heading', { + name: 'There is a problem', + level: 2 + }) + expect($heading).toBeInTheDocument() + }) + + it('should render the select page after POST when multiple addresses are found', async () => { + jest.mocked(search).mockResolvedValueOnce([ + { + address: + 'PRIME MINISTER & FIRST LORD OF THE TREASURY, 10, DOWNING STREET, LONDON, SW1A 2AA', + addressLine1: 'Prime Minister & First Lord Of The Treasury 10', + addressLine2: 'Downing Street', + county: '', + formatted: + 'Prime Minister & First Lord Of The Treasury 10, Downing Street, London, SW1A 2AA', + postcode: 'SW1A 2AA', + town: 'London', + uprn: '100023336956' + }, + { + address: + 'CHANCELLOR & FIRST LORD OF THE TREASURY, 10, DOWNING STREET, LONDON, SW1A 2AA', + addressLine1: 'Chancellor & First Lord Of The Treasury 11', + addressLine2: 'Downing Street', + county: '', + formatted: + 'Chancellor & First Lord Of The Treasury 10, Downing Street, London, SW1A 2AA', + postcode: 'SW1A 2AA', + town: 'London', + uprn: '100023336957' + } + ]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-ybMHIv', + crumb: csrfToken + } + }) + + const { response: selectResponse, container } = await renderResponse( + server, + { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'details', + postcodeQuery: 'SW1A 2AA', + buildingNameQuery: '', + crumb: csrfToken + } + } + ) + + expect(selectResponse.statusCode).toBe(StatusCodes.OK) + + const $addressSelector = container.getByRole('combobox') + expect($addressSelector).toBeInTheDocument() + + const $addressSelectorOptions = container.getAllByRole('option') + expect($addressSelectorOptions).toHaveLength(3) + + const $useAddressButton = container.getByRole('button', { + name: 'Use this address' + }) + + expect($useAddressButton).toBeInTheDocument() + }) + + it('should render the select page after POST when a single address is found', async () => { + jest.mocked(search).mockResolvedValueOnce([ + { + address: 'SHERLOCK HOLMES MUSEUM, 221B, BAKER STREET, LONDON, NW1 6XE', + addressLine1: 'Sherlock Holmes Museum 221b', + addressLine2: 'Baker Street', + county: '', + formatted: 'Sherlock Holmes Museum 221b, Baker Street, London, NW1 6XE', + postcode: 'NW1 6XE', + town: 'London', + uprn: '100023071949' + } + ]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-ybMHIv', + crumb: csrfToken + } + }) + + const { response: selectResponse, container } = await renderResponse( + server, + { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'details', + postcodeQuery: 'NW1 6XE', + buildingNameQuery: '221B', + crumb: csrfToken + } + } + ) + + expect(selectResponse.statusCode).toBe(StatusCodes.OK) + + const $addressPostcodeDisplay = container.getByText('NW1 6XE', { + selector: 'strong' + }) + expect($addressPostcodeDisplay).toBeInTheDocument() + + const $addressBuildingNameDisplay = container.getByText('221B', { + selector: 'strong' + }) + expect($addressBuildingNameDisplay).toBeInTheDocument() + + const $addressDisplay = container.getByText( + 'Sherlock Holmes Museum 221b, Baker Street, London, NW1 6XE' + ) + expect($addressDisplay).toBeInTheDocument() + + const $useAddressButton = container.getByRole('button', { + name: 'Use this address' + }) + + expect($useAddressButton).toBeInTheDocument() + }) + + it('should render the select page after POST when a no addresses are found', async () => { + jest.mocked(search).mockResolvedValueOnce([]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-ybMHIv', + crumb: csrfToken + } + }) + + const { response: selectResponse, container } = await renderResponse( + server, + { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'details', + postcodeQuery: 'AA1 1AA', + buildingNameQuery: '100', + crumb: csrfToken + } + } + ) + + expect(selectResponse.statusCode).toBe(StatusCodes.OK) + + const $noAddressesFound = container.getByRole('heading', { + name: 'No address found', + level: 1 + }) + expect($noAddressesFound).toBeInTheDocument() + }) + + it('should redirect back to the source page after POST when multiple addresses are found', async () => { + jest.mocked(searchByUPRN).mockResolvedValueOnce([ + { + address: + 'PRIME MINISTER & FIRST LORD OF THE TREASURY, 10, DOWNING STREET, LONDON, SW1A 2AA', + addressLine1: 'Prime Minister & First Lord Of The Treasury 10', + addressLine2: 'Downing Street', + county: '', + formatted: + 'Prime Minister & First Lord Of The Treasury 10, Downing Street, London, SW1A 2AA', + postcode: 'SW1A 2AA', + town: 'London', + uprn: '100023336956' + } + ]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-ybMHIv', + crumb: csrfToken + } + }) + + let { response } = await renderResponse(server, { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'select', + uprn: '100023336956', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toEndWith('/address') + + // Follow the redirect back to the source + // page to exercise `importExternalComponentState` + response = await server.inject({ + url: `${basePath}/address`, + headers + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + + it('should render validation errors after POST when no address is selected', async () => { + jest.mocked(search).mockResolvedValueOnce([ + { + address: + 'PRIME MINISTER & FIRST LORD OF THE TREASURY, 10, DOWNING STREET, LONDON, SW1A 2AA', + addressLine1: 'Prime Minister & First Lord Of The Treasury 10', + addressLine2: 'Downing Street', + county: '', + formatted: + 'Prime Minister & First Lord Of The Treasury 10, Downing Street, London, SW1A 2AA', + postcode: 'SW1A 2AA', + town: 'London', + uprn: '100023336956' + } + ]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-ybMHIv', + crumb: csrfToken + } + }) + + const { response, container } = await renderResponse(server, { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'select', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + const $errorSummary = container.getByRole('alert') + + const $heading = within($errorSummary).getByRole('heading', { + name: 'There is a problem', + level: 2 + }) + expect($heading).toBeInTheDocument() + }) + + it('should render validation errors after POST to manual page when no address lines are provided', async () => { + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-ybMHIv', + crumb: csrfToken + } + }) + + const { response, container } = await renderResponse(server, { + url: '/postcode-lookup?step=manual', + method: 'POST', + headers, + payload: { + step: 'manual', + addressLine1: '', + addressLine2: '', + town: '', + county: '', + postcode: '', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + const $errorSummary = container.getByRole('alert') + + const $heading = within($errorSummary).getByRole('heading', { + name: 'There is a problem', + level: 2 + }) + expect($heading).toBeInTheDocument() + }) + + it('should redirect back to the source page after successful POST to manual page', async () => { + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-ybMHIv', + crumb: csrfToken + } + }) + + let { response } = await renderResponse(server, { + url: '/postcode-lookup?step=manual', + method: 'POST', + headers, + payload: { + step: 'manual', + addressLine1: '1 Street Name', + addressLine2: '', + town: 'Middletown', + county: '', + postcode: 'M15 5TN', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toEndWith('/address') + + // Follow the redirect back to the source + // page to exercise `importExternalComponentState` + response = await server.inject({ + url: `${basePath}/address`, + headers + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */