From 3df96e895c963a9d13b1b21b78da011382527b79 Mon Sep 17 00:00:00 2001 From: Alan Cruikshanks Date: Tue, 5 Mar 2024 08:58:04 +0000 Subject: [PATCH] wip --- app/controllers/bill-runs.controller.js | 38 +++- .../bill-runs/send-bill-run.presenter.js | 50 ++++++ app/routes/bill-runs.routes.js | 26 +++ .../bill-runs/send-bill-run.service.js | 51 ++++++ .../bill-runs/submit-send-bill-run.service.js | 118 +++++++++++++ .../charging-module/send-bill-run.service.js | 61 +++++-- .../charging-module/view-bill-run.service.js | 29 +++ .../wait-for-status.service.js | 87 +++++---- app/views/bill-runs/send.njk | 77 ++++++++ app/views/bill-runs/view.njk | 6 +- config/billing.config.js | 3 +- .../view-bill-run.service.test.js | 124 +++++++++++++ .../wait-for-status.service.test.js | 165 ++++++++++++++++++ 13 files changed, 782 insertions(+), 53 deletions(-) create mode 100644 app/presenters/bill-runs/send-bill-run.presenter.js create mode 100644 app/services/bill-runs/send-bill-run.service.js create mode 100644 app/services/bill-runs/submit-send-bill-run.service.js create mode 100644 app/services/charging-module/view-bill-run.service.js create mode 100644 app/views/bill-runs/send.njk create mode 100644 test/services/charging-module/view-bill-run.service.test.js create mode 100644 test/services/charging-module/wait-for-status.service.test.js diff --git a/app/controllers/bill-runs.controller.js b/app/controllers/bill-runs.controller.js index 577bf64df5..fa0f05192f 100644 --- a/app/controllers/bill-runs.controller.js +++ b/app/controllers/bill-runs.controller.js @@ -11,8 +11,10 @@ const CancelBillRunService = require('../services/bill-runs/cancel-bill-run.serv const CreateBillRunValidator = require('../validators/create-bill-run.validator.js') const ReviewBillRunService = require('../services/bill-runs/two-part-tariff/review-bill-run.service.js') const ReviewLicenceService = require('../services/bill-runs/two-part-tariff/review-licence.service.js') +const SendBillRunService = require('../services/bill-runs/send-bill-run.service.js') const StartBillRunProcessService = require('../services/bill-runs/start-bill-run-process.service.js') const SubmitCancelBillRunService = require('../services/bill-runs/submit-cancel-bill-run.service.js') +const SubmitSendBillRunService = require('../services/bill-runs/submit-send-bill-run.service.js') const ViewBillRunService = require('../services/bill-runs/view-bill-run.service.js') async function cancel (request, h) { @@ -56,6 +58,18 @@ async function review (request, h) { }) } +async function send (request, h) { + const { id } = request.params + + const pageData = await SendBillRunService.go(id) + + return h.view('bill-runs/send.njk', { + pageTitle: "You're about to send this bill run", + activeNavBar: 'bill-runs', + ...pageData + }) +} + async function reviewLicence (request, h) { const { id: billRunId, licenceId } = request.params @@ -73,13 +87,7 @@ async function submitCancel (request, h) { try { // NOTE: What we are awaiting here is for the SubmitCancelBillRunService to update the status of the bill run to - // `cancel'. If the bill run run is deemed small enough, we'll also wait for the cancelling to complete before - // we redirect. This avoids users always having to refresh the bill runs page to get rid of bill runs that have - // finished cancelling 1 second after the request is submitted. - // - // But for larger bill runs, especially annual, after the bill run status has been updated control will be returned - // to here and the cancel process will then happen in the background. Because of this if we didn't wrap the call - // in a try/catch and the process errored, we'd get an unhandled exception which will bring the service down! + // `cancel'. await SubmitCancelBillRunService.go(id) return h.redirect('/billing/batch/list') @@ -88,6 +96,20 @@ async function submitCancel (request, h) { } } +async function submitSend (request, h) { + const { id } = request.params + + try { + // NOTE: What we are awaiting here is for the SubmitSendBillRunService to update the status of the bill run to + // `sending'. + await SubmitSendBillRunService.go(id) + + return h.redirect('/billing/batch/list') + } catch (error) { + return Boom.badImplementation(error.message) + } +} + async function view (request, h) { const { id } = request.params @@ -104,6 +126,8 @@ module.exports = { create, review, reviewLicence, + send, submitCancel, + submitSend, view } diff --git a/app/presenters/bill-runs/send-bill-run.presenter.js b/app/presenters/bill-runs/send-bill-run.presenter.js new file mode 100644 index 0000000000..ed3c4aa675 --- /dev/null +++ b/app/presenters/bill-runs/send-bill-run.presenter.js @@ -0,0 +1,50 @@ +'use strict' + +/** + * Formats the bill run data ready for presenting in the send bill run confirmation page + * @module SendBillRunPresenter + */ + +const { + capitalize, + formatBillRunType, + formatChargeScheme, + formatFinancialYear, + formatLongDate +} = require('../base.presenter.js') + +/** + * Prepares and processes bill run data for presentation + * + * @param {module:BillRunModel} billRun - an instance of `BillRunModel` + * + * @returns {Object} - the prepared bill run data to be passed to the send bill run confirmation page + */ +function go (billRun) { + const { + batchType, + billRunNumber, + createdAt, + id, + region, + scheme, + status, + summer, + toFinancialYearEnding + } = billRun + + return { + billRunId: id, + billRunNumber, + billRunStatus: status, + billRunType: formatBillRunType(batchType, scheme, summer), + chargeScheme: formatChargeScheme(scheme), + dateCreated: formatLongDate(createdAt), + financialYear: formatFinancialYear(toFinancialYearEnding), + region: capitalize(region.displayName) + } +} + +module.exports = { + go +} diff --git a/app/routes/bill-runs.routes.js b/app/routes/bill-runs.routes.js index 95d359a2d0..b8d7cbce45 100644 --- a/app/routes/bill-runs.routes.js +++ b/app/routes/bill-runs.routes.js @@ -83,6 +83,32 @@ const routes = [ }, description: 'Review a two-part tariff licence' } + }, + { + method: 'GET', + path: '/bill-runs/{id}/send', + handler: BillRunsController.send, + options: { + auth: { + access: { + scope: ['billing'] + } + }, + description: 'Confirm (send) a bill run' + } + }, + { + method: 'POST', + path: '/bill-runs/{id}/send', + handler: BillRunsController.submitSend, + options: { + auth: { + access: { + scope: ['billing'] + } + }, + description: 'Submit bill run (send) confirmation' + } } ] diff --git a/app/services/bill-runs/send-bill-run.service.js b/app/services/bill-runs/send-bill-run.service.js new file mode 100644 index 0000000000..e85b9b36c2 --- /dev/null +++ b/app/services/bill-runs/send-bill-run.service.js @@ -0,0 +1,51 @@ +'use strict' + +/** + * Orchestrates fetching and presenting the data needed for the send bill run confirmation page + * @module SendBillRunService + */ + +const BillRunModel = require('../../models/bill-run.model.js') +const SendBillRunPresenter = require('../../presenters/bill-runs/send-bill-run.presenter.js') + +/** + * Orchestrates fetching and presenting the data needed for the send bill run confirmation page + * + * @param {string} id - The UUID of the bill run to send + * + * @returns {Promise an object representing the `pageData` needed by the send bill run template. It contains + * details of the bill run. + */ +async function go (id) { + const billRun = await _fetchBillRun(id) + + const pageData = SendBillRunPresenter.go(billRun) + + return pageData +} + +async function _fetchBillRun (id) { + return BillRunModel.query() + .findById(id) + .select([ + 'id', + 'batchType', + 'billRunNumber', + 'createdAt', + 'scheme', + 'status', + 'summer', + 'toFinancialYearEnding' + ]) + .withGraphFetched('region') + .modifyGraph('region', (builder) => { + builder.select([ + 'id', + 'displayName' + ]) + }) +} + +module.exports = { + go +} diff --git a/app/services/bill-runs/submit-send-bill-run.service.js b/app/services/bill-runs/submit-send-bill-run.service.js new file mode 100644 index 0000000000..d223cbad7f --- /dev/null +++ b/app/services/bill-runs/submit-send-bill-run.service.js @@ -0,0 +1,118 @@ +'use strict' + +/** + * Orchestrates the sending of a bill run + * @module SubmitCancelBillRunService + */ + +const BillModel = require('../../models/bill.model.js') +const BillRunModel = require('../../models/bill-run.model.js') +const ExpandedError = require('../../errors/expanded.error.js') +const { calculateAndLogTimeTaken, timestampForPostgres } = require('../../lib/general.lib.js') +const ChargingModuleSendBillRunService = require('../charging-module/send-bill-run.service.js') +const ChargingModuleViewBillRunService = require('../charging-module/view-bill-run.service.js') + +/** + * Orchestrates the sending of a bill run + * + * After checking that the bill run has a status that can be sent (only ready bill runs can be sent) + * we set the status of the bill run to `sending`. At this point we return control to the controller so that it can + * redirect the user to the bill runs page. + * + * Meantime now in the background, we send a request to the Charging Module API to send the bill run there. Once that + * process is complete we get the updated bill run summary and extract the generated bill numbers and transaction file + * reference from it. + * + * It can take the CHA more than a second to complete the send process for larger bill runs. This is why we hand back + * control to the UI early so we don't block the user or cause a timeout. + * + * @param {string} billRunId - UUID of the bill run to be sent + * + * @returns {Promise} the promise returned is not intended to resolve to any particular value + */ +async function go (billRunId) { + const billRun = await _fetchBillRun(billRunId) + + if (billRun.status !== 'ready') { + return + } + + await _updateStatus(billRunId, 'sending') + + _sendBillRun(billRun) +} + +async function _updateBillRunData (billRun, externalBillRun) { + const { transactionFileReference } = externalBillRun + + return billRun.$query().patch({ status: 'sent', transactionFileReference }) +} + +async function _updateInvoiceData (externalBillRun) { + const { invoices } = externalBillRun + + for (const invoice of invoices) { + const { id, transactionReference: invoiceNumber } = invoice + + await BillModel.query().patch({ invoiceNumber }).where('externalId', id) + } +} + +/** + * This service handles the POST request from the confirm sent bill run page. We _could_ have included the + * externalId as a hidden field in the form and included it in the request. This would give the impression we could + * avoid a query to the DB. + * + * But we need to ensure no one exploits the `POST /bill-runs/{id}/send` endpoint. So, we always have to fetch the bill + * run to check its status is not one that prevents us deleting it. + */ +async function _fetchBillRun (id) { + return BillRunModel.query() + .findById(id) + .select([ + 'id', + 'externalId', + 'status' + ]) +} + +async function _fetchChargingModuleBillRun (externalId) { + const result = await ChargingModuleViewBillRunService.go(externalId) + + if (!result.succeeded) { + const error = new ExpandedError( + 'Charging Module view bill run request failed', + { billRunExternalId: externalId, responseBody: result.response.body } + ) + + throw error + } + + return result.response.body.billRun +} + +async function _sendBillRun (billRun) { + const startTime = process.hrtime.bigint() + + const { id: billRunId, externalId } = billRun + + await ChargingModuleSendBillRunService.go(externalId) + + const externalBillRun = await _fetchChargingModuleBillRun(externalId) + + await _updateInvoiceData(externalBillRun) + + await _updateBillRunData(billRun, externalBillRun) + + calculateAndLogTimeTaken(startTime, 'Send bill run complete', { billRunId }) +} + +async function _updateStatus (billRunId, status) { + return BillRunModel.query() + .findById(billRunId) + .patch({ status, updatedAt: timestampForPostgres() }) +} + +module.exports = { + go +} diff --git a/app/services/charging-module/send-bill-run.service.js b/app/services/charging-module/send-bill-run.service.js index 4e73a2adce..4307979604 100644 --- a/app/services/charging-module/send-bill-run.service.js +++ b/app/services/charging-module/send-bill-run.service.js @@ -6,6 +6,8 @@ */ const ChargingModuleRequestLib = require('../../lib/charging-module-request.lib.js') +const ExpandedError = require('../../errors/expanded.error.js') +const WaitForStatusService = require('./wait-for-status.service.js') /** * Approve then send a bill run in the Charging Module API @@ -15,29 +17,64 @@ const ChargingModuleRequestLib = require('../../lib/charging-module-request.lib. * run can be sent. * * Sending a bill run is the final step. Once sent a bill run cannot be changed or deleted. Sending involves the CHA - * generating a bill number for every bill in the bill run. It then generates a transaction reference for the bill run - * itself. This reference is used to name the transaction import file which the CHA also generates at this time. This - * is the file that will make it's way to SOP and be used to generate the invoice and credit notes that customers - * receive. + * generating a transaction reference for every bill in the bill run. It then generates a transaction reference for the + * bill run itself. This reference is used to name the transaction import file which the CHA also generates at this + * time. This is the file that will make it's way to SOP and be used to generate the invoice and credit notes that + * customers receive. * * For small bill runs the process is near instantaneous. Larger bill runs however it can take a number of seconds. - * Because of this when the request is first made the CHA switches the bill run's status to `sending`. Only when the - * process is complete does the status get set to `sent`. - * - * It's this we are waiting for because then we can extract the generated bill numbers and transaction file reference - * and apply them to our bill run records. + * Because of this when the request is first made the CHA switches the bill run's status to `pending`. Only when the + * process is complete does the status get set to `billed` or `billing_not_required`. * * See {@link https://defra.github.io/sroc-charging-module-api-docs/#/bill-run/SendBillRun | CHA API docs} for more * details * @param {string} billRunId - UUID of the charging module API bill run to send * - * @returns {Promise} The result of the request; whether it succeeded and the response or error returned + * @returns {Promise} the promise returned is not intended to resolve to any particular value */ async function go (billRunId) { - const path = `v3/wrls/bill-runs/${billRunId}/generate` + await _approve(billRunId) + await _send(billRunId) + + return _waitForSent(billRunId) +} + +async function _approve (billRunId) { + const path = `v3/wrls/bill-runs/${billRunId}/approve` + const result = await ChargingModuleRequestLib.patch(path) + + if (!result.succeeded) { + const error = new ExpandedError( + 'Charging Module approve request failed', + { billRunExternalId: billRunId, responseBody: result.response.body } + ) + + throw error + } +} + +async function _send (billRunId) { + const path = `v3/wrls/bill-runs/${billRunId}/send` const result = await ChargingModuleRequestLib.patch(path) - return result + if (!result.succeeded) { + const error = new ExpandedError( + 'Charging Module send request failed', + { billRunExternalId: billRunId, responseBody: result.response.body } + ) + + throw error + } +} + +async function _waitForSent (billRunId) { + const result = await WaitForStatusService.go(billRunId, ['billed', 'billing_not_required']) + + if (!result.succeeded) { + const error = new ExpandedError('Charging Module waiting for sent took too long', result) + + throw error + } } module.exports = { diff --git a/app/services/charging-module/view-bill-run.service.js b/app/services/charging-module/view-bill-run.service.js new file mode 100644 index 0000000000..db80576f29 --- /dev/null +++ b/app/services/charging-module/view-bill-run.service.js @@ -0,0 +1,29 @@ +'use strict' + +/** + * Connects with the Charging Module to get a bill run summary + * @module ChargingModuleViewBillRunService + */ + +const ChargingModuleRequestLib = require('../../lib/charging-module-request.lib.js') + +/** + * View a bill run in the Charging Module API + * + * See {@link https://defra.github.io/sroc-charging-module-api-docs/#/bill-run/ViewBillRun | CHA API docs} for more + * details + * + * @param {string} billRunId - UUID of the charging module API bill run to view + * + * @returns {Promise} The result of the request; whether it succeeded and the response or error returned + */ +async function go (billRunId) { + const path = `v3/wrls/bill-runs/${billRunId}` + const result = await ChargingModuleRequestLib.get(path) + + return result +} + +module.exports = { + go +} diff --git a/app/services/charging-module/wait-for-status.service.js b/app/services/charging-module/wait-for-status.service.js index 6c21013f02..86b37ab31d 100644 --- a/app/services/charging-module/wait-for-status.service.js +++ b/app/services/charging-module/wait-for-status.service.js @@ -1,8 +1,8 @@ 'use strict' /** - * Connects with the Charging Module to send a bill run - * @module ChargingModuleSendBillRunService + * Use to wait for a Charging Module bill run to be in a certain state + * @module ChargingModuleWaitForStatusService */ const { setTimeout } = require('node:timers/promises') @@ -10,61 +10,88 @@ const { setTimeout } = require('node:timers/promises') const BillRunStatusService = require('./bill-run-status.service.js') const ExpandedError = require('../../errors/expanded.error.js') -const ONE_SECOND = 1000 +const billingConfig = require('../../../config/billing.config.js') -async function go (billRunId, statusToWaitFor, maximumAttempts = 120) { +/** + * Wait for a Charging Module bill run to have a specified state + * + * Will keep calling the Charging Module API's + * {@link https://defra.github.io/sroc-charging-module-api-docs/#/bill-run/ViewBillRunStatus | status endpoint} until it + * returns one of the statuses specified. For example, after requesting a bill run is 'sent' the CHA will set the bill + * run's state to `pending` until it has finished sending it to SOP. It will then set the state to either `billed` or + * `billing_not_required`. + * + * We need to be able to update the bills and bill run on our side with the transaction references generated by the CHA. + * So, we have a need to first tell the CHA to 'send' its bill run then wait until it has finished before extracting the + * new data. + * + * This service was created for exactly that purpose. In order not to bombard the CHA we pause between checks (default + * is 1 second). To prevent the process getting stuck we'll only check so many times (default 120). + * + * Either way we return a result which states whether the wait was successful, what the last status returned was, and + * how many attempts it took. + * + * ```javascript + * { + * succeeded: true, + * status: 'billed', + * attempts: 7 + * } + * ``` + * + * @param {string} billRunId - UUID of bill run being sent + * @param {string[]} statusesToWaitFor - Array of statuses to wait for, for example, `['billed', 'billing_not_required]` + * @param {number} [maximumAttempts] - Number of times to check the status before giving up. Defaults to 120 + * + * @returns {Promise} returns the results of the wait + */ +async function go (billRunId, statusesToWaitFor, maximumAttempts = 120) { let attempts = 0 let status - // - do { - await _pause(attempts) - + for (let i = 1; i <= maximumAttempts; i++) { const result = await BillRunStatusService.go(billRunId) + attempts = i + if (!result.succeeded) { _requestFailed(billRunId, result) } status = result.response.body.status - attempts += 1 - } while (status !== statusToWaitFor || attempts >= maximumAttempts) - if (status !== statusToWaitFor) { - _statusNotAchieved(billRunId, statusToWaitFor, status, attempts, maximumAttempts) + if (statusesToWaitFor.includes(status)) { + break + } + + await _pause() + } + + return { + succeeded: statusesToWaitFor.includes(status), + status, + attempts } } /** - * Pause for one second between requests so that we are not bombarding the Charging Module + * Pause between requests so that we are not bombarding the Charging Module + * + * The default is 1 second but we make this configurable mainly to allow us to override the pause in unit tests */ -function _pause (attempts) { - // If attempts is 0 then this is the first attempt so don't pause - if (attempts === 0) { - return - } - - return setTimeout(ONE_SECOND) +function _pause () { + return setTimeout(billingConfig.waitForStatusPauseInMs) } function _requestFailed (billRunId, result) { const error = new ExpandedError( - 'Charging Module status request failed', + 'Charging Module wait for status request failed', { billRunExternalId: billRunId, responseBody: result.response.body } ) throw error } -function _statusNotAchieved (billRunId, statusToWaitFor, status, attempts, maximumAttempts) { - const error = new ExpandedError( - 'Charging Module wait for status failed', - { billRunExternalId: billRunId, statusToWaitFor, lastStatus: status, attempts, maximumAttempts } - ) - - throw error -} - module.exports = { go } diff --git a/app/views/bill-runs/send.njk b/app/views/bill-runs/send.njk new file mode 100644 index 0000000000..20f4f67682 --- /dev/null +++ b/app/views/bill-runs/send.njk @@ -0,0 +1,77 @@ +{% extends 'layout.njk' %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} + +{% from "macros/badge.njk" import statusBadge %} + +{% block breadcrumbs %} + {# Back link #} + {{ + govukBackLink({ + text: 'Back', + href: '/system/bill-runs/' + billRunId + }) + }} +{% endblock %} + +{% block content %} + {# Main heading #} +
+

+ Bill run {{ billRunNumber }}{{ pageTitle }} +

+
+ +
+
+ + {# Status badge #} +

+ {{ statusBadge(billRunStatus) }} +

+ + {# Bill run meta-data #} + {# + GOV.UK summary lists only allow us to assign attributes at the top level and not to each row. This means we + can't assign our data-test attribute using the component. Our solution is to use the html option for each row + instead of text and wrap each value in a . That way we can manually assign our data-test attribute to the + span. + #} + {{ + govukSummaryList({ + classes: 'govuk-summary-list--no-border', + attributes: { + 'data-test': 'meta-data' + }, + rows: [ + { + key: { text: "Date created", classes: "meta-data__label" }, + value: { html: '' + dateCreated + '', classes: "meta-data__value" } + }, + { + key: { text: "Region", classes: "meta-data__label" }, + value: { html: '' + region + '', classes: "meta-data__value" } + }, + { + key: { text: "Bill run type", classes: "meta-data__label" }, + value: { html: '' + billRunType + '', classes: "meta-data__value" } + }, + { + key: { text: "Charge scheme", classes: "meta-data__label" }, + value: { html: '' + chargeScheme + '', classes: "meta-data__value" } + }, + { + key: { text: "Financial year", classes: "meta-data__label" }, + value: { html: '' + financialYear + '', classes: "meta-data__value" } + } + ] + }) + }} + +
+ {{ govukButton({ text: "Send bill run" }) }} +
+
+
+{% endblock %} diff --git a/app/views/bill-runs/view.njk b/app/views/bill-runs/view.njk index 1980172dfa..f1d5a1f12d 100644 --- a/app/views/bill-runs/view.njk +++ b/app/views/bill-runs/view.njk @@ -112,15 +112,15 @@ {# Confirm and cancel buttons #} {% if billRunStatus === 'ready' %} {% set cancelBillRunLink = '/system/bill-runs/' + billRunId + '/cancel' %} - {% set confirmBillRunLink = '/billing/batch/' + billRunId + '/confirm' %} + {% set sendBillRunLink = '/system/bill-runs/' + billRunId + '/send' %}
{{ govukButton({ classes: 'govuk-!-margin-right-1', - text: "Confirm bill run", - href: confirmBillRunLink + text: "Send bill run", + href: sendBillRunLink }) }} {{ diff --git a/config/billing.config.js b/config/billing.config.js index f56b01c90c..83cb4acdf4 100644 --- a/config/billing.config.js +++ b/config/billing.config.js @@ -12,7 +12,8 @@ require('dotenv').config() const config = { annual: { batchSize: parseInt(process.env.BILLING_ANNUAL_BATCH_SIZE) || 5 - } + }, + waitForStatusPauseInMs: parseInt(process.env.BILLING_WAIT_FOR_STATUS_PAUSE_IN_MS) || 1000 } module.exports = config diff --git a/test/services/charging-module/view-bill-run.service.test.js b/test/services/charging-module/view-bill-run.service.test.js new file mode 100644 index 0000000000..f58b25780a --- /dev/null +++ b/test/services/charging-module/view-bill-run.service.test.js @@ -0,0 +1,124 @@ +'use strict' + +// Test framework dependencies +const Lab = require('@hapi/lab') +const Code = require('@hapi/code') +const Sinon = require('sinon') + +const { describe, it, beforeEach, afterEach } = exports.lab = Lab.script() +const { expect } = Code + +// Things we need to stub +const ChargingModuleRequestLib = require('../../../app/lib/charging-module-request.lib.js') + +// Thing under test +const ChargingModuleViewBillRunService = require('../../../app/services/charging-module/view-bill-run.service.js') + +describe('Charging Module View Bill Run service', () => { + const billRunId = 'db82bf38-638a-44d3-b1b3-1ae8524d9c38' + + afterEach(() => { + Sinon.restore() + }) + + describe('when the service can view a bill run', () => { + beforeEach(async () => { + Sinon.stub(ChargingModuleRequestLib, 'get').resolves({ + succeeded: true, + response: { + info: { + gitCommit: '273604040a47e0977b0579a0fef0f09726d95e39', + dockerTag: 'ghcr.io/defra/sroc-charging-module-api:v0.19.0' + }, + statusCode: 200, + body: { + // truncated invoice, see CM docs for full invoice https://defra.github.io/sroc-charging-module-api-docs + invoice: { + id: '8e32a958-13f3-4ea3-a92d-651b764c2cbe', + billRunId + } + } + } + }) + }) + + it('hits the correct endpoint', async () => { + await ChargingModuleViewBillRunService.go(billRunId) + const endpoint = ChargingModuleRequestLib.get.firstCall.firstArg + + expect(endpoint).to.equal(`v3/wrls/bill-runs/${billRunId}`) + }) + + it('returns a `true` success status', async () => { + const result = await ChargingModuleViewBillRunService.go(billRunId) + + expect(result.succeeded).to.be.true() + }) + + it('returns the bill run in the `response`', async () => { + const result = await ChargingModuleViewBillRunService.go(billRunId) + + expect(result.response.body.invoice.billRunId).to.equal(billRunId) + }) + }) + + describe('when the service cannot view a bill run', () => { + describe('because the request did not return a 2xx/3xx response', () => { + beforeEach(async () => { + Sinon.stub(ChargingModuleRequestLib, 'get').resolves({ + succeeded: false, + response: { + info: { + gitCommit: '273604040a47e0977b0579a0fef0f09726d95e39', + dockerTag: 'ghcr.io/defra/sroc-charging-module-api:v0.19.0' + }, + statusCode: 401, + body: { + statusCode: 401, + error: 'Unauthorized', + message: 'Invalid JWT: Token format not valid', + attributes: { error: 'Invalid JWT: Token format not valid' } + } + } + }) + }) + + it('returns a `false` success status', async () => { + const result = await ChargingModuleViewBillRunService.go(billRunId) + + expect(result.succeeded).to.be.false() + }) + + it('returns the error in the `response`', async () => { + const result = await ChargingModuleViewBillRunService.go(billRunId) + + expect(result.response.body.statusCode).to.equal(401) + expect(result.response.body.error).to.equal('Unauthorized') + expect(result.response.body.message).to.equal('Invalid JWT: Token format not valid') + }) + }) + + describe('because the request attempt returned an error, for example, TimeoutError', () => { + beforeEach(async () => { + Sinon.stub(ChargingModuleRequestLib, 'get').resolves({ + succeeded: false, + response: new Error("Timeout awaiting 'request' for 5000ms") + }) + }) + + it('returns a `false` success status', async () => { + const result = await ChargingModuleViewBillRunService.go(billRunId) + + expect(result.succeeded).to.be.false() + }) + + it('returns the error in the `response`', async () => { + const result = await ChargingModuleViewBillRunService.go(billRunId) + + expect(result.response.statusCode).not.to.exist() + expect(result.response.body).not.to.exist() + expect(result.response.message).to.equal("Timeout awaiting 'request' for 5000ms") + }) + }) + }) +}) diff --git a/test/services/charging-module/wait-for-status.service.test.js b/test/services/charging-module/wait-for-status.service.test.js new file mode 100644 index 0000000000..dbd8ca95cc --- /dev/null +++ b/test/services/charging-module/wait-for-status.service.test.js @@ -0,0 +1,165 @@ +'use strict' + +// Test framework dependencies +const Lab = require('@hapi/lab') +const Code = require('@hapi/code') +const Sinon = require('sinon') + +const { describe, it, beforeEach, afterEach } = exports.lab = Lab.script() +const { expect } = Code + +// Test helpers +const billingConfig = require('../../../config/billing.config.js') +const ExpandedError = require('../../../app/errors/expanded.error.js') + +// Things we need to stub +const BillRunStatusService = require('../../../app/services/charging-module/bill-run-status.service.js') + +// Thing under test +const ChargingModuleWaitForStatusService = require('../../../app/services/charging-module/wait-for-status.service.js') + +describe('Charging Module Wait For Status service', () => { + const billRunId = '2bbbe459-966e-4026-b5d2-2f10867bdddd' + const statusesToWaitFor = ['billed', 'billing_not_required'] + + let billRunStatusServiceStub + let maxNumberOfAttempts + + beforeEach(async () => { + billRunStatusServiceStub = Sinon.stub(BillRunStatusService, 'go') + + // Set the pause between requests to just 50ms so our tests are not slowed down or cause a timeout + Sinon.replace(billingConfig, 'waitForStatusPauseInMs', 50) + }) + + afterEach(() => { + Sinon.restore() + }) + + describe('when the Charging Module returns one of the statuses waited for', () => { + describe('on the first attempt', () => { + beforeEach(() => { + billRunStatusServiceStub.onFirstCall().resolves(_testResponse('billing_not_required')) + }) + + it('returns a `true` succeeded status', async () => { + const result = await ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor) + + expect(result.succeeded).to.be.true() + }) + + it('returns the last status received', async () => { + const result = await ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor) + + expect(result.status).to.equal('billing_not_required') + }) + + it('returns the number of attempts', async () => { + const result = await ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor) + + expect(result.attempts).to.equal(1) + }) + }) + + describe('after a number of attempts', () => { + beforeEach(() => { + // NOTE: We set the maximum number of attempts greater than how many it takes to succeed to demonstrate that the + // service returns as soon as the status we are waiting for is returned. + maxNumberOfAttempts = 5 + + billRunStatusServiceStub.onFirstCall().resolves(_testResponse('processing')) + billRunStatusServiceStub.onSecondCall().resolves(_testResponse('processing')) + billRunStatusServiceStub.onThirdCall().resolves(_testResponse('billed')) + }) + + it('returns a `true` success status', async () => { + const result = await ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor, maxNumberOfAttempts) + + expect(result.succeeded).to.be.true() + }) + + it('returns the last status received', async () => { + const result = await ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor, maxNumberOfAttempts) + + expect(result.status).to.equal('billed') + }) + + it('returns the number of attempts', async () => { + const result = await ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor, maxNumberOfAttempts) + + expect(result.attempts).to.equal(3) + }) + }) + }) + + describe('when the Charging Module never returns the status waited for', () => { + beforeEach(() => { + // Set the number of attempts to the same as the number of requests we've stubbed + maxNumberOfAttempts = 3 + + billRunStatusServiceStub.onFirstCall().resolves(_testResponse('processing')) + billRunStatusServiceStub.onSecondCall().resolves(_testResponse('processing')) + billRunStatusServiceStub.onThirdCall().resolves(_testResponse('processing')) + }) + + it('returns a `false` success status', async () => { + const result = await ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor, maxNumberOfAttempts) + + expect(result.succeeded).to.be.false() + }) + + it('returns the last status received', async () => { + const result = await ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor, maxNumberOfAttempts) + + expect(result.status).to.equal('processing') + }) + + it('returns the number of attempts', async () => { + const result = await ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor, maxNumberOfAttempts) + + expect(result.attempts).to.equal(3) + }) + }) + + describe('when a request to get the bill run status fails', () => { + beforeEach(() => { + billRunStatusServiceStub.resolves({ + succeeded: false, + response: { + headers: { + 'x-cma-git-commit': '273604040a47e0977b0579a0fef0f09726d95e39', + 'x-cma-docker-tag': 'ghcr.io/defra/sroc-charging-module-api:v0.19.0' + }, + statusCode: 404, + statusMessage: 'Not Found', + body: { statusCode: 404, error: 'Not Found', message: 'Not Found' } + } + }) + }) + + it('throws an error', async () => { + const error = await expect(ChargingModuleWaitForStatusService.go(billRunId, statusesToWaitFor)).to.reject() + + expect(error).to.be.instanceOf(ExpandedError) + expect(error.message).to.equal('Charging Module wait for status request failed') + expect(error.billRunExternalId).to.equal(billRunId) + expect(error.responseBody).to.equal({ statusCode: 404, error: 'Not Found', message: 'Not Found' }) + }) + }) +}) + +function _testResponse (status = 'processing') { + return { + succeeded: true, + response: { + info: { + gitCommit: '273604040a47e0977b0579a0fef0f09726d95e39', + dockerTag: 'ghcr.io/defra/sroc-charging-module-api:v0.19.0' + }, + statusCode: 200, + body: { + status + } + } + } +}