diff --git a/package-lock.json b/package-lock.json index 901a724..bf607f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1949,6 +1949,18 @@ "lodash": "4.17.19" }, "dependencies": { + "@mojaloop/sdk-standard-components": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@mojaloop/sdk-standard-components/-/sdk-standard-components-10.3.2.tgz", + "integrity": "sha512-O5DqUL+ncS718nFDFUMx8QO0pmTmg+/CNYuaXPrFfHDgf8c05mgSjg6Z8wt69Auwph6WXWaNjKTQRqZG2/BDdQ==", + "requires": { + "base64url": "3.0.1", + "fast-safe-stringify": "^2.0.7", + "ilp-packet": "2.2.0", + "jsonwebtoken": "8.5.1", + "jws": "4.0.0" + } + }, "lodash": { "version": "4.17.19", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", @@ -2208,18 +2220,6 @@ } } }, - "@mojaloop/sdk-standard-components": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@mojaloop/sdk-standard-components/-/sdk-standard-components-10.3.2.tgz", - "integrity": "sha512-O5DqUL+ncS718nFDFUMx8QO0pmTmg+/CNYuaXPrFfHDgf8c05mgSjg6Z8wt69Auwph6WXWaNjKTQRqZG2/BDdQ==", - "requires": { - "base64url": "3.0.1", - "fast-safe-stringify": "^2.0.7", - "ilp-packet": "2.2.0", - "jsonwebtoken": "8.5.1", - "jws": "4.0.0" - } - }, "@npmcli/ci-detect": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@npmcli/ci-detect/-/ci-detect-1.3.0.tgz", diff --git a/src/interface/api-template.yaml b/src/interface/api-template.yaml index ef8a687..9420347 100644 --- a/src/interface/api-template.yaml +++ b/src/interface/api-template.yaml @@ -182,6 +182,53 @@ paths: $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/501' 503: $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/503' + /thirdpartyRequests/transactions/{ID}/error: + put: + tags: + - thirdparty + - sampled + operationId: ThirdpartyTransactionRequestsError + parameters: + #Path + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/ID.yaml' + #Headers + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/Accept.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/Content-Length.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/Content-Type.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/Date.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/X-Forwarded-For.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/FSPIOP-Source.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/FSPIOP-Destination.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/FSPIOP-Encryption.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/FSPIOP-Signature.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/FSPIOP-URI.yaml' + - $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/parameters/FSPIOP-HTTP-Method.yaml' + requestBody: + description: Details of the error returned. + required: true + content: + application/json: + schema: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/schemas/ErrorInformationObject.yaml' + responses: + 200: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/200' + 400: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/400' + 401: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/401' + 403: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/403' + 404: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/404' + 405: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/405' + 406: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/406' + 501: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/501' + 503: + $ref: '../../node_modules/@mojaloop/api-snippets/v1.0/openapi3/responses/index.yaml#/503' /thirdpartyRequests/transactions/{ID}/authorizations: post: tags: diff --git a/src/interface/api.yaml b/src/interface/api.yaml index 7a6a0c9..0c475a1 100644 --- a/src/interface/api.yaml +++ b/src/interface/api.yaml @@ -26,67 +26,7 @@ paths: description: Data model for the complex type object that contains an optional element ErrorInformation used along with 4xx and 5xx responses. properties: errorInformation: - title: ErrorInformation - type: object - description: Data model for the complex type ErrorInformation. - properties: - errorCode: - title: ErrorCode - type: string - pattern: '^[1-9]\d{3}$' - description: | - The API data type ErrorCode is a JSON String of four characters, - consisting of digits only. Negative numbers are not allowed. - A leading zero is not allowed. Each error code in the API is a - four-digit number, for example, 1234, where the first number - (1 in the example) represents the high-level error category, - the second number (2 in the example) represents the low-level error category, - and the last two numbers (34 in the example) represent the specific error. - example: 5100 - errorDescription: - title: ErrorDescription - type: string - minLength: 1 - maxLength: 128 - description: Error description string. - example: This is an error description. - extensionList: - title: ExtensionList - type: object - description: | - Data model for the complex type ExtensionList. - An optional list of extensions, specific to deployment. - properties: - extension: - type: array - items: - title: Extension - type: object - description: Data model for the complex type Extension. - properties: - key: - title: ExtensionKey - type: string - minLength: 1 - maxLength: 32 - description: Extension key. - value: - title: ExtensionValue - type: string - minLength: 1 - maxLength: 128 - description: Extension value. - required: - - key - - value - minItems: 1 - maxItems: 16 - description: Number of Extension elements. - required: - - extension - required: - - errorCode - - errorDescription + $ref: '#/paths/~1thirdpartyRequests~1transactions~1%7BID%7D~1error/put/requestBody/content/application~1json/schema/properties/errorInformation' headers: Content-Length: required: false @@ -323,6 +263,118 @@ paths: $ref: '#/paths/~1health/get/responses/501' '503': $ref: '#/paths/~1health/get/responses/503' + '/thirdpartyRequests/transactions/{ID}/error': + put: + tags: + - thirdparty + - sampled + operationId: ThirdpartyTransactionRequestsError + parameters: + - $ref: '#/paths/~1consents~1%7BID%7D/put/parameters/0' + - $ref: '#/paths/~1consents/post/parameters/0' + - $ref: '#/paths/~1consents/post/parameters/1' + - $ref: '#/paths/~1consents/post/parameters/2' + - $ref: '#/paths/~1consents/post/parameters/3' + - $ref: '#/paths/~1consents/post/parameters/4' + - $ref: '#/paths/~1consents/post/parameters/5' + - $ref: '#/paths/~1consents/post/parameters/6' + - $ref: '#/paths/~1consents/post/parameters/7' + - $ref: '#/paths/~1consents/post/parameters/8' + - $ref: '#/paths/~1consents/post/parameters/9' + - $ref: '#/paths/~1consents/post/parameters/10' + requestBody: + description: Details of the error returned. + required: true + content: + application/json: + schema: + title: ErrorInformationObject + type: object + description: Data model for the complex type object that contains ErrorInformation. + properties: + errorInformation: + title: ErrorInformation + type: object + description: Data model for the complex type ErrorInformation. + properties: + errorCode: + title: ErrorCode + type: string + pattern: '^[1-9]\d{3}$' + description: | + The API data type ErrorCode is a JSON String of four characters, + consisting of digits only. Negative numbers are not allowed. + A leading zero is not allowed. Each error code in the API is a + four-digit number, for example, 1234, where the first number + (1 in the example) represents the high-level error category, + the second number (2 in the example) represents the low-level error category, + and the last two numbers (34 in the example) represent the specific error. + example: 5100 + errorDescription: + title: ErrorDescription + type: string + minLength: 1 + maxLength: 128 + description: Error description string. + example: This is an error description. + extensionList: + title: ExtensionList + type: object + description: | + Data model for the complex type ExtensionList. + An optional list of extensions, specific to deployment. + properties: + extension: + type: array + items: + title: Extension + type: object + description: Data model for the complex type Extension. + properties: + key: + title: ExtensionKey + type: string + minLength: 1 + maxLength: 32 + description: Extension key. + value: + title: ExtensionValue + type: string + minLength: 1 + maxLength: 128 + description: Extension value. + required: + - key + - value + minItems: 1 + maxItems: 16 + description: Number of Extension elements. + required: + - extension + required: + - errorCode + - errorDescription + required: + - errorInformation + responses: + '200': + $ref: '#/paths/~1health/get/responses/200' + '400': + $ref: '#/paths/~1health/get/responses/400' + '401': + $ref: '#/paths/~1health/get/responses/401' + '403': + $ref: '#/paths/~1health/get/responses/403' + '404': + $ref: '#/paths/~1health/get/responses/404' + '405': + $ref: '#/paths/~1health/get/responses/405' + '406': + $ref: '#/paths/~1health/get/responses/406' + '501': + $ref: '#/paths/~1health/get/responses/501' + '503': + $ref: '#/paths/~1health/get/responses/503' '/thirdpartyRequests/transactions/{ID}/authorizations': post: tags: diff --git a/src/server/handlers/index.ts b/src/server/handlers/index.ts index 1a0b15e..eae52fb 100644 --- a/src/server/handlers/index.ts +++ b/src/server/handlers/index.ts @@ -29,6 +29,7 @@ import Health from './health' import Metrics from './metrics' import ThirdpartyTransactions from './thirdpartyRequests/transactions' import ThirdpartyTransactionsId from './thirdpartyRequests/transactions/{ID}' +import ThirdpartyTransactionsIdError from './thirdpartyRequests/transactions/{ID}/error' import Consents from './consents' import ConsentsId from './consents/{ID}' import ConsentsIdGenerateChallenge from './consents/{ID}/generateChallenge' @@ -57,6 +58,14 @@ export default { ['success'] ] ), + ThirdpartyTransactionRequestsError: wrapWithHistogram( + ThirdpartyTransactionsIdError.put, + [ + 'thirdpartyRequests_transactions_error_put', + 'Put thirdpartyRequests transactions error request', + ['success'] + ] + ), VerifyThirdPartyAuthorization: wrapWithHistogram( Authorizations.post, [ diff --git a/src/server/handlers/thirdpartyRequests/transactions/{ID}/error.ts b/src/server/handlers/thirdpartyRequests/transactions/{ID}/error.ts new file mode 100644 index 0000000..3b451de --- /dev/null +++ b/src/server/handlers/thirdpartyRequests/transactions/{ID}/error.ts @@ -0,0 +1,88 @@ +/***** + License + -------------- + Copyright © 2020 Mojaloop Foundation The Mojaloop files are made available by the Mojaloop Foundation + under the Apache License, Version 2.0 (the 'License') and you may not + use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in + writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS + OF ANY KIND, either express or implied. See the License for the specific language governing + permissions and limitations under the License. Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. Names of the original + copyright holders (individuals or organizations) should be listed with a '*' in the first column. + People who have contributed from an organization can be listed under the organization that actually + holds the copyright for their contributions (see the Gates Foundation organization for an example). + Those individuals should have their names indented and be marked with a '-'. Email address can be + added optionally within square brackets . + * Gates Foundation + - Name Surname + + - Kevin Leyow + + -------------- + ******/ +'use strict' + +import { Request, ResponseToolkit, ResponseObject } from '@hapi/hapi' +import Logger from '@mojaloop/central-services-logger' +import { ReformatFSPIOPError, APIErrorObject } from '@mojaloop/central-services-error-handling' +import { Enum } from '@mojaloop/central-services-shared' +import { AuditEventAction } from '@mojaloop/event-sdk' +import { Transactions } from '~/domain/thirdpartyRequests' +import { getSpanTags } from '~/shared/util' + +/** + * summary: ThirdpartyTransactionRequestsError + * description: The HTTP request PUT /thirdpartyRequests/transactions/{ID}/error is used to inform a thirdparty + * of a transaction error. + * parameters: body, accept, content-length, content-type, date, x-forwarded-for, fspiop-source, + * fspiop-destination, fspiop-encryption,fspiop-signature, fspiop-uri fspiop-http-method + * produces: application/json + * responses: 200, 400, 401, 403, 404, 405, 406, 501, 503 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const put = async (_context: any, request: Request, h: ResponseToolkit): Promise => { + const span = (request as any).span + const transactionRequestId: string = request.params.ID + const payload = request.payload as APIErrorObject + + try { + const tags: { [id: string]: string } = getSpanTags( + request, + Enum.Events.Event.Type.TRANSACTION_REQUEST, + Enum.Events.Event.Action.PUT, + { transactionRequestId: request.params.transactionRequestId }) + + span?.setTags(tags) + await span?.audit({ + headers: request.headers, + payload: request.payload + }, AuditEventAction.start) + + // Note: calling async function without `await` + Transactions.forwardTransactionRequestError( + request.headers, + Enum.EndPoints.FspEndpointTemplates.TP_TRANSACTION_REQUEST_PUT_ERROR, + Enum.Http.RestMethods.PUT, + transactionRequestId, + payload, + span + ) + .catch(err => { + // Do nothing with the error - forwardTransactionRequestError takes care of async errors + Logger.error('Transactions::put - forwardTransactionRequestError async handler threw an unhandled error') + Logger.error(ReformatFSPIOPError(err)) + }) + + return h.response().code(Enum.Http.ReturnCodes.OK.CODE) + } catch (err) { + const fspiopError = ReformatFSPIOPError(err) + Logger.error(fspiopError) + throw fspiopError + } +} + +export default { + put +} diff --git a/test/features/transactionRequests.feature b/test/features/transactionRequests.feature index 0c6e51a..4553786 100644 --- a/test/features/transactionRequests.feature +++ b/test/features/transactionRequests.feature @@ -19,3 +19,8 @@ Scenario: GetThirdpartyTransactionRequests Given thirdparty-api-adapter server When I send a 'GetThirdpartyTransactionRequests' request Then I get a response with a status code of '202' + +Scenario: ThirdpartyTransactionRequestsError + Given thirdparty-api-adapter server + When I send a 'ThirdpartyTransactionRequestsError' request + Then I get a response with a status code of '200' diff --git a/test/step-definitions/transactionRequests.step.ts b/test/step-definitions/transactionRequests.step.ts index 5cb85cc..8ef6b60 100644 --- a/test/step-definitions/transactionRequests.step.ts +++ b/test/step-definitions/transactionRequests.step.ts @@ -11,6 +11,7 @@ const featurePath = path.join(__dirname, '../features/transactionRequests.featur const feature = loadFeature(featurePath) const mockForwardTransactionRequest = jest.spyOn(Transactions, 'forwardTransactionRequest') +const mockForwardTransactionRequestError = jest.spyOn(Transactions, 'forwardTransactionRequestError') const mockForwardAuthorizationRequest = jest.spyOn(Authorizations, 'forwardAuthorizationRequest') const mockData = JSON.parse(JSON.stringify(TestData)) @@ -200,4 +201,56 @@ defineFeature(feature, (test): void => { expect(mockForwardTransactionRequest).toHaveBeenCalledWith(...expected) }) }) + + test('ThirdpartyTransactionRequestsError', ({ given, when, then }): void => { + const reqHeaders = { + ...mockData.transactionRequest.headers, + date: 'Thu, 23 Jan 2020 10:22:12 GMT', + accept: 'application/json' + } + const request = { + method: 'PUT', + url: '/thirdpartyRequests/transactions/67fff06f-2380-4403-ba35-f97b6a4250a1/error', + headers: reqHeaders, + payload: { + errorInformation: { + errorCode: '6000', + errorDescription: 'Generic third party error', + extensionList: { + extension: [ + { + key: 'test', + value: 'test' + } + ] + } + } + } + } + given('thirdparty-api-adapter server', async (): Promise => { + server = await ThirdPartyAPIAdapterService.run(Config) + return server + }) + + when('I send a \'ThirdpartyTransactionRequestsError\' request', async (): Promise => { + mockForwardTransactionRequestError.mockResolvedValueOnce() + response = await server.inject(request) + return response + }) + + then('I get a response with a status code of \'200\'', (): void => { + const expected = [ + expect.objectContaining(request.headers), + '/thirdpartyRequests/transactions/{{ID}}/error', + 'PUT', + '67fff06f-2380-4403-ba35-f97b6a4250a1', + request.payload, + expect.any(Object) + ] + + expect(response.statusCode).toBe(200) + expect(response.result).toBeNull() + expect(mockForwardTransactionRequestError).toHaveBeenCalledWith(...expected) + }) + }) }) diff --git a/test/unit/data/mockData.json b/test/unit/data/mockData.json index e447d88..76c3229 100644 --- a/test/unit/data/mockData.json +++ b/test/unit/data/mockData.json @@ -397,5 +397,26 @@ "payload": "credential_payload" } } + }, + "genericThirdpartyError": { + "headers": { + "fspiop-source": "dfspA", + "fspiop-destination": "pispA" + }, + "params": { "ID": "a5bbfd51-d9fc-4084-961a-c2c2221a31e0" }, + "payload": { + "errorInformation": { + "errorCode": "6000", + "errorDescription": "Generic third party error", + "extensionList": { + "extension": [ + { + "key": "test", + "value": "test" + } + ] + } + } + } } } diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index b621f91..b0139ae 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -35,6 +35,7 @@ import * as Consents from '~/domain/consents' import * as ConsentRequests from '~/domain/consentRequests' const mockForwardTransactionRequest = jest.spyOn(Transactions, 'forwardTransactionRequest') +const mockForwardTransactionRequestError = jest.spyOn(Transactions, 'forwardTransactionRequestError') const mockForwardAuthorizationRequest = jest.spyOn(Authorizations, 'forwardAuthorizationRequest') const mockForwardConsentsRequest = jest.spyOn(Consents, 'forwardConsentsRequest') const mockForwardConsentsIdRequest = jest.spyOn(Consents, 'forwardConsentsIdRequest') @@ -45,6 +46,7 @@ const mockLoggerPush = jest.spyOn(Logger, 'push') const mockLoggerError = jest.spyOn(Logger, 'error') const mockData = JSON.parse(JSON.stringify(TestData)) const trxnRequest = mockData.transactionRequest +const trxnRequestError = mockData.genericThirdpartyError describe('index', (): void => { it('should have proper layout', (): void => { @@ -154,6 +156,67 @@ describe('index', (): void => { }) }) + + describe('/thirdpartyRequests/transactions/{ID}/error', (): void => { + beforeAll((): void => { + mockLoggerPush.mockReturnValue(null) + mockLoggerError.mockReturnValue(null) + }) + + beforeEach((): void => { + jest.clearAllMocks() + }) + + it('PUT', async (): Promise => { + mockForwardTransactionRequestError.mockResolvedValueOnce() + const reqHeaders = Object.assign(trxnRequestError.headers, { + date: 'Thu, 23 Jan 2020 10:22:12 GMT', + accept: 'application/json' + }) + const request = { + method: 'PUT', + url: '/thirdpartyRequests/transactions/a5bbfd51-d9fc-4084-961a-c2c2221a31e0/error', + headers: reqHeaders, + payload: trxnRequestError.payload + } + + const expected = [ + expect.objectContaining(request.headers), + '/thirdpartyRequests/transactions/{{ID}}/error', + 'PUT', + 'a5bbfd51-d9fc-4084-961a-c2c2221a31e0', + request.payload, + expect.any(Object) + ] + const response = await server.inject(request) + + expect(response.statusCode).toBe(200) + expect(response.result).toBeNull() + expect(mockForwardTransactionRequestError).toHaveBeenCalledWith(...expected) + }) + + it('mandatory fields validation', async (): Promise => { + const errPayload = Object.assign(trxnRequestError.payload, { errorInformation: undefined }) + const request = { + method: 'PUT', + url: '/thirdpartyRequests/transactions/a5bbfd51-d9fc-4084-961a-c2c2221a31e0/error', + headers: trxnRequestError.headers, + payload: errPayload + } + const expected = { + errorInformation: { + errorCode: '3102', + errorDescription: 'Missing mandatory element - .requestBody should have required property \'errorInformation\'' + } + } + const response = await server.inject(request) + + expect(response.statusCode).toBe(400) + expect(response.result).toStrictEqual(expected) + expect(mockForwardTransactionRequestError).not.toHaveBeenCalled() + }) + }) + describe('POST /thirdpartyRequests/transactions/{ID}/authorizations', (): void => { beforeAll((): void => { mockLoggerPush.mockReturnValue(null) diff --git a/test/unit/server/handlers/thirdpartyRequests/transactions/{ID}/error.test.ts b/test/unit/server/handlers/thirdpartyRequests/transactions/{ID}/error.test.ts new file mode 100644 index 0000000..c2b8499 --- /dev/null +++ b/test/unit/server/handlers/thirdpartyRequests/transactions/{ID}/error.test.ts @@ -0,0 +1,87 @@ +/***** + License + -------------- + Copyright © 2020 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + - Kevin Leyow + + -------------- + ******/ +'use strict' +import { Request } from '@hapi/hapi' +import Logger from '@mojaloop/central-services-logger' +import Handler from '~/server/handlers/thirdpartyRequests/transactions/{ID}/error' +import { Transactions } from '~/domain/thirdpartyRequests' +import TestData from 'test/unit/data/mockData.json' +import { mockResponseToolkit } from 'test/unit/__mocks__/responseToolkit' + +const mockForwardTransactionRequestError = jest.spyOn(Transactions, 'forwardTransactionRequestError') +const mockLoggerPush = jest.spyOn(Logger, 'push') +const mockLoggerError = jest.spyOn(Logger, 'error') +const MockData = JSON.parse(JSON.stringify(TestData)) + +const request: Request = MockData.genericThirdpartyError + +describe('transactions error handler', (): void => { + describe('PUT /thirdpartyRequests/transactions/{ID}/error', (): void => { + beforeAll((): void => { + mockLoggerPush.mockReturnValue(null) + mockLoggerError.mockReturnValue(null) + }) + + beforeEach((): void => { + jest.clearAllMocks() + }) + + it('handles a successful request', async (): Promise => { + mockForwardTransactionRequestError.mockResolvedValueOnce() + + const expected = [ + expect.objectContaining(request.headers), + '/thirdpartyRequests/transactions/{{ID}}/error', + 'PUT', + 'a5bbfd51-d9fc-4084-961a-c2c2221a31e0', + request.payload, + undefined + ] + + // Act + const response = await Handler.put(null, request, mockResponseToolkit) + + // Assert + expect(response.statusCode).toBe(200) + expect(mockForwardTransactionRequestError).toHaveBeenCalledTimes(1) + expect(mockForwardTransactionRequestError).toHaveBeenCalledWith(...expected) + }) + + it('handles validation errors synchronously', async (): Promise => { + // Arrange + const badSpanRequest = { + ...request, + // Setting to empty span dict will cause a validation error + span: {} + } + + // Act + const action = async () => await Handler.put(null, badSpanRequest as unknown as Request, mockResponseToolkit) + + // Assert + await expect(action).rejects.toThrowError('span.setTags is not a function') + }) + }) +})