From d203d29730495b591f88f30801792a243cd04067 Mon Sep 17 00:00:00 2001 From: Aaron Scully Date: Mon, 2 Mar 2026 13:56:33 +0000 Subject: [PATCH] Updates to resolve type issues (when forms-engine-plugin package has relative paths, making types better resolvable) --- src/server/index.test.ts | 3 +- .../formAdapterEventPublisher.test.js | 14 ++- .../messaging/formAdapterEventPublisher.ts | 2 +- src/server/models/FeedbackPageViewModel.ts | 9 ++ .../models/SummaryViewModelWithEmail.ts | 10 ++ .../plugins/FeedbackPageController.test.ts | 14 ++- src/server/plugins/FeedbackPageController.ts | 16 +-- ...ageWithConfirmationEmailController.test.ts | 7 +- ...maryPageWithConfirmationEmailController.ts | 12 +- src/server/plugins/router.ts | 4 +- .../routes/save-and-exit-with-cache.test.js | 3 +- src/server/routes/save-and-exit.js | 28 +++-- src/server/routes/save-and-exit.test.js | 11 +- src/server/services/outputService.test.js | 105 ++++++++++-------- src/server/services/outputService.ts | 6 +- test/form/feedback.test.js | 5 +- 16 files changed, 157 insertions(+), 92 deletions(-) create mode 100644 src/server/models/FeedbackPageViewModel.ts create mode 100644 src/server/models/SummaryViewModelWithEmail.ts diff --git a/src/server/index.test.ts b/src/server/index.test.ts index aaa2ad100..c16564fef 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -494,7 +494,7 @@ describe('Model cache', () => { const mockYar = { flash: mockFlash } - const mockRequest = /** @type {any} */ { + const mockRequest = { params: { slug: 'test-form' }, @@ -511,6 +511,7 @@ describe('Model cache', () => { expect(pluginObject).toBeDefined() const saveAndExitFunc = pluginObject.options.saveAndExit expect(saveAndExitFunc).toBeDefined() + // @ts-expect-error - partial mock objects for tests saveAndExitFunc(mockRequest, mockH, undefined) expect(mockFlash).toHaveBeenCalledWith( 'SAVE_AND_EXIT_PAYLOAD', diff --git a/src/server/messaging/formAdapterEventPublisher.test.js b/src/server/messaging/formAdapterEventPublisher.test.js index 4383410e1..1942729b7 100644 --- a/src/server/messaging/formAdapterEventPublisher.test.js +++ b/src/server/messaging/formAdapterEventPublisher.test.js @@ -28,7 +28,7 @@ describe('formAdapterEventPublisher', () => { /** @type {FormAdapterSubmissionMessagePayload} */ let mockPayload - /** @type {any} */ + /** @type {{ send: jest.Mock }} */ let mockSnsClient beforeEach(() => { @@ -59,10 +59,14 @@ describe('formAdapterEventPublisher', () => { } }) - mockSnsClient = { - send: jest.fn() - } - jest.mocked(getSNSClient).mockReturnValue(mockSnsClient) + mockSnsClient = { send: jest.fn() } + jest + .mocked(getSNSClient) + .mockReturnValue( + /** @type {import('@aws-sdk/client-sns').SNSClient} */ ( + /** @type {unknown} */ (mockSnsClient) + ) + ) }) describe('publishFormAdapterEvent', () => { diff --git a/src/server/messaging/formAdapterEventPublisher.ts b/src/server/messaging/formAdapterEventPublisher.ts index eb97dfe39..0bf677add 100644 --- a/src/server/messaging/formAdapterEventPublisher.ts +++ b/src/server/messaging/formAdapterEventPublisher.ts @@ -13,7 +13,7 @@ const snsAdapterTopicArn = config.get('snsAdapterTopicArn') * Validate form adapter submission payload against schema * @param submissionPayload - Form submission payload to validate * @returns Validated payload - * @throws Error if validation fails + * @throws {Error} if validation fails */ function validateFormAdapterPayload( submissionPayload: FormAdapterSubmissionMessagePayload diff --git a/src/server/models/FeedbackPageViewModel.ts b/src/server/models/FeedbackPageViewModel.ts new file mode 100644 index 000000000..d1593fe63 --- /dev/null +++ b/src/server/models/FeedbackPageViewModel.ts @@ -0,0 +1,9 @@ +import { type FormPageViewModel } from '@defra/forms-engine-plugin/types' + +/** + * Extends {@link FormPageViewModel} with template variables specific to the feedback page. + */ +export interface FeedbackPageViewModel extends FormPageViewModel { + hidePhaseBanner?: boolean + submitButtonText: string +} diff --git a/src/server/models/SummaryViewModelWithEmail.ts b/src/server/models/SummaryViewModelWithEmail.ts new file mode 100644 index 000000000..8a6b8ebc7 --- /dev/null +++ b/src/server/models/SummaryViewModelWithEmail.ts @@ -0,0 +1,10 @@ +import { type SummaryViewModel } from '@defra/forms-engine-plugin/engine/models/SummaryViewModel.js' +import { type GovukField } from '@defra/forms-model' + +/** + * Extends {@link SummaryViewModel} with an optional confirmation email field + * rendered on the summary page when the user opts in to receive a confirmation email. + */ +export interface SummaryViewModelWithEmail extends SummaryViewModel { + userConfirmationEmailField?: GovukField +} diff --git a/src/server/plugins/FeedbackPageController.test.ts b/src/server/plugins/FeedbackPageController.test.ts index 03c259dcf..cd9def98f 100644 --- a/src/server/plugins/FeedbackPageController.test.ts +++ b/src/server/plugins/FeedbackPageController.test.ts @@ -21,10 +21,11 @@ describe('FeedbackPageController', () => { const response = { code: jest.fn().mockImplementation(() => response) } - const h: FormResponseToolkit = { + const h = { redirect: jest.fn().mockReturnValue(response), - view: jest.fn() - } + view: jest.fn(), + continue: Symbol('continue') + } as unknown as FormResponseToolkit beforeEach(() => { model = new FormModel(definition, { @@ -70,11 +71,12 @@ describe('FeedbackPageController', () => { errors: [ { name: 'PMPyjg', - path: '/feedback', - text: 'Select how you feel about this service' + path: ['feedback'], + text: 'Select how you feel about this service', + href: '#PMPyjg' } ] - } as FormContext + } as unknown as FormContext jest .spyOn(controller as unknown as QuestionPageController, 'getState') diff --git a/src/server/plugins/FeedbackPageController.ts b/src/server/plugins/FeedbackPageController.ts index 9d125f726..5c92d6c02 100644 --- a/src/server/plugins/FeedbackPageController.ts +++ b/src/server/plugins/FeedbackPageController.ts @@ -6,24 +6,25 @@ import { type FormContextRequest } from '@defra/forms-engine-plugin/engine/types.js' import { - type FormPageViewModel, type FormRequestPayload, type FormResponseToolkit } from '@defra/forms-engine-plugin/types' +import { type FeedbackPageViewModel } from '~/src/server/models/FeedbackPageViewModel.js' + export class FeedbackPageController extends QuestionPageController { allowSaveAndExit = false getViewModel( request: FormContextRequest, context: FormContext - ): FormPageViewModel { + ): FeedbackPageViewModel { const viewModel = super.getViewModel(request, context) return { ...viewModel, hidePhaseBanner: true, submitButtonText: 'Send feedback', - name: context.state.formName + name: context.state.formName as string | undefined } } @@ -63,10 +64,11 @@ export class FeedbackPageController extends QuestionPageController { // Save state await this.setState(request, state) - const summary = new SummaryPageController( - model, - context.pageMap.get(context.paths[0]) - ) + const pageController = context.pageMap.get(context.paths[0]) + if (!pageController) { + throw new Error('Summary page controller not found') + } + const summary = new SummaryPageController(model, pageController.pageDef) return summary.handleFormSubmit(request, context, h) } } diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts index 2bcd25407..c6bc44e50 100644 --- a/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts @@ -27,10 +27,11 @@ describe('SummaryPageWithConfirmationEmailController', () => { const response = { code: jest.fn().mockImplementation(() => response) } - const h: FormResponseToolkit = { + const h = { redirect: jest.fn().mockReturnValue(response), - view: jest.fn() - } + view: jest.fn(), + continue: Symbol('continue') + } as unknown as FormResponseToolkit beforeEach(() => { model = new FormModel(definition, { diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts index 2c578ce38..fb0571705 100644 --- a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts @@ -1,7 +1,6 @@ import { type PageController } from '@defra/forms-engine-plugin/controllers/PageController.js' import { type QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' import { SummaryPageController } from '@defra/forms-engine-plugin/controllers/SummaryPageController.js' -import { type SummaryViewModel } from '@defra/forms-engine-plugin/engine/models/SummaryViewModel.js' import { type FormContext, type FormContextRequest, @@ -17,6 +16,8 @@ import { import { type GovukField } from '@defra/forms-model' import Joi from 'joi' +import { type SummaryViewModelWithEmail } from '~/src/server/models/SummaryViewModelWithEmail.js' + export const CONFIRMATION_EMAIL_FIELD_NAME = 'userConfirmationEmailAddress' const schema = Joi.object().keys({ @@ -33,9 +34,12 @@ export class SummaryPageWithConfirmationEmailController extends SummaryPageContr getSummaryViewModel( request: FormContextRequest, context: FormContext - ): SummaryViewModel { - const viewModel = super.getSummaryViewModel(request, context) - const payerEmail = viewModel?.paymentState?.payerEmail + ): SummaryViewModelWithEmail { + const viewModel = super.getSummaryViewModel( + request, + context + ) as SummaryViewModelWithEmail + const payerEmail = viewModel.paymentState?.payerEmail // Fill in user confirmation email, if supplied from payment journey if (payerEmail) { const payload = (request.payload ?? {}) as FormPayload diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 89b53a1ea..a8e0f1914 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -159,7 +159,7 @@ export default { options }) - server.route<{ Params: { slug: string } }>({ + server.route({ method: 'get', path: '/help/cookies/{slug}', async handler(request, h) { @@ -171,7 +171,7 @@ export default { const state = await cacheService.getState(request) - const formId = state?.formId ?? '' + const formId = (state.formId ?? '') as string return h.view('help/cookies', { googleAnalyticsContainerId: config diff --git a/src/server/routes/save-and-exit-with-cache.test.js b/src/server/routes/save-and-exit-with-cache.test.js index d92f38bdb..d73a732d2 100644 --- a/src/server/routes/save-and-exit-with-cache.test.js +++ b/src/server/routes/save-and-exit-with-cache.test.js @@ -1,4 +1,5 @@ import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import { FormStatus } from '@defra/forms-model' import { StatusCodes } from 'http-status-codes' import { createServer } from '~/src/server/index.js' @@ -82,7 +83,7 @@ describe('Save-and-exit check routes', () => { // @ts-expect-error - allow partial objects for tests form: { id: FORM_ID, - status: 'draft', + status: FormStatus.Draft, isPreview: true } }) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 181e66f82..8e4389359 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -69,7 +69,9 @@ export default [ // (in case the current page wasn't yet validated and saved). // The current page state may be invalid so we don't want to push into the cache as normal properties. const cacheService = getCacheService(request.server) - const formState = await cacheService.getState(request) + const formState = await cacheService.getState( + /** @type {CacheRequest} */ (request) + ) const pagePayload = getPayloadFromFlash(request) const currentPagePayload = Array.isArray(pagePayload) ? {} @@ -92,7 +94,10 @@ export default [ mergeArrays: false } ) - await cacheService.setState(request, combinedState) + await cacheService.setState( + /** @type {CacheRequest} */ (request), + combinedState + ) } // Clear any previous save and exit session state @@ -124,7 +129,9 @@ export default [ question: securityQuestion, answer: securityAnswer } - const state = await cacheService.getState(request) + const state = await cacheService.getState( + /** @type {CacheRequest} */ (request) + ) await publishSaveAndExitEvent( metadata.id, @@ -136,7 +143,7 @@ export default [ ) // Clear all form data - await cacheService.clearState(request) + await cacheService.clearState(/** @type {CacheRequest} */ (request)) // Add email to session for the confirmation page request.yar.set(getKey(slug, status), email) @@ -351,7 +358,10 @@ export default [ if (validatedLink.validPassword) { // Restore state const cacheService = getCacheService(request.server) - await cacheService.setState(request, validatedLink.state) + await cacheService.setState( + /** @type {CacheRequest} */ (request), + validatedLink.state + ) const { isPreview, status } = validatedLink.form @@ -431,7 +441,10 @@ export default [ const { params } = request const { slug, state } = params const form = await getFormMetadata(slug) - const model = resumeSuccessViewModel(form, state) + const model = resumeSuccessViewModel( + form, + /** @type {FormStatus | undefined} */ (state) + ) return h.view(RESUME_SUCCESS, model) }, @@ -450,6 +463,7 @@ export default [ /** * @import { ServerRoute } from '@hapi/hapi' - * @import { FormPayload } from '@defra/forms-engine-plugin/engine/types.js' + * @import { CacheRequest, FormPayload } from '@defra/forms-engine-plugin/engine/types.js' + * @import { FormStatus } from '@defra/forms-model' * @import { SaveAndExitParams, SaveAndExitPayload, SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' */ diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js index 71d1ec116..95ef728bc 100644 --- a/src/server/routes/save-and-exit.test.js +++ b/src/server/routes/save-and-exit.test.js @@ -1,3 +1,4 @@ +import { FormStatus } from '@defra/forms-model' import { StatusCodes } from 'http-status-codes' import { createJoiError } from '~/src/server/helpers/error-helper.js' @@ -41,7 +42,7 @@ describe('Save-and-exit check routes', () => { // @ts-expect-error - allow partial objects for tests form: { isPreview: true, - status: 'draft' + status: FormStatus.Draft } }) @@ -66,7 +67,7 @@ describe('Save-and-exit check routes', () => { // @ts-expect-error - allow partial objects for tests form: { isPreview: true, - status: 'draft' + status: FormStatus.Draft } }) @@ -160,7 +161,7 @@ describe('Save-and-exit check routes', () => { // @ts-expect-error - allow partial objects for tests form: { isPreview: true, - status: 'draft' + status: FormStatus.Draft } }) @@ -185,7 +186,7 @@ describe('Save-and-exit check routes', () => { // @ts-expect-error - allow partial objects for tests form: { isPreview: true, - status: 'draft' + status: FormStatus.Draft } }) @@ -427,7 +428,7 @@ describe('Save-and-exit check routes', () => { // @ts-expect-error - allow partial objects for tests form: { isPreview: true, - status: 'draft' + status: FormStatus.Draft } }) diff --git a/src/server/services/outputService.test.js b/src/server/services/outputService.test.js index 0843f98d0..ac7b906b5 100644 --- a/src/server/services/outputService.test.js +++ b/src/server/services/outputService.test.js @@ -60,48 +60,59 @@ describe('OutputService', () => { outputService = new OutputService() - mockContext = /** @type {any} */ ({ - referenceNumber: 'REF-123456', - evaluationState: {}, - relevantState: {}, - relevantPages: [], - payload: {}, - state: {}, - errors: undefined, - paths: [], - isForceAccess: false, - data: {}, - pageDefMap: new Map(), - listDefMap: new Map(), - componentDefMap: new Map(), - pageMap: new Map(), - componentMap: new Map() - }) - - mockRequest = /** @type {any} */ ({ - params: { - formSlug: 'test-form', - path: '/test-form', - slug: 'test-form' - } - }) - - mockModel = /** @type {any} */ ({ - name: 'Test Form' - }) + mockContext = /** @type {FormContext} */ ( + /** @type {unknown} */ ({ + referenceNumber: 'REF-123456', + evaluationState: {}, + relevantState: {}, + relevantPages: [], + payload: {}, + state: {}, + errors: undefined, + paths: [], + isForceAccess: false, + data: {}, + pageDefMap: new Map(), + listDefMap: new Map(), + componentDefMap: new Map(), + pageMap: new Map(), + componentMap: new Map() + }) + ) + + mockRequest = /** @type {FormRequestPayload} */ ( + /** @type {unknown} */ ({ + params: { + formSlug: 'test-form', + path: '/test-form', + slug: 'test-form' + }, + payload: {} + }) + ) - mockItems = /** @type {any} */ ({ - main: { - items: [ - { name: 'field1', value: 'value1' }, - { name: 'field2', value: 'value2' } - ] - } - }) + mockModel = /** @type {FormModel} */ ( + /** @type {unknown} */ ({ + name: 'Test Form' + }) + ) + + mockItems = /** @type {DetailItem[]} */ ( + /** @type {unknown} */ ({ + main: { + items: [ + { name: 'field1', value: 'value1' }, + { name: 'field2', value: 'value2' } + ] + } + }) + ) - mockSubmitResponse = /** @type {any} */ ({ - retrievalKey: 'SUB-789' - }) + mockSubmitResponse = /** @type {SubmitResponsePayload} */ ( + /** @type {unknown} */ ({ + retrievalKey: 'SUB-789' + }) + ) mockFormMetadata = { id: 'form-123', @@ -165,12 +176,14 @@ describe('OutputService', () => { data: mockItems } - const mockRequestWithEmail = { - ...mockRequest, - payload: { - userConfirmationEmailAddress: 'my-email@test123.com' - } - } + const mockRequestWithEmail = /** @type {FormRequestPayload} */ ( + /** @type {unknown} */ ({ + ...mockRequest, + payload: { + userConfirmationEmailAddress: 'my-email@test123.com' + } + }) + ) mockFormatter.mockReturnValue(JSON.stringify(mockPayload)) diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index eafa70315..acecdc5eb 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -73,7 +73,7 @@ export class OutputService implements IOutputService { if (isFeedbackForm(model.def) && submissionPayload.data.main.formId) { // Override notification email to that of the related form (not the feedback form) - const relatedFormId = submissionPayload.data.main.formId + const relatedFormId = submissionPayload.data.main.formId as string const relatedMetadata = await getFormMetadataById(relatedFormId) if (!relatedMetadata.notificationEmail) { logger.info( @@ -94,9 +94,9 @@ export class OutputService implements IOutputService { return } - if (request.payload?.userConfirmationEmailAddress) { + if (request.payload.userConfirmationEmailAddress) { submissionPayload.meta.custom = { - userConfirmationEmail: request.payload?.userConfirmationEmailAddress + userConfirmationEmail: request.payload.userConfirmationEmailAddress } } diff --git a/test/form/feedback.test.js b/test/form/feedback.test.js index 0e504972e..5d49858f6 100644 --- a/test/form/feedback.test.js +++ b/test/form/feedback.test.js @@ -4,6 +4,7 @@ import { checkFormStatus, getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import { FormStatus } from '@defra/forms-model' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/services/formsService.js' @@ -43,7 +44,9 @@ describe('Feedback link', () => { .fn() .mockResolvedValue({ formId: '661e4ca5039739ef2902b214' }) })) - jest.mocked(checkFormStatus).mockReturnValue({ isPreview: true, state: {} }) + jest + .mocked(checkFormStatus) + .mockReturnValue({ isPreview: true, state: FormStatus.Live }) const { container } = await renderResponse(server, { method: 'GET', url: '/help/cookies/feedback'