Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion jest.setup.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ 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'
process.env.PAYMENT_PROVIDER_API_KEY_TEST_formid = 'test-api-key'
process.env.PAYMENT_PROVIDER_API_KEY_TEST = 'test-api-key'
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 @@ -150,7 +150,8 @@ describe('getFormModel helper', () => {
formsService: {
getFormMetadata: jest.fn().mockResolvedValue(metadata),
getFormMetadataById: jest.fn(),
getFormDefinition: jest.fn().mockResolvedValue(definition)
getFormDefinition: jest.fn().mockResolvedValue(definition),
getFormSecret: jest.fn()
},
formSubmissionService: {
persistFiles: jest.fn(),
Expand Down
144 changes: 139 additions & 5 deletions src/server/plugins/engine/components/PaymentField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type FormMetadata,
type PaymentFieldComponent
} from '@defra/forms-model'
import { StatusCodes } from 'http-status-codes'

import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
Expand All @@ -14,18 +15,26 @@ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
import { PaymentPreAuthError } from '~/src/server/plugins/engine/pageControllers/errors.js'
import {
type FormContext,
type FormValue
type FormValue,
type PaymentExternalArgs
} from '~/src/server/plugins/engine/types.js'
import {
type FormRequestPayload,
type FormResponseToolkit
} from '~/src/server/routes/types.js'
import { get, post, postJson } from '~/src/server/services/httpService.js'
import { type Services } from '~/src/server/types.js'
import definition from '~/test/form/definitions/blank.js'
import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'

jest.mock('~/src/server/services/httpService.ts')

const mockServices = {
formsService: {
getFormSecret: () => 'secret-value'
}
} as unknown as Services

describe('PaymentField', () => {
let model: FormModel

Expand Down Expand Up @@ -250,6 +259,7 @@ describe('PaymentField', () => {

const collection = new ComponentCollection([def], { model })
const paymentField = collection.fields[0] as PaymentField
paymentField.model = { services: mockServices } as unknown as FormModel

describe('dispatcher', () => {
it('should create payment and redirect to gov pay', async () => {
Expand Down Expand Up @@ -277,7 +287,8 @@ describe('PaymentField', () => {
model: {
formId: 'formid',
basePath: 'base-path',
name: 'PaymentModel'
name: 'PaymentModel',
services: mockServices
},
getState: jest
.fn()
Expand All @@ -287,7 +298,7 @@ describe('PaymentField', () => {
sourceUrl: 'http://localhost:3009/test-payment',
isLive: false,
isPreview: true
}
} as unknown as PaymentExternalArgs
// @ts-expect-error - partial mock
jest.mocked(postJson).mockResolvedValueOnce({
payload: {
Expand Down Expand Up @@ -342,7 +353,8 @@ describe('PaymentField', () => {
model: {
formId: 'formid',
basePath: 'base-path',
name: 'PaymentModel'
name: 'PaymentModel',
services: mockServices
},
getState: jest.fn().mockResolvedValueOnce({
$$__referenceNumber: 'pay-ref-123',
Expand All @@ -361,7 +373,7 @@ describe('PaymentField', () => {
sourceUrl: 'http://localhost:3009/test-payment',
isLive: false,
isPreview: true
}
} as unknown as PaymentExternalArgs

const res = await PaymentField.dispatcher(mockRequest, mockH, args)

Expand All @@ -372,6 +384,128 @@ describe('PaymentField', () => {
expect(mockRedirectCode).toHaveBeenCalledWith(303)
expect(postJson).not.toHaveBeenCalled()
})

it('should display error if create payment fails (e.g. network or bad api key) - test payment', async () => {
const mockYarSet = jest.fn()
const mockYarFlash = jest.fn()
const mockRequest = {
server: {
plugins: {
// eslint-disable-next-line no-useless-computed-key
['forms-engine-plugin']: {
baseUrl: 'base-url'
}
}
},
yar: {
set: mockYarSet,
flash: mockYarFlash
},
url: {
href: '/here'
}
} as unknown as FormRequestPayload
const mockH = {
redirect: jest
.fn()
.mockReturnValueOnce({ code: jest.fn().mockReturnValueOnce('ok') })
} as unknown as FormResponseToolkit
const args = {
controller: {
model: {
formId: 'formid',
basePath: 'base-path',
name: 'PaymentModel',
services: mockServices
},
getState: jest
.fn()
.mockResolvedValueOnce({ $$__referenceNumber: 'pay-ref-123' })
},
component: paymentField,
sourceUrl: 'http://localhost:3009/test-payment',
isLive: false,
isPreview: true
} as unknown as PaymentExternalArgs
jest.mocked(postJson).mockImplementationOnce(() => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw { output: { statusCode: StatusCodes.UNAUTHORIZED } }
})

const res = await PaymentField.dispatcher(mockRequest, mockH, args)
expect(res).toBe('ok')
expect(mockYarSet).not.toHaveBeenCalled()
expect(mockYarFlash).toHaveBeenCalledWith(
'COMPONENT_STATE_ERROR',
{
href: '#myComponent',
name: 'myComponent',
text: 'Add a valid test API key before you can preview the payment journey.'
},
true
)
})

it('should display error if create payment fails (e.g. network or bad api key) - live payment', async () => {
const mockYarSet = jest.fn()
const mockYarFlash = jest.fn()
const mockRequest = {
server: {
plugins: {
// eslint-disable-next-line no-useless-computed-key
['forms-engine-plugin']: {
baseUrl: 'base-url'
}
}
},
yar: {
set: mockYarSet,
flash: mockYarFlash
},
url: {
href: '/here'
}
} as unknown as FormRequestPayload
const mockH = {
redirect: jest
.fn()
.mockReturnValueOnce({ code: jest.fn().mockReturnValueOnce('ok') })
} as unknown as FormResponseToolkit
const args = {
controller: {
model: {
formId: 'formid',
basePath: 'base-path',
name: 'PaymentModel',
services: mockServices
},
getState: jest
.fn()
.mockResolvedValueOnce({ $$__referenceNumber: 'pay-ref-123' })
},
component: paymentField,
sourceUrl: 'http://localhost:3009/test-payment',
isLive: true,
isPreview: false
} as unknown as PaymentExternalArgs
jest.mocked(postJson).mockImplementationOnce(() => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw { output: { statusCode: StatusCodes.UNAUTHORIZED } }
})

const res = await PaymentField.dispatcher(mockRequest, mockH, args)
expect(res).toBe('ok')
expect(mockYarSet).not.toHaveBeenCalled()
expect(mockYarFlash).toHaveBeenCalledWith(
'COMPONENT_STATE_ERROR',
{
href: '#myComponent',
name: 'myComponent',
text: 'There is a problem and we cannot take a payment. Contact us (details in the footer of this form) or save your progress and return to the form later.'
},
true
)
})
})

describe('onSubmit', () => {
Expand Down
50 changes: 29 additions & 21 deletions src/server/plugins/engine/components/PaymentField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import {
import { StatusCodes } from 'http-status-codes'
import joi, { type ObjectSchema } from 'joi'

import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js'
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js'
import {
createError,
getPluginOptions
} from '~/src/server/plugins/engine/helpers.js'
import {
PaymentErrorTypes,
PaymentPreAuthError,
PaymentSubmissionError
} from '~/src/server/plugins/engine/pageControllers/errors.js'
import {
type AnyFormRequest,
type FormContext,
type FormRequestPayload,
type FormResponseToolkit
Expand All @@ -27,7 +30,8 @@ import {
type FormState,
type FormStateValue,
type FormSubmissionError,
type FormSubmissionState
type FormSubmissionState,
type PaymentExternalArgs
} from '~/src/server/plugins/engine/types.js'
import {
createPaymentService,
Expand Down Expand Up @@ -186,7 +190,7 @@ export class PaymentField extends FormComponent {
static async dispatcher(
request: FormRequestPayload,
h: FormResponseToolkit,
args: PaymentDispatcherArgs
args: PaymentExternalArgs
): Promise<unknown> {
const { options, name: componentName } = args.component
const { model } = args.controller
Expand All @@ -205,7 +209,12 @@ export class PaymentField extends FormComponent {

const isLivePayment = args.isLive && !args.isPreview
const formId = args.controller.model.formId
const paymentService = createPaymentService(isLivePayment, formId)
const formsService = model.services.formsService
const paymentService = await createPaymentService(
isLivePayment,
formId,
formsService
)

const uuid = randomUUID()

Expand All @@ -229,6 +238,15 @@ export class PaymentField extends FormComponent {
{ formId, slug }
)

if (!payment) {
const message = isLivePayment
? 'There is a problem and we cannot take a payment. Contact us (details in the footer of this form) or save your progress and return to the form later.'
: 'Add a valid test API key before you can preview the payment journey.'
const govukError = createError(componentName, message)
request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
return h.redirect(request.url.href).code(StatusCodes.SEE_OTHER)
}

const sessionData: PaymentSessionData = {
uuid,
formId,
Expand Down Expand Up @@ -272,7 +290,12 @@ export class PaymentField extends FormComponent {
}

const { paymentId, isLivePayment, formId } = paymentState
const paymentService = createPaymentService(isLivePayment, formId)
const formsService = this.model.services.formsService
const paymentService = await createPaymentService(
isLivePayment,
formId,
formsService
)

/**
* @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle
Expand Down Expand Up @@ -343,21 +366,6 @@ export class PaymentField extends FormComponent {
}
}

export interface PaymentDispatcherArgs {
controller: {
model: {
formId: string
basePath: string
name: string
}
getState: (request: AnyFormRequest) => Promise<FormSubmissionState>
}
component: PaymentField
sourceUrl: string
isLive: boolean
isPreview: boolean
}

/**
* Session data stored when dispatching to GOV.UK Pay
*/
Expand Down
4 changes: 3 additions & 1 deletion src/server/plugins/engine/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export const plugin = {
onRequest,
ordnanceSurveyApiKey,
baseUrl,
ordnanceSurveyApiSecret
ordnanceSurveyApiSecret,
services
} = options

const cacheService =
Expand Down Expand Up @@ -77,6 +78,7 @@ export const plugin = {
server.expose('cacheService', cacheService)
server.expose('saveAndExit', saveAndExit)
server.expose('baseUrl', baseUrl)
server.expose('services', services)

server.app.model = model

Expand Down
14 changes: 9 additions & 5 deletions src/server/plugins/engine/routes/payment-helper.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import Boom from '@hapi/boom'

import { PAYMENT_SESSION_PREFIX } from '~/src/server/plugins/engine/routes/payment.js'
import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js'
import { PaymentService } from '~/src/server/plugins/payment/service.js'
import { createPaymentService } from '~/src/server/plugins/payment/helper.js'

/**
* Validates session data and retrieves payment status
* @param {Request} request - the request
* @param {string} uuid - the payment UUID
* @param {FormsService} formsService - the forms service
* @returns {Promise<{ session: PaymentSessionData, sessionKey: string, paymentStatus: GetPaymentResponse }>}
*/
export async function getPaymentContext(request, uuid) {
export async function getPaymentContext(request, uuid, formsService) {
const sessionKey = `${PAYMENT_SESSION_PREFIX}${uuid}`
const session = /** @type {PaymentSessionData | null} */ (
request.yar.get(sessionKey)
Expand All @@ -26,8 +26,11 @@ export async function getPaymentContext(request, uuid) {
throw Boom.badRequest('No paymentId in session')
}

const apiKey = getPaymentApiKey(isLivePayment, formId)
const paymentService = new PaymentService(apiKey)
const paymentService = await createPaymentService(
isLivePayment,
formId,
formsService
)
const paymentStatus = await paymentService.getPaymentStatus(
paymentId,
isLivePayment
Expand Down Expand Up @@ -73,4 +76,5 @@ export function convertPenceToPounds(amount) {
/**
* @import { Request } from '@hapi/hapi'
* @import { GetPaymentResponse, PaymentSessionData } from '~/src/server/plugins/payment/types.js'
* @import { FormsService } from '~/src/server/types.js'
*/
Loading
Loading