From b3a9d582a8444f218219cb688a6825a4a3250d54 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 13 May 2026 13:15:02 +0100 Subject: [PATCH 1/5] feat(DF-789): render unavailable view when form is offline --- .../plugins/engine/beta/form-context.ts | 9 ++--- .../plugins/engine/form-availability.ts | 31 ++++++++++++++++ .../engine/models/unavailable-view-model.ts | 36 +++++++++++++++++++ src/server/plugins/engine/plugin.ts | 3 ++ .../plugins/engine/unavailable-response.ts | 27 ++++++++++++++ .../plugins/engine/views/unavailable.html | 20 +++++++++++ 6 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 src/server/plugins/engine/form-availability.ts create mode 100644 src/server/plugins/engine/models/unavailable-view-model.ts create mode 100644 src/server/plugins/engine/unavailable-response.ts create mode 100644 src/server/plugins/engine/views/unavailable.html diff --git a/src/server/plugins/engine/beta/form-context.ts b/src/server/plugins/engine/beta/form-context.ts index 556f5aa99..6fe1df9f4 100644 --- a/src/server/plugins/engine/beta/form-context.ts +++ b/src/server/plugins/engine/beta/form-context.ts @@ -3,6 +3,7 @@ import { type Request, type Server } from '@hapi/hapi' import { isEqual } from 'date-fns' import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' +import { assertFormAvailable } from '~/src/server/plugins/engine/form-availability.js' import { checkEmailAddressForLiveFormSubmission, getCacheService @@ -52,6 +53,7 @@ export async function getFormModel( const formState = resolveState(state) const metadata = await formsService.getFormMetadata(slug) + assertFormAvailable(metadata) const definition = await formsService.getFormDefinition( metadata.id, @@ -134,6 +136,7 @@ export async function resolveFormModel( const { formsService } = services const metadata = await formsService.getFormMetadata(slug) + assertFormAvailable(metadata) const formState = resolveState(state) const isPreview = options.isPreview ?? isPreviewState(state, options) const stateMetadata = metadata[formState] @@ -145,10 +148,8 @@ export async function resolveFormModel( } // The models cache is created lazily per server instance - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!server.app.models) { - server.app.models = new Map() - } + + server.app.models ??= new Map() const cache = server.app.models as Map< string, diff --git a/src/server/plugins/engine/form-availability.ts b/src/server/plugins/engine/form-availability.ts new file mode 100644 index 000000000..b740750ed --- /dev/null +++ b/src/server/plugins/engine/form-availability.ts @@ -0,0 +1,31 @@ +import { type FormMetadata } from '@defra/forms-model' +import Boom from '@hapi/boom' + +interface OfflineBoomData { + offline: true + metadata: FormMetadata +} + +/** + * Throws when the form has been taken offline. The plugin's + * unavailable-response extension catches the marker and renders the + * "Sorry, this form is unavailable" view at HTTP 200. + */ +export function assertFormAvailable(metadata: FormMetadata): void { + if (metadata.offline === true) { + const data: OfflineBoomData = { offline: true, metadata } + throw Boom.boomify(new Error(`Form ${metadata.slug} is offline`), { + statusCode: 503, + data + }) + } +} + +/** Type guard for the offline Boom marker. */ +export function isOfflineBoom( + err: unknown +): err is Boom.Boom & { data: OfflineBoomData } { + if (!Boom.isBoom(err)) return false + const data = err.data as Partial | null | undefined + return data?.offline === true && !!data.metadata +} diff --git a/src/server/plugins/engine/models/unavailable-view-model.ts b/src/server/plugins/engine/models/unavailable-view-model.ts new file mode 100644 index 000000000..244607516 --- /dev/null +++ b/src/server/plugins/engine/models/unavailable-view-model.ts @@ -0,0 +1,36 @@ +import { type FormMetadata } from '@defra/forms-model' + +export interface UnavailableViewModel { + pageTitle: string + formTitle: string + organisationName: string + phoneLines?: string[] +} + +/** + * Defra organisations carry an abbreviation suffix on the enum value, e.g. + * "Rural Payments Agency – RPA". The unavailable page reads cleanly without it. + */ +function stripOrgSuffix(organisation: string) { + return organisation.split(' – ')[0] +} + +function splitPhoneLines(phone: string | undefined) { + if (!phone) return undefined + const lines = phone + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + return lines.length > 0 ? lines : undefined +} + +export function unavailableViewModel( + metadata: FormMetadata +): UnavailableViewModel { + return { + pageTitle: 'Sorry, this form is unavailable', + formTitle: metadata.title, + organisationName: stripOrgSuffix(metadata.organisation), + phoneLines: splitPhoneLines(metadata.contact?.phone) + } +} diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 2fec65366..ff24cd6b8 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -15,6 +15,7 @@ import { getRoutes as getQuestionRoutes } from '~/src/server/plugins/engine/rout import { getRoutes as getRepeaterItemDeleteRoutes } from '~/src/server/plugins/engine/routes/repeaters/item-delete.js' import { getRoutes as getRepeaterSummaryRoutes } from '~/src/server/plugins/engine/routes/repeaters/summary.js' import { type PluginOptions } from '~/src/server/plugins/engine/types.js' +import { registerUnavailableResponse } from '~/src/server/plugins/engine/unavailable-response.js' import { registerVision } from '~/src/server/plugins/engine/vision.js' import { mapPlugin } from '~/src/server/plugins/map/index.js' import { postcodeLookupPlugin } from '~/src/server/plugins/postcode-lookup/index.js' @@ -129,5 +130,7 @@ export const plugin = { ] server.route(routes as unknown as ServerRoute[]) // TODO + + registerUnavailableResponse(server) } } satisfies Plugin diff --git a/src/server/plugins/engine/unavailable-response.ts b/src/server/plugins/engine/unavailable-response.ts new file mode 100644 index 000000000..fede89cc9 --- /dev/null +++ b/src/server/plugins/engine/unavailable-response.ts @@ -0,0 +1,27 @@ +import { type Request, type ResponseToolkit, type Server } from '@hapi/hapi' + +import { isOfflineBoom } from '~/src/server/plugins/engine/form-availability.js' +import { unavailableViewModel } from '~/src/server/plugins/engine/models/unavailable-view-model.js' + +/** + * Registers a server-wide onPreResponse extension that intercepts the offline + * Boom thrown and renders the unavailable view. + * + * Must be registered after the engine's routes so it sees their responses, + * but before any global error-page handler that would re-shape Boom errors. + */ +export function registerUnavailableResponse(server: Server) { + server.ext('onPreResponse', (request: Request, h: ResponseToolkit) => { + const response = request.response + if (!isOfflineBoom(response)) { + return h.continue + } + + return h + .view('unavailable', unavailableViewModel(response.data.metadata)) + .header('Cache-Control', 'no-store, no-cache, must-revalidate') + .header('X-Robots-Tag', 'noindex, nofollow') + .code(200) + .takeover() + }) +} diff --git a/src/server/plugins/engine/views/unavailable.html b/src/server/plugins/engine/views/unavailable.html new file mode 100644 index 000000000..63a449f5c --- /dev/null +++ b/src/server/plugins/engine/views/unavailable.html @@ -0,0 +1,20 @@ +{% extends baseLayoutPath %} + +{% block content %} +
+
+

Sorry, this form is unavailable

+

'{{ formTitle }}' has been archived and is no longer available

+

Contact the {{ organisationName }}.

+ + {% if phoneLines %} +
    + {% for line in phoneLines %} +
  • {{ line }}
  • + {% endfor %} +
+

Find out about call charges

+ {% endif %} +
+
+{% endblock %} From a53e38411ac39ef71c02e09bfcbb973def0f4038 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 13 May 2026 18:51:00 +0100 Subject: [PATCH 2/5] chore: update @defra/forms-model dependency to version 3.0.663 in package.json and package-lock.json --- package-lock.json | 43 +++++-------------------------------------- package.json | 2 +- 2 files changed, 6 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 262490521..3f312ec24 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.655", + "@defra/forms-model": "^3.0.663", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.22-alpha", "@elastic/ecs-pino-format": "^1.5.0", @@ -236,7 +236,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2082,7 +2081,6 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2225,7 +2223,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2265,7 +2262,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3517,9 +3513,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.655", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.655.tgz", - "integrity": "sha512-tElgwckuEK5I3FtC6a8FLBGbsZw7yOlVZK54EK0sH1Xyg9tDD2z91NmunAkwpO7HQlcpJPgoGsAxNCWYvcqljw==", + "version": "3.0.663", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.663.tgz", + "integrity": "sha512-hBP+Y/mt/5vI0IaqcYxT0ExpLUfTRngMORqgNjyVvymW8kEYeBMNK8mYQ92Qz4pz4GMKV8I460j3skxRlpNNaA==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -4416,7 +4412,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.1.tgz", "integrity": "sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w==", "license": "MIT", - "peer": true, "dependencies": { "@docusaurus/babel": "3.10.1", "@docusaurus/bundler": "3.10.1", @@ -4591,7 +4586,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.1.tgz", "integrity": "sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ==", "license": "MIT", - "peer": true, "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/logger": "3.10.1", @@ -4625,7 +4619,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.1.tgz", "integrity": "sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==", "license": "MIT", - "peer": true, "dependencies": { "@docusaurus/mdx-loader": "3.10.1", "@docusaurus/module-type-aliases": "3.10.1", @@ -7365,7 +7358,6 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -11313,7 +11305,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -11510,7 +11501,6 @@ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", @@ -11550,7 +11540,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -12355,7 +12344,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -12432,7 +12420,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -13481,7 +13468,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -13840,7 +13826,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -17382,7 +17367,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -17570,7 +17554,6 @@ "integrity": "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", @@ -21364,7 +21347,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -22582,7 +22564,6 @@ "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -22649,7 +22630,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -27563,7 +27543,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -28960,7 +28939,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -29538,7 +29516,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -29548,7 +29525,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -29593,7 +29569,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" }, @@ -29622,7 +29597,6 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -31149,7 +31123,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -32402,7 +32375,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -33152,7 +33124,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -33389,8 +33360,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.21.0", @@ -33398,7 +33368,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -33593,7 +33562,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -34270,7 +34238,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index f5de40cb0..3e4ee1d6b 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.655", + "@defra/forms-model": "^3.0.663", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.22-alpha", "@elastic/ecs-pino-format": "^1.5.0", From b0aca05b38b71241108d8c30afee737ef2b86ef2 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 13 May 2026 19:02:35 +0100 Subject: [PATCH 3/5] chore: fix CI check failures (formatting, linting, and typing) --- src/server/index.test.ts | 6 +++--- src/server/plugins/engine/beta/form-context.ts | 7 ++----- src/server/plugins/engine/components/helpers/geospatial.ts | 2 +- src/server/plugins/engine/form-availability.ts | 4 ++-- src/server/plugins/engine/unavailable-response.ts | 4 +++- src/typings/hapi/index.d.ts | 2 +- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index ebd747845..919d7a5d9 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -27,11 +27,11 @@ describe('Model cache', () => { let server: Server const getCacheSize = () => { - return server.app.models.size + return server.app.models?.size ?? 0 } const getCacheItem = (key: string) => { - return server.app.models.get(key) + return server.app.models?.get(key) } beforeAll(async () => { @@ -43,7 +43,7 @@ describe('Model cache', () => { beforeEach(() => { jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) - server.app.models.clear() + server.app.models?.clear() }) afterAll(async () => { diff --git a/src/server/plugins/engine/beta/form-context.ts b/src/server/plugins/engine/beta/form-context.ts index 6fe1df9f4..88b06839d 100644 --- a/src/server/plugins/engine/beta/form-context.ts +++ b/src/server/plugins/engine/beta/form-context.ts @@ -148,13 +148,10 @@ export async function resolveFormModel( } // The models cache is created lazily per server instance - + server.app.models ??= new Map() - const cache = server.app.models as Map< - string, - { model: FormModel; updatedAt: Date } - > + const cache = server.app.models const cacheKey = `${metadata.id}_${formState}_${isPreview}` let entry = cache.get(cacheKey) diff --git a/src/server/plugins/engine/components/helpers/geospatial.ts b/src/server/plugins/engine/components/helpers/geospatial.ts index 7ccc01bd0..baecedb67 100644 --- a/src/server/plugins/engine/components/helpers/geospatial.ts +++ b/src/server/plugins/engine/components/helpers/geospatial.ts @@ -116,7 +116,7 @@ export function getGeospatialSchema(country?: GeospatialFieldOptionsCountry) { return value } - const result = booleanWithin(value, countryFeature) + const result = booleanWithin(value as Geometry | Feature, countryFeature) if (!result) { return helpers.error('any.custom', { diff --git a/src/server/plugins/engine/form-availability.ts b/src/server/plugins/engine/form-availability.ts index b740750ed..214df81fb 100644 --- a/src/server/plugins/engine/form-availability.ts +++ b/src/server/plugins/engine/form-availability.ts @@ -1,7 +1,7 @@ import { type FormMetadata } from '@defra/forms-model' import Boom from '@hapi/boom' -interface OfflineBoomData { +export interface OfflineBoomData { offline: true metadata: FormMetadata } @@ -24,7 +24,7 @@ export function assertFormAvailable(metadata: FormMetadata): void { /** Type guard for the offline Boom marker. */ export function isOfflineBoom( err: unknown -): err is Boom.Boom & { data: OfflineBoomData } { +): err is Boom.Boom & { data: OfflineBoomData } { if (!Boom.isBoom(err)) return false const data = err.data as Partial | null | undefined return data?.offline === true && !!data.metadata diff --git a/src/server/plugins/engine/unavailable-response.ts b/src/server/plugins/engine/unavailable-response.ts index fede89cc9..768924b79 100644 --- a/src/server/plugins/engine/unavailable-response.ts +++ b/src/server/plugins/engine/unavailable-response.ts @@ -17,8 +17,10 @@ export function registerUnavailableResponse(server: Server) { return h.continue } + const { metadata } = response.data + return h - .view('unavailable', unavailableViewModel(response.data.metadata)) + .view('unavailable', unavailableViewModel(metadata)) .header('Cache-Control', 'no-store, no-cache, must-revalidate') .header('X-Robots-Tag', 'noindex, nofollow') .code(200) diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index fa7577016..dcfff8c73 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -59,7 +59,7 @@ declare module '@hapi/hapi' { interface ServerApplicationState { model?: FormModel - models: Map + models?: Map } } From fdb7446a24653b35fc6d8bc3de4cd4adec0fa7c8 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 13 May 2026 20:07:16 +0100 Subject: [PATCH 4/5] test: increase coverage for form offline feature --- .../plugins/engine/form-availability.test.ts | 63 +++++++++++ .../models/unavailable-view-model.test.ts | 62 +++++++++++ .../engine/unavailable-response.test.ts | 105 ++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 src/server/plugins/engine/form-availability.test.ts create mode 100644 src/server/plugins/engine/models/unavailable-view-model.test.ts create mode 100644 src/server/plugins/engine/unavailable-response.test.ts diff --git a/src/server/plugins/engine/form-availability.test.ts b/src/server/plugins/engine/form-availability.test.ts new file mode 100644 index 000000000..aa50c8f95 --- /dev/null +++ b/src/server/plugins/engine/form-availability.test.ts @@ -0,0 +1,63 @@ +import Boom from '@hapi/boom' + +import { + assertFormAvailable, + isOfflineBoom +} from '~/src/server/plugins/engine/form-availability.js' +import { metadata } from '~/test/fixtures/form.js' + +describe('form-availability', () => { + describe('assertFormAvailable', () => { + it('should do nothing if form is online', () => { + expect(() => + assertFormAvailable({ ...metadata, offline: false }) + ).not.toThrow() + expect(() => + assertFormAvailable({ ...metadata, offline: undefined }) + ).not.toThrow() + }) + + it('should throw a 503 Boom error if form is offline', () => { + expect(() => assertFormAvailable({ ...metadata, offline: true })).toThrow( + expect.objectContaining({ + message: `Form ${metadata.slug} is offline`, + data: { + offline: true, + metadata: { ...metadata, offline: true } + } + }) + ) + }) + }) + + describe('isOfflineBoom', () => { + it('should return false for non-Boom errors', () => { + expect(isOfflineBoom(new Error('test'))).toBe(false) + expect(isOfflineBoom(null)).toBe(false) + expect(isOfflineBoom({})).toBe(false) + }) + + it('should return false for Boom errors without offline marker', () => { + expect(isOfflineBoom(Boom.notFound())).toBe(false) + expect(isOfflineBoom(Boom.badRequest('test', { offline: false }))).toBe( + false + ) + }) + + it('should return true for valid offline Boom errors', () => { + const offlineErr = Boom.boomify(new Error('offline'), { + statusCode: 503, + data: { offline: true, metadata } + }) + expect(isOfflineBoom(offlineErr)).toBe(true) + }) + + it('should return false if metadata is missing from data', () => { + const invalidErr = Boom.boomify(new Error('offline'), { + statusCode: 503, + data: { offline: true } + }) + expect(isOfflineBoom(invalidErr)).toBe(false) + }) + }) +}) diff --git a/src/server/plugins/engine/models/unavailable-view-model.test.ts b/src/server/plugins/engine/models/unavailable-view-model.test.ts new file mode 100644 index 000000000..dc9ab8636 --- /dev/null +++ b/src/server/plugins/engine/models/unavailable-view-model.test.ts @@ -0,0 +1,62 @@ +import { type FormMetadata } from '@defra/forms-model' + +import { unavailableViewModel } from '~/src/server/plugins/engine/models/unavailable-view-model.js' +import { metadata } from '~/test/fixtures/form.js' + +describe('unavailableViewModel', () => { + it('should return the correct view model with basic metadata', () => { + const result = unavailableViewModel(metadata) + expect(result).toEqual({ + pageTitle: 'Sorry, this form is unavailable', + formTitle: 'Test form', + organisationName: 'Defra', + phoneLines: undefined + }) + }) + + it('should strip the organisation suffix if present', () => { + const result = unavailableViewModel({ + ...metadata, + organisation: 'Rural Payments Agency – RPA' + } as FormMetadata) + expect(result.organisationName).toBe('Rural Payments Agency') + }) + + it('should handle multiple phone lines correctly', () => { + const result = unavailableViewModel({ + ...metadata, + contact: { + phone: '01234 567 890\n09876 543 210' + } + } as FormMetadata) + expect(result.phoneLines).toEqual(['01234 567 890', '09876 543 210']) + }) + + it('should filter out empty phone lines and trim whitespace', () => { + const result = unavailableViewModel({ + ...metadata, + contact: { + phone: ' 01234 567 890 \n \n 09876 543 210 ' + } + } as FormMetadata) + expect(result.phoneLines).toEqual(['01234 567 890', '09876 543 210']) + }) + + it('should return undefined if phone is empty or only whitespace', () => { + const result = unavailableViewModel({ + ...metadata, + contact: { + phone: ' \n ' + } + } as FormMetadata) + expect(result.phoneLines).toBeUndefined() + }) + + it('should handle missing contact property', () => { + const result = unavailableViewModel({ + ...metadata, + contact: undefined + } as FormMetadata) + expect(result.phoneLines).toBeUndefined() + }) +}) diff --git a/src/server/plugins/engine/unavailable-response.test.ts b/src/server/plugins/engine/unavailable-response.test.ts new file mode 100644 index 000000000..d5aca4118 --- /dev/null +++ b/src/server/plugins/engine/unavailable-response.test.ts @@ -0,0 +1,105 @@ +import Boom from '@hapi/boom' +import { + type Lifecycle, + type Request, + type ResponseToolkit, + type Server +} from '@hapi/hapi' + +import * as availability from '~/src/server/plugins/engine/form-availability.js' +import * as viewModel from '~/src/server/plugins/engine/models/unavailable-view-model.js' +import { registerUnavailableResponse } from '~/src/server/plugins/engine/unavailable-response.js' +import { metadata } from '~/test/fixtures/form.js' + +describe('registerUnavailableResponse', () => { + let mockServer: Server + let extensionHandler: ( + request: Request, + h: ResponseToolkit + ) => ReturnType + + beforeEach(() => { + mockServer = { + ext: jest.fn().mockImplementation((event, handler) => { + if (event === 'onPreResponse') { + extensionHandler = (handler as Lifecycle.Method).bind(null) + } + }) + } as unknown as Server + + registerUnavailableResponse(mockServer) + }) + + it('should register an onPreResponse extension', () => { + expect(mockServer.ext).toHaveBeenCalledWith( + 'onPreResponse', + expect.any(Function) + ) + }) + + it('should continue if error is not an offline Boom', async () => { + const mockRequest = { response: Boom.notFound() } as Request + const mockH = { continue: Symbol('continue') } as unknown as ResponseToolkit + + const isOfflineBoomSpy = jest + .spyOn(availability, 'isOfflineBoom') + .mockReturnValue(false) + + const result = await extensionHandler(mockRequest, mockH) + expect(result).toBe(mockH.continue) + isOfflineBoomSpy.mockRestore() + }) + + it('should render unavailable view if error is an offline Boom', async () => { + const offlineData = { offline: true, metadata } + const mockResponse = Boom.boomify(new Error('offline'), { + statusCode: 503, + data: offlineData + }) + const mockRequest = { response: mockResponse } as Request + + const mockViewResponse = { + header: jest.fn().mockReturnThis(), + code: jest.fn().mockReturnThis(), + takeover: jest.fn().mockReturnThis() + } + const mockH = { + view: jest.fn().mockReturnValue(mockViewResponse) + } as unknown as ResponseToolkit + + const isOfflineBoomSpy = jest + .spyOn(availability, 'isOfflineBoom') + .mockReturnValue(true) + const unavailableViewModelSpy = jest + .spyOn(viewModel, 'unavailableViewModel') + .mockReturnValue({ + pageTitle: 'Unavailable', + formTitle: 'Test', + organisationName: 'Defra' + }) + + const result = await extensionHandler(mockRequest, mockH) + + expect(availability.isOfflineBoom).toHaveBeenCalledWith(mockResponse) + expect(viewModel.unavailableViewModel).toHaveBeenCalledWith(metadata) + expect(mockH.view).toHaveBeenCalledWith('unavailable', { + pageTitle: 'Unavailable', + formTitle: 'Test', + organisationName: 'Defra' + }) + expect(mockViewResponse.header).toHaveBeenCalledWith( + 'Cache-Control', + 'no-store, no-cache, must-revalidate' + ) + expect(mockViewResponse.header).toHaveBeenCalledWith( + 'X-Robots-Tag', + 'noindex, nofollow' + ) + expect(mockViewResponse.code).toHaveBeenCalledWith(200) + expect(mockViewResponse.takeover).toHaveBeenCalled() + expect(result).toBe(mockViewResponse) + + isOfflineBoomSpy.mockRestore() + unavailableViewModelSpy.mockRestore() + }) +}) From 17db87a44b87d591f00063704e38e6105bf22592 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 14 May 2026 13:09:47 +0100 Subject: [PATCH 5/5] chore: add full stop to unavailable message and reiterate registration order comment --- src/server/plugins/engine/plugin.ts | 3 +++ src/server/plugins/engine/views/unavailable.html | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index ff24cd6b8..fb715d23d 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -131,6 +131,9 @@ export const plugin = { server.route(routes as unknown as ServerRoute[]) // TODO + // Registration order is important: must be registered after the engine's + // routes so it sees their responses, but before any global error-page + // handler that would re-shape Boom errors. registerUnavailableResponse(server) } } satisfies Plugin diff --git a/src/server/plugins/engine/views/unavailable.html b/src/server/plugins/engine/views/unavailable.html index 63a449f5c..f82fb10c9 100644 --- a/src/server/plugins/engine/views/unavailable.html +++ b/src/server/plugins/engine/views/unavailable.html @@ -4,7 +4,7 @@

Sorry, this form is unavailable

-

'{{ formTitle }}' has been archived and is no longer available

+

'{{ formTitle }}' has been archived and is no longer available.

Contact the {{ organisationName }}.

{% if phoneLines %}