Skip to content
4 changes: 3 additions & 1 deletion src/server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,9 @@ describe('Model cache', () => {

// Assert the live/live cache item has the correct updatedAt timestamp
expect(
getCacheItem(`${fixtures.form.metadata.id}_live_false`)?.updatedAt
getCacheItem(
`${fixtures.form.metadata.id}_live_false_${fixtures.form.metadata.notificationEmail}`
)?.updatedAt
).toBe(now2)

// Expect the cache size to remain unchanged
Expand Down
3 changes: 2 additions & 1 deletion src/server/plugins/engine/beta/form-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ describe('resolveFormModel helper', () => {
expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(2)
expect(mockCheckEmailAddressForLiveFormSubmission).toHaveBeenCalledWith(
undefined,
true
true,
false
)
expect(FormModel).toHaveBeenCalledWith(
definition,
Expand Down
12 changes: 8 additions & 4 deletions src/server/plugins/engine/beta/form-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { hasPaymentQuestionInForm } from '@defra/forms-model'
import Boom from '@hapi/boom'
import { type Request, type Server } from '@hapi/hapi'
import { isEqual } from 'date-fns'
Expand Down Expand Up @@ -73,7 +74,8 @@ export async function getFormModel(
options.basePath ??
buildBasePath(options.routePrefix ?? '', slug, formState, isPreview),
ordnanceSurveyApiKey: options.ordnanceSurveyApiKey,
formId: options.formId ?? metadata.id
formId: options.formId ?? metadata.id,
notificationEmail: metadata.notificationEmail
},
services,
options.controllers
Expand Down Expand Up @@ -153,7 +155,7 @@ export async function resolveFormModel(

const cache = server.app.models

const cacheKey = `${metadata.id}_${formState}_${isPreview}`
const cacheKey = `${metadata.id}_${formState}_${isPreview}_${metadata.notificationEmail}`
let entry = cache.get(cacheKey)

if (!entry || !isEqual(entry.updatedAt, stateMetadata.updatedAt)) {
Expand All @@ -170,7 +172,8 @@ export async function resolveFormModel(

checkEmailAddressForLiveFormSubmission(
metadata.notificationEmail,
isPreview
isPreview,
hasPaymentQuestionInForm(definition)
)

const routePrefix =
Expand All @@ -183,7 +186,8 @@ export async function resolveFormModel(
options.basePath ??
buildBasePath(routePrefix, slug, formState, isPreview),
ordnanceSurveyApiKey: options.ordnanceSurveyApiKey,
formId: options.formId ?? metadata.id
formId: options.formId ?? metadata.id,
notificationEmail: metadata.notificationEmail
},
services,
options.controllers
Expand Down
14 changes: 10 additions & 4 deletions src/server/plugins/engine/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ describe('Helpers', () => {
describe('checkEmailAddressForLiveFormSubmission', () => {
it('should throw an error if emailAddress is undefined and isPreview is false', () => {
expect(() =>
checkEmailAddressForLiveFormSubmission(undefined, false)
checkEmailAddressForLiveFormSubmission(undefined, false, false)
).toThrow(
Boom.internal(
'An email address is required to complete the form submission'
Expand All @@ -411,19 +411,25 @@ describe('Helpers', () => {

it('should not throw an error if emailAddress is defined and isPreview is false', () => {
expect(() =>
checkEmailAddressForLiveFormSubmission('test@example.com', false)
checkEmailAddressForLiveFormSubmission('test@example.com', false, false)
).not.toThrow()
})

it('should not throw an error if emailAddress is undefined and isPreview is true', () => {
expect(() =>
checkEmailAddressForLiveFormSubmission(undefined, true)
checkEmailAddressForLiveFormSubmission(undefined, true, false)
).not.toThrow()
})

it('should not throw an error if emailAddress is defined and isPreview is true', () => {
expect(() =>
checkEmailAddressForLiveFormSubmission('test@example.com', true)
checkEmailAddressForLiveFormSubmission('test@example.com', true, false)
).not.toThrow()
})

it('should not throw when emailAddress is undefined and the form has a PaymentField', () => {
expect(() =>
checkEmailAddressForLiveFormSubmission(undefined, false, true)
).not.toThrow()
})
})
Expand Down
6 changes: 4 additions & 2 deletions src/server/plugins/engine/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,11 @@ export function checkFormStatus(params?: FormParams) {

export function checkEmailAddressForLiveFormSubmission(
emailAddress: string | undefined,
isPreview: boolean
isPreview: boolean,
hasPayment: boolean
) {
if (!emailAddress && !isPreview) {
// Payment-only forms submit via GOV.UK Pay without a notification email.
if (!emailAddress && !isPreview && !hasPayment) {
throw Boom.internal(
'An email address is required to complete the form submission'
)
Expand Down
6 changes: 5 additions & 1 deletion src/server/plugins/engine/models/FormModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,15 @@ export class FormModel {

pageMap: Map<string, PageControllerClass>
componentMap: Map<string, Component>
notificationEmail?: string

constructor(
def: typeof this.def,
options: {
basePath: string
ordnanceSurveyApiKey?: string
formId?: string
notificationEmail?: string
},
services: Services = defaultServices,
controllers?: Record<string, typeof PageController>
Expand Down Expand Up @@ -155,6 +157,7 @@ export class FormModel {
this.values = result.value
this.basePath = options.basePath
this.ordnanceSurveyApiKey = options.ordnanceSurveyApiKey
this.notificationEmail = options.notificationEmail
this.conditions = {}
this.services = services
this.controllers = controllers
Expand Down Expand Up @@ -357,7 +360,8 @@ export class FormModel {
componentDefMap: this.componentDefMap,
pageMap: this.pageMap,
componentMap: this.componentMap,
referenceNumber: getReferenceNumber(state)
referenceNumber: getReferenceNumber(state),
notificationEmail: this.notificationEmail
}

// Validate current page
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
import {
SummaryPageController,
Expand Down Expand Up @@ -429,4 +430,114 @@ describe('SummaryPageController - Payment (DF-832)', () => {
).rejects.toBe(err)
})
})

describe('handleFormSubmit - notification email gate', () => {
function buildHandleFormSubmitHarness({
notificationEmail
}: {
notificationEmail?: string
}) {
const state = {
$$__referenceNumber: 'ref',
yesNoField: false
} as unknown as FormSubmissionState

const cacheService = {
setConfirmationState: jest.fn().mockResolvedValue(undefined),
clearState: jest.fn().mockResolvedValue(undefined),
resetComponentStates: jest.fn().mockResolvedValue(undefined)
} as unknown as CacheService

const getFormMetadata = jest.fn().mockResolvedValue({
notificationEmail
})

model.services = {
...model.services,
formsService: { ...model.services.formsService, getFormMetadata }
} as typeof model.services

const request = {
...requestPage,
method: 'post',
params: { ...requestPage.params, path: 'summary' },
server: {
plugins: { 'forms-engine-plugin': { cacheService } }
},
yar: { id: 'session-id' },
logger: { info: jest.fn(), error: jest.fn() }
} as unknown as FormRequestPayload

const context = model.getFormContext(request, state)
jest
.spyOn(controller, 'proceed')
.mockReturnValue(
undefined as unknown as ReturnType<typeof controller.proceed>
)

return { request, context, cacheService }
}

it('runs PaymentField onSubmit when notificationEmail is empty', async () => {
const onSubmitSpy = jest
.spyOn(PaymentField.prototype, 'onSubmit')
.mockResolvedValue(undefined)

const { request, context } = buildHandleFormSubmitHarness({
notificationEmail: ''
})

await controller.handleFormSubmit(request, context, h)

expect(onSubmitSpy).toHaveBeenCalled()
})

it('routes through submitForm when notificationEmail is set', async () => {
jest
.spyOn(PaymentField.prototype, 'onSubmit')
.mockResolvedValue(undefined)

model.services = {
...model.services,
formSubmissionService: {
...model.services.formSubmissionService,
submit: jest.fn().mockResolvedValue({ data: { reference: 'r' } })
},
outputService: {
...model.services.outputService,
submit: jest.fn().mockResolvedValue(undefined)
}
} as typeof model.services

const { request, context } = buildHandleFormSubmitHarness({
notificationEmail: 'notify@example.com'
})

await controller.handleFormSubmit(request, context, h)

expect(model.services.outputService.submit).toHaveBeenCalled()
})

it('routes errors through handleSubmissionError when finalisePaymentFields throws', async () => {
const err = new Error('pay boom')
jest.spyOn(PaymentField.prototype, 'onSubmit').mockRejectedValue(err)

const { request, context } = buildHandleFormSubmitHarness({
notificationEmail: ''
})

const handleSubmissionErrorSpy = jest
.spyOn(
controller as unknown as {
handleSubmissionError: (...args: unknown[]) => unknown
},
'handleSubmissionError'
)
.mockReturnValue(undefined)

await controller.handleFormSubmit(request, context, h)

expect(handleSubmissionErrorSpy).toHaveBeenCalledWith(err, request, h)
})
})
})
40 changes: 34 additions & 6 deletions src/server/plugins/engine/pageControllers/SummaryPageController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,12 +267,19 @@ export class SummaryPageController extends QuestionPageController {
const { notificationEmail } = formMetadata
const { isPreview } = checkFormStatus(request.params)

checkEmailAddressForLiveFormSubmission(notificationEmail, isPreview)
const hasPayment = this.findPaymentField() !== undefined
checkEmailAddressForLiveFormSubmission(
notificationEmail,
isPreview,
hasPayment
)

if (notificationEmail) {
const viewModel = this.getSummaryViewModel(request, context)
const viewModel = notificationEmail
? this.getSummaryViewModel(request, context)
: undefined

try {
try {
if (notificationEmail && viewModel) {
await submitForm(
context,
formMetadata,
Expand All @@ -281,9 +288,12 @@ export class SummaryPageController extends QuestionPageController {
model,
notificationEmail
)
} catch (error) {
return this.handleSubmissionError(error, request, h)
} else {
// Payment-only submission path — runs PaymentField capture hooks only.
await finalisePaymentFields(request, formMetadata, context, model)
}
} catch (error) {
return this.handleSubmissionError(error, request, h)
}

await cacheService.setConfirmationState(request, {
Expand Down Expand Up @@ -516,6 +526,24 @@ async function finaliseComponents(
}
}

/**
* Runs PaymentField capture hooks for the no-email submission path.
*/
async function finalisePaymentFields(
request: FormRequestPayload,
metadata: FormMetadata,
context: FormContext,
model: FormModel
) {
for (const page of model.pages) {
for (const field of page.collection.fields) {
if (field instanceof PaymentField) {
await field.onSubmit(request, metadata, context)
}
}
}
}

function submitData(
model: FormModel,
items: DetailItem[],
Expand Down
1 change: 1 addition & 0 deletions src/server/plugins/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export interface FormContext {
pageMap: Map<string, PageControllerClass>
componentMap: Map<string, Component>
referenceNumber: string
notificationEmail?: string
}

export type FormContextRequest = (
Expand Down
14 changes: 13 additions & 1 deletion src/server/plugins/engine/views/partials/preview-banner.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,22 @@
{% call govukNotificationBanner() %}
{% if not context.isForceAccess %}
<p class="govuk-notification-banner__heading govuk-!-margin-bottom-0">
This is a preview of a {{ previewMode }} form. Do not enter personal information.
This is a preview of a {{ previewMode }} form. Do not enter personal information.
</p>

{{ _closeLink() }}

{% if not context.notificationEmail %}
<p class="govuk-body govuk-!-margin-top-2">
You can complete this form to test the user journey. The form will appear to submit successfully, but because a destination email address has not been set, the following features will not trigger:
</p>
<ul class="govuk-list govuk-list--bullet">
<li>email delivery</li>
<li>file uploads</li>
<li>payments</li>
<li>SQS deliveries</li>
</ul>
{% endif %}
{% else %}
<p class="govuk-notification-banner__heading govuk-!-margin-bottom-0">
This is a preview of a {{ previewMode }} form page you are editing.
Expand Down
Loading
Loading