From 20d1e8bab6b5fffe162c47833da7029f286c7286 Mon Sep 17 00:00:00 2001 From: Stuart Adair <43574728+StuAA78@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:34:13 +0100 Subject: [PATCH] Create endpoint to generate mock data (#347) https://eaflood.atlassian.net/browse/WATER-4070 https://eaflood.atlassian.net/browse/WATER-4073 We are looking to re-design the bill runs page in the internal service due to repeated issues with the existing one timing out. It is simply trying to return too much data when displaying annual bill runs. To properly test the new design our UAT team hit a blocker; how do we demonstrate a large bill run in the prototype? They were able to manually create a bill run or 2. But to properly test this they needed 100's of licences represented and thousands of bills. They also had to be 'realistic' because previous testing had highlighted users got distracted by inaccuracies in the test data. So, we have created a new feature in the app which allows the delivery team to 'mock' existing records. Currently, it only supports mocking bill runs but this change is done in a way it should be easy to add additional entity types if needed. To mock a bill run, a team member would first identify one in the environment they are connected to that they would like to mock (the endpoint is only available in non-prod environments). They then extract the ID from the URL and make a request in their browser to - `https://[non-prod-environment-domain]/system/data/mock/bill-run/96187453-7243-4f45-a6a7-d0c5f530cbac` > When called in our local development environment drop `/system` from the URL The system will then use the existing bill run as a template, mock things like invoice and licence holder details and obfuscate other data before returning the mocked bill run as JSON. The format returned is based on what the prototype is already using so it can be copied and pasted right in. --- Co-authored-by: Alan Cruikshanks --- app/controllers/data/data.controller.js | 9 + .../data/mock-bill-run.presenter.js | 205 +++++++++++++++++ app/routes/data.routes.js | 9 + .../data/mock/generate-bill-run.service.js | 190 ++++++++++++++++ .../data/mock/generate-mock-data.service.js | 77 +++++++ app/services/data/mock/mock.service.js | 39 ++++ app/services/plugins/filter-routes.service.js | 2 +- test/controllers/data/data.controller.test.js | 37 ++- .../data/mock-bill-run.presenter.test.js | 210 ++++++++++++++++++ .../mock/generate-bill-run.service.test.js | 136 ++++++++++++ .../mock/generate-mock-data.service.test.js | 31 +++ test/services/data/mock/mock.service.test.js | 88 ++++++++ 12 files changed, 1031 insertions(+), 2 deletions(-) create mode 100644 app/presenters/data/mock-bill-run.presenter.js create mode 100644 app/services/data/mock/generate-bill-run.service.js create mode 100644 app/services/data/mock/generate-mock-data.service.js create mode 100644 app/services/data/mock/mock.service.js create mode 100644 test/presenters/data/mock-bill-run.presenter.test.js create mode 100644 test/services/data/mock/generate-bill-run.service.test.js create mode 100644 test/services/data/mock/generate-mock-data.service.test.js create mode 100644 test/services/data/mock/mock.service.test.js diff --git a/app/controllers/data/data.controller.js b/app/controllers/data/data.controller.js index 79dc55c4bc..19e88e976f 100644 --- a/app/controllers/data/data.controller.js +++ b/app/controllers/data/data.controller.js @@ -6,6 +6,7 @@ */ const ExportService = require('../../services/data/export/export.service.js') +const MockService = require('../../services/data/mock/mock.service.js') const SeedService = require('../../services/data/seed/seed.service.js') const TearDownService = require('../../services/data/tear-down/tear-down.service.js') @@ -20,6 +21,13 @@ async function exportDb (_request, h) { return h.response().code(204) } +async function mock (request, h) { + const { type, id } = request.params + const mockData = await MockService.go(type, id) + + return h.response(mockData) +} + async function seed (_request, h) { await SeedService.go() @@ -34,6 +42,7 @@ async function tearDown (_request, h) { module.exports = { exportDb, + mockData: mock, seed, tearDown } diff --git a/app/presenters/data/mock-bill-run.presenter.js b/app/presenters/data/mock-bill-run.presenter.js new file mode 100644 index 0000000000..2b10fd01d5 --- /dev/null +++ b/app/presenters/data/mock-bill-run.presenter.js @@ -0,0 +1,205 @@ +'use strict' + +/** + * Formats the response for the GET `/data/mock/{bill-run}` endpoint + * @module MockBillRunPresenter + */ + +const { convertPenceToPounds, formatAbstractionPeriod, formatLongDate, formatNumberAsMoney } = require('../base.presenter.js') + +function go (billingBatch) { + const { + billingInvoices, + billRunNumber, + createdAt, + fromFinancialYearEnding, + netTotal, + region, + status, + scheme, + toFinancialYearEnding, + batchType: type, + transactionFileReference: transactionFile + } = billingBatch + + return { + dateCreated: formatLongDate(createdAt), + status, + region: region.name, + type, + chargeScheme: scheme === 'sroc' ? 'Current' : 'Old', + transactionFile, + billRunNumber, + financialYear: `${fromFinancialYearEnding} to ${toFinancialYearEnding}`, + debit: formatNumberAsMoney(convertPenceToPounds(netTotal)), + bills: _formatBillingInvoices(billingInvoices) + } +} + +function _formatAdditionalCharges (transaction) { + const formattedData = [] + + const { grossValuesCalculated, isWaterCompanyCharge, supportedSourceName } = transaction + + if (supportedSourceName) { + const formattedSupportedSourceCharge = formatNumberAsMoney(grossValuesCalculated.supportedSourceCharge, true) + formattedData.push(`Supported source ${supportedSourceName} (${formattedSupportedSourceCharge})`) + } + + if (isWaterCompanyCharge) { + formattedData.push('Public Water Supply') + } + + return formattedData +} + +function _formatAdjustments (chargeElement) { + const formattedData = [] + + if (!chargeElement.adjustments) { + return formattedData + } + + const { aggregate, charge, s126, s127, s130, winter } = chargeElement.adjustments + + if (aggregate) { + formattedData.push(`Aggregate factor (${aggregate})`) + } + + if (charge) { + formattedData.push(`Adjustment factor (${charge})`) + } + + if (s126) { + formattedData.push(`Abatement factor (${s126})`) + } + + if (s127) { + formattedData.push('Two-part tariff (0.5)') + } + + if (s130) { + formattedData.push('Canal and River Trust (0.5)') + } + + if (winter) { + formattedData.push('Winter discount (0.5)') + } + + return formattedData +} + +function _formatBillingInvoices (billingInvoices) { + return billingInvoices.map((billingInvoice) => { + const { + accountAddress, + billingInvoiceLicences, + contact, + creditNoteValue, + invoiceValue, + netAmount, + billingInvoiceId: id, + invoiceAccountNumber: account, + invoiceNumber: number + } = billingInvoice + + return { + id, + account, + number, + accountAddress, + contact, + isWaterCompany: billingInvoiceLicences[0].licence.isWaterUndertaker, + credit: formatNumberAsMoney(convertPenceToPounds(creditNoteValue)), + debit: formatNumberAsMoney(convertPenceToPounds(invoiceValue)), + netTotal: formatNumberAsMoney(convertPenceToPounds(netAmount)), + licences: _formatBillingInvoiceLicences(billingInvoiceLicences) + } + }) +} + +function _formatBillingInvoiceLicences (billingInvoiceLicences) { + return billingInvoiceLicences.map((billingInvoiceLicence) => { + const { + billingTransactions, + credit, + debit, + netTotal, + licenceHolder, + billingInvoiceLicenceId: id, + licenceRef: licence + } = billingInvoiceLicence + + return { + id, + licence, + licenceStartDate: billingInvoiceLicence.licence.startDate, + licenceHolder, + credit: formatNumberAsMoney(convertPenceToPounds(credit)), + debit: formatNumberAsMoney(convertPenceToPounds(debit)), + netTotal: formatNumberAsMoney(convertPenceToPounds(netTotal)), + transactions: _formatBillingTransactions(billingTransactions) + } + }) +} + +function _formatBillingTransactions (billingTransactions) { + return billingTransactions.map((billingTransaction) => { + const { + authorisedDays, + billableDays, + chargeCategoryCode, + chargeElement, + chargeType, + endDate, + grossValuesCalculated, + isCredit, + netAmount, + startDate, + billableQuantity: chargeQuantity, + chargeCategoryDescription: chargeDescription, + description: lineDescription + } = billingTransaction + + return { + type: chargeType === 'standard' ? 'Water abstraction charge' : 'Compensation charge', + lineDescription, + billableDays, + authorisedDays, + chargeQuantity, + credit: isCredit ? formatNumberAsMoney(convertPenceToPounds(netAmount)) : '0.00', + debit: isCredit ? '0.00' : formatNumberAsMoney(convertPenceToPounds(netAmount)), + chargePeriod: `${formatLongDate(startDate)} to ${formatLongDate(endDate)}`, + chargeRefNumber: `${chargeCategoryCode} (${formatNumberAsMoney(grossValuesCalculated.baselineCharge, true)})`, + chargeDescription, + addCharges: _formatAdditionalCharges(billingTransaction), + adjustments: _formatAdjustments(chargeElement), + elements: _formatChargePurposes(chargeElement.chargePurposes) + } + }) +} + +function _formatChargePurposes (chargePurposes) { + return chargePurposes.map((chargePurpose) => { + const { + purposesUse, + abstractionPeriodStartDay: startDay, + abstractionPeriodStartMonth: startMonth, + abstractionPeriodEndDay: endDay, + abstractionPeriodEndMonth: endMonth, + authorisedAnnualQuantity: authorisedQuantity, + chargePurposeId: id + } = chargePurpose + + return { + id, + purpose: purposesUse.description, + abstractionPeriod: formatAbstractionPeriod(startDay, startMonth, endDay, endMonth), + authorisedQuantity + } + }) +} + +module.exports = { + go +} diff --git a/app/routes/data.routes.js b/app/routes/data.routes.js index 6649c7d8ea..f184a2db91 100644 --- a/app/routes/data.routes.js +++ b/app/routes/data.routes.js @@ -15,6 +15,15 @@ const routes = [ } } }, + { + method: 'GET', + path: '/data/mock/{type}/{id}', + handler: DataController.mockData, + options: { + description: 'Used to generate mock data', + app: { excludeFromProd: true } + } + }, { method: 'POST', path: '/data/seed', diff --git a/app/services/data/mock/generate-bill-run.service.js b/app/services/data/mock/generate-bill-run.service.js new file mode 100644 index 0000000000..e4d1692ed0 --- /dev/null +++ b/app/services/data/mock/generate-bill-run.service.js @@ -0,0 +1,190 @@ +'use strict' + +/** + * Generates a mock bill run based on a real one + * @module GenerateBillRunService + */ + +const BillingBatchModel = require('../../../models/water/billing-batch.model.js') +const GenerateMockDataService = require('./generate-mock-data.service.js') +const MockBillRunPresenter = require('../../../presenters/data/mock-bill-run.presenter.js') + +async function go (id) { + const billingBatch = await _fetchBillingBatch(id) + + if (!billingBatch) { + throw new Error('No matching bill run exists') + } + + _mockBillingInvoices(billingBatch.billingInvoices) + + return _response(billingBatch) +} + +async function _fetchBillingBatch (id) { + return BillingBatchModel.query() + .findById(id) + .select([ + 'billingBatchId', + 'batchType', + 'billRunNumber', + 'dateCreated', + 'fromFinancialYearEnding', + 'netTotal', + 'scheme', + 'status', + 'toFinancialYearEnding', + 'transactionFileReference' + ]) + .withGraphFetched('region') + .modifyGraph('region', (builder) => { + builder.select([ + 'name' + ]) + }) + .withGraphFetched('billingInvoices') + .modifyGraph('billingInvoices', (builder) => { + builder.select([ + 'billingInvoiceId', + 'creditNoteValue', + 'invoiceAccountNumber', + 'invoiceNumber', + 'invoiceValue', + 'netAmount' + ]) + }) + .withGraphFetched('billingInvoices.billingInvoiceLicences') + .modifyGraph('billingInvoices.billingInvoiceLicences', (builder) => { + builder.select([ + 'billingInvoiceLicenceId', + 'licenceRef' + ]) + }) + .withGraphFetched('billingInvoices.billingInvoiceLicences.licence') + .modifyGraph('billingInvoices.billingInvoiceLicences.licence', (builder) => { + builder.select([ + 'isWaterUndertaker' + ]) + }) + .withGraphFetched('billingInvoices.billingInvoiceLicences.billingTransactions') + .modifyGraph('billingInvoices.billingInvoiceLicences.billingTransactions', (builder) => { + builder.select([ + 'authorisedDays', + 'billableDays', + 'billableQuantity', + 'chargeCategoryCode', + 'chargeCategoryDescription', + 'chargeType', + 'description', + 'endDate', + 'grossValuesCalculated', + 'isCredit', + 'netAmount', + 'startDate', + 'supportedSourceName' + ]) + }) + .withGraphFetched('billingInvoices.billingInvoiceLicences.billingTransactions.chargeElement') + .modifyGraph('billingInvoices.billingInvoiceLicences.billingTransactions.chargeElement', (builder) => { + builder.select([ + 'adjustments' + ]) + }) + .withGraphFetched('billingInvoices.billingInvoiceLicences.billingTransactions.chargeElement.chargePurposes') + .modifyGraph('billingInvoices.billingInvoiceLicences.billingTransactions.chargeElement.chargePurposes', (builder) => { + builder.select([ + 'chargePurposeId', + 'abstractionPeriodStartDay', + 'abstractionPeriodStartMonth', + 'abstractionPeriodEndDay', + 'abstractionPeriodEndMonth', + 'authorisedAnnualQuantity' + ]) + }) + .withGraphFetched('billingInvoices.billingInvoiceLicences.billingTransactions.chargeElement.chargePurposes.purposesUse') + .modifyGraph('billingInvoices.billingInvoiceLicences.billingTransactions.chargeElement.chargePurposes.purposesUse', (builder) => { + builder.select([ + 'description' + ]) + }) +} + +/** + * Masks an invoice account number by replacing the first 3 digits, for example, T88898349A becomes Z11898349A + */ +function _maskInvoiceAccountNumber (invoiceAccountNumber) { + return `Z11${invoiceAccountNumber.substring(3)}` +} + +/** + * Masks an invoice number by replacing the first 2 digits, for example, TAI0000011T becomes ZZI0000011T + */ +function _maskInvoiceNumber (invoiceNumber) { + return `ZZ${invoiceNumber.substring(2)}` +} + +function _mockBillingInvoices (billingInvoices) { + billingInvoices.forEach((billingInvoice) => { + const { address, name } = GenerateMockDataService.go() + + billingInvoice.accountAddress = address + billingInvoice.contact = name + + billingInvoice.invoiceAccountNumber = _maskInvoiceAccountNumber(billingInvoice.invoiceAccountNumber) + billingInvoice.invoiceNumber = _maskInvoiceNumber(billingInvoice.invoiceNumber) + + _mockBillingInvoiceLicences(billingInvoice.billingInvoiceLicences) + }) +} + +function _mockBillingInvoiceLicences (billingInvoiceLicences) { + billingInvoiceLicences.forEach((billingInvoiceLicence) => { + const { name } = GenerateMockDataService.go() + const { credit, debit, netTotal } = _transactionTotals(billingInvoiceLicence.billingTransactions) + + billingInvoiceLicence.licenceHolder = name + billingInvoiceLicence.credit = credit + billingInvoiceLicence.debit = debit + billingInvoiceLicence.netTotal = netTotal + }) +} + +function _response (mockedBillingBatch) { + return MockBillRunPresenter.go(mockedBillingBatch) +} + +/** + * Calculate the totals for a licence based on the transaction values + * + * We don't hold totals in the `billing_invoice_licence` record. But the UI shows them. We found it is calculating + * these on the fly in the UI code so we need to replicate the same behaviour. + * + * Another thing to note is that if a transaction is flagged as a credit, then `netAmount` will be held as a signed + * value, for example -213.40. This is why it might look confusing we are always adding on each iteration but the + * calculation will be correct. + * @param {*} transactions + * @returns + */ +function _transactionTotals (transactions) { + const values = { + debit: 0, + credit: 0, + netTotal: 0 + } + + transactions.forEach((transaction) => { + if (transaction.isCredit) { + values.credit += transaction.netAmount + } else { + values.debit += transaction.netAmount + } + + values.netTotal += transaction.netAmount + }) + + return values +} + +module.exports = { + go +} diff --git a/app/services/data/mock/generate-mock-data.service.js b/app/services/data/mock/generate-mock-data.service.js new file mode 100644 index 0000000000..07ee0ec398 --- /dev/null +++ b/app/services/data/mock/generate-mock-data.service.js @@ -0,0 +1,77 @@ +'use strict' + +/** + * Generates mock data for use in the other generators + * @module GenerateMockDataService + */ + +const ADDRESS_FIRST_LINES = [ + 'Fake Street', + 'Fake Avenue', + 'Fake Road', + 'Fake Court', + 'Fake Crescent' +] + +const ADDRESS_SECOND_LINES = [ + 'Fakesville', + 'Faketown', + 'Fakechester' +] + +const FIRST_NAMES = [ + 'Stuart', + 'Alan', + 'Rebecca', + 'Jason', + 'Mandy', + 'Chris' +] + +const LAST_NAMES = [ + 'Adair', + 'Cruikshanks', + 'Ransome', + 'Claxton', + 'White', + 'Barrett' +] + +function go () { + return { + address: _generateAddress(), + name: _generateName() + } +} + +function _generateAddress () { + return [ + _randomDigit() + ' ' + _pickRandomElement(ADDRESS_FIRST_LINES), + _pickRandomElement(ADDRESS_SECOND_LINES), + _generatePostcode() + ] +} + +function _generateName () { + return `${_pickRandomElement(FIRST_NAMES)} ${_pickRandomElement(LAST_NAMES)}` +} + +function _generatePostcode () { + return `${_randomLetter()}${_randomLetter()}${_randomDigit()}${_randomDigit()} ${_randomDigit()}${_randomLetter()}${_randomLetter()}` +} + +function _randomDigit () { + return Math.floor(Math.random() * 9) + 1 +} + +function _randomLetter () { + return String.fromCharCode(65 + Math.floor(Math.random() * 26)) +} + +function _pickRandomElement (array) { + return array[Math.floor(Math.random() * array.length)] +} + +module.exports = { + go +} diff --git a/app/services/data/mock/mock.service.js b/app/services/data/mock/mock.service.js new file mode 100644 index 0000000000..4441dfd52a --- /dev/null +++ b/app/services/data/mock/mock.service.js @@ -0,0 +1,39 @@ +'use strict' + +/** + * Generates mock data for prototype and test use + * @module MockService + */ + +const ExpandedError = require('../../../errors/expanded.error.js') +const GenerateBillRunService = require('./generate-bill-run.service.js') + +const types = { + 'bill-run': _billRun +} + +async function go (type, id) { + _validateParams(type, id) + + return types[type](id) +} + +function _validateParams (type, id) { + // Validate that a type and id have been provided + if (!type || !id) { + throw new ExpandedError('Both type and ID are required for the mocking', { type, id }) + } + + // Validate that the provided type is supported + if (!Object.keys(types).includes(type)) { + throw new ExpandedError('Mocking is not supported for this type', { type, id }) + } +} + +async function _billRun (id) { + return GenerateBillRunService.go(id) +} + +module.exports = { + go +} diff --git a/app/services/plugins/filter-routes.service.js b/app/services/plugins/filter-routes.service.js index 1aa7d858c9..5232d7e187 100644 --- a/app/services/plugins/filter-routes.service.js +++ b/app/services/plugins/filter-routes.service.js @@ -38,7 +38,7 @@ function go (routes, environment) { } function _protectedEnvironment (environment) { - return ['pre', 'prd'].includes(environment) + return ['prd'].includes(environment) } function _filteredRoutes (routes) { diff --git a/test/controllers/data/data.controller.test.js b/test/controllers/data/data.controller.test.js index 2d8a71d052..d3ad787efd 100644 --- a/test/controllers/data/data.controller.test.js +++ b/test/controllers/data/data.controller.test.js @@ -9,7 +9,8 @@ const { describe, it, beforeEach, afterEach } = exports.lab = Lab.script() const { expect } = Code // Things we need to stub -const ExportService = require('../../../app/services/data/export/export.service') +const ExportService = require('../../../app/services/data/export/export.service.js') +const MockService = require('../../../app/services/data/mock/mock.service.js') const SeedService = require('../../../app/services/data/seed/seed.service.js') const TearDownService = require('../../../app/services/data/tear-down/tear-down.service.js') @@ -54,6 +55,40 @@ describe('Data controller', () => { }) }) + describe('GET /data/mock', () => { + const options = { + method: 'GET', + url: '/data/mock/licence/32055e54-a17d-4629-837d-5da51390bb47' + } + + describe('when the request succeeds', () => { + beforeEach(async () => { + Sinon.stub(MockService, 'go').resolves({ data: 'mock' }) + }) + + it('displays the correct message', async () => { + const response = await server.inject(options) + + expect(response.statusCode).to.equal(200) + // TODO: test the response object + }) + }) + + describe('when the request fails', () => { + describe('because the MockService errors', () => { + beforeEach(async () => { + Sinon.stub(MockService, 'go').rejects() + }) + + it('returns a 500 status', async () => { + const response = await server.inject(options) + + expect(response.statusCode).to.equal(500) + }) + }) + }) + }) + describe('POST /data/seed', () => { const options = { method: 'POST', diff --git a/test/presenters/data/mock-bill-run.presenter.test.js b/test/presenters/data/mock-bill-run.presenter.test.js new file mode 100644 index 0000000000..380ba09eba --- /dev/null +++ b/test/presenters/data/mock-bill-run.presenter.test.js @@ -0,0 +1,210 @@ +'use strict' + +// Test framework dependencies +const Lab = require('@hapi/lab') +const Code = require('@hapi/code') + +const { describe, it } = exports.lab = Lab.script() +const { expect } = Code + +// Thing under test +const MockBillRunPresenter = require('../../../app/presenters//data/mock-bill-run.presenter.js') + +describe('Mock Bill Run presenter', () => { + describe('when provided with a mocked billing batch', () => { + it('correctly presents the billing batch', () => { + const result = MockBillRunPresenter.go(mockedBillingBatch) + + expect(result.dateCreated).to.equal('9 August 2023') + expect(result.status).to.equal('sent') + expect(result.region).to.equal('Thames') + expect(result.type).to.equal('supplementary') + expect(result.chargeScheme).to.equal('Current') + expect(result.transactionFile).to.equal('nalti50013t') + expect(result.billRunNumber).to.equal(10029) + expect(result.financialYear).to.equal('2023 to 2024') + expect(result.debit).to.equal('840.00') + }) + + it('correctly presents a billing invoice', () => { + const { bills: results } = MockBillRunPresenter.go(mockedBillingBatch) + + expect(results[0].id).to.equal('86e5841a-81a9-4207-97ce-cee0917c0975') + expect(results[0].account).to.equal('Z11895994A') + expect(results[0].number).to.equal('ZZI0000013T') + expect(results[0].accountAddress).to.be.an.array() + expect(results[0].accountAddress[0]).to.be.a.string() + expect(results[0].contact).to.be.a.string() + expect(results[0].isWaterCompany).to.be.false() + expect(results[0].credit).to.equal('0.00') + expect(results[0].debit).to.equal('840.00') + expect(results[0].netTotal).to.equal('840.00') + }) + + it('correctly presents a billing invoice licence', () => { + const { licences: results } = MockBillRunPresenter.go(mockedBillingBatch).bills[0] + + expect(results[0].id).to.equal('bedd5971-4491-4c6f-a8cd-b75592ab4328') + expect(results[0].licence).to.equal('TH/037/0051/002') + expect(results[0].licenceHolder).to.be.a.string() + expect(results[0].credit).to.equal('0.00') + expect(results[0].debit).to.equal('840.00') + expect(results[0].netTotal).to.equal('840.00') + }) + + it('correctly presents a billing transaction', () => { + const { transactions: results } = MockBillRunPresenter.go(mockedBillingBatch).bills[0].licences[0] + + expect(results[0].type).to.equal('Water abstraction charge') + expect(results[0].lineDescription).to.equal('Water abstraction charge: Chris data thingy') + expect(results[0].billableDays).to.equal(151) + expect(results[0].authorisedDays).to.equal(151) + expect(results[0].chargeQuantity).to.equal(100) + expect(results[0].credit).to.equal('0.00') + expect(results[0].debit).to.equal('840.00') + expect(results[0].chargePeriod).to.equal('1 April 2022 to 31 March 2023') + expect(results[0].chargeRefNumber).to.equal('4.5.13 (£1162.00)') + expect(results[0].chargeDescription).to.equal('Medium loss, non-tidal, greater than 83 up to and including 142 ML/yr') + expect(results[0].addCharges).to.equal(['Supported source Thames (£518.00)']) + expect(results[0].adjustments).to.equal(['Winter discount (0.5)']) + }) + + it('correctly presents a charge purpose', () => { + const { elements: results } = MockBillRunPresenter.go(mockedBillingBatch).bills[0].licences[0].transactions[0] + + expect(results[0].id).to.equal('30c31312-59ef-4818-8e78-20ac115c39f7') + expect(results[0].purpose).to.equal('Make-Up Or Top Up Water') + expect(results[0].abstractionPeriod).to.equal('1 November to 31 March') + expect(results[0].authorisedQuantity).to.equal(10.22) + }) + }) +}) + +const mockedBillingBatch = { + billingBatchId: '6e9eb9f6-cf4d-40ea-929c-e8a915d84ef5', + batchType: 'supplementary', + billRunNumber: 10029, + fromFinancialYearEnding: 2023, + netTotal: 84000, + scheme: 'sroc', + status: 'sent', + toFinancialYearEnding: 2024, + transactionFileReference: 'nalti50013t', + createdAt: new Date(2023, 7, 9, 15, 2, 24, 783), + region: { + name: 'Thames' + }, + billingInvoices: [ + { + billingInvoiceId: '86e5841a-81a9-4207-97ce-cee0917c0975', + creditNoteValue: 0, + invoiceAccountNumber: 'Z11895994A', + invoiceNumber: 'ZZI0000013T', + invoiceValue: 84000, + netAmount: 84000, + billingInvoiceLicences: [ + { + billingInvoiceLicenceId: 'bedd5971-4491-4c6f-a8cd-b75592ab4328', + licenceRef: 'TH/037/0051/002', + licence: { + isWaterUndertaker: false + }, + billingTransactions: [ + { + authorisedDays: 151, + billableDays: 151, + billableQuantity: 100, + chargeCategoryCode: '4.5.13', + chargeCategoryDescription: 'Medium loss, non-tidal, greater than 83 up to and including 142 ML/yr', + chargeType: 'standard', + description: 'Water abstraction charge: Chris data thingy', + endDate: new Date(2023, 2, 31, 2), + grossValuesCalculated: { + baselineCharge: 1162, + supportedSourceCharge: 518 + }, + isCredit: false, + netAmount: 84000, + startDate: new Date(2022, 3, 1, 2), + supportedSourceName: 'Thames', + chargeElement: { + adjustments: { + s126: null, + s127: false, + s130: false, + charge: null, + winter: true, + aggregate: null + }, + chargePurposes: [ + { + chargePurposeId: '30c31312-59ef-4818-8e78-20ac115c39f7', + abstractionPeriodStartDay: 1, + abstractionPeriodStartMonth: 11, + abstractionPeriodEndDay: 31, + abstractionPeriodEndMonth: 3, + authorisedAnnualQuantity: 10.22, + purposesUse: { + description: 'Make-Up Or Top Up Water' + } + } + ] + } + }, + { + authorisedDays: 151, + billableDays: 151, + billableQuantity: 100, + chargeCategoryCode: '4.5.13', + chargeCategoryDescription: 'Medium loss, non-tidal, greater than 83 up to and including 142 ML/yr', + chargeType: 'compensation', + description: 'Compensation charge: calculated from the charge reference, activity description and regional environmental improvement charge; excludes any supported source additional charge and two-part tariff charge agreement', + endDate: new Date(2023, 2, 31, 2), + grossValuesCalculated: { + baselineCharge: 1162, + supportedSourceCharge: 0 + }, + isCredit: false, + netAmount: 0, + startDate: new Date(2022, 3, 1, 2), + supportedSourceName: 'Thames', + chargeElement: { + adjustments: { + s126: null, + s127: false, + s130: false, + charge: null, + winter: true, + aggregate: null + }, + chargePurposes: [ + { + chargePurposeId: '30c31312-59ef-4818-8e78-20ac115c39f7', + abstractionPeriodStartDay: 1, + abstractionPeriodStartMonth: 11, + abstractionPeriodEndDay: 31, + abstractionPeriodEndMonth: 3, + authorisedAnnualQuantity: 10.22, + purposesUse: { + description: 'Make-Up Or Top Up Water' + } + } + ] + } + } + ], + licenceHolder: 'Stuart Barrett', + credit: 0, + debit: 84000, + netTotal: 84000 + } + ], + accountAddress: [ + '2 Fake Street', + 'Fakechester', + 'XM53 3UX' + ], + contact: 'Rebecca Adair' + } + ] +} diff --git a/test/services/data/mock/generate-bill-run.service.test.js b/test/services/data/mock/generate-bill-run.service.test.js new file mode 100644 index 0000000000..a860053b22 --- /dev/null +++ b/test/services/data/mock/generate-bill-run.service.test.js @@ -0,0 +1,136 @@ +'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 BillingBatchHelper = require('../../../support/helpers/water/billing-batch.helper.js') +const BillingInvoiceHelper = require('../../../support/helpers/water/billing-invoice.helper.js') +const BillingInvoiceLicenceHelper = require('../../../support/helpers/water/billing-invoice-licence.helper.js') +const BillingTransactionHelper = require('../../../support/helpers/water/billing-transaction.helper.js') +const ChargeElement = require('../../../support/helpers/water/charge-element.helper.js') +const ChargePurpose = require('../../../support/helpers/water/charge-purpose.helper.js') +const LicenceHelper = require('../../../support/helpers/water/licence.helper.js') +const PurposesUseHelper = require('../../../support/helpers/water/purposes-use.helper.js') +const RegionHelper = require('../../../support/helpers/water/region.helper.js') +const DatabaseHelper = require('../../../support/helpers/database.helper.js') + +// Things we need to stub +const GenerateMockDataService = require('../../../../app/services/data/mock/generate-mock-data.service.js') + +// Thing under test +const GenerateBillRunService = require('../../../../app/services/data/mock/generate-bill-run.service.js') + +describe('Generate Bill Run service', () => { + let billingBatchId + + beforeEach(async () => { + await DatabaseHelper.clean() + + const generateMockDataServiceStub = Sinon.stub(GenerateMockDataService, 'go') + + generateMockDataServiceStub.onFirstCall().returns({ + address: ['7 Fake Court', 'Fakechester', 'FO68 7EJ'], + name: 'Jason White' + }) + + generateMockDataServiceStub.onSecondCall().returns({ + address: ['9 Fake Street', 'Fakesville', 'FT12 3BA'], + name: 'Rebecca Barrett' + }) + }) + + afterEach(() => { + Sinon.restore() + }) + + describe('when a billing batch with a matching ID exists', () => { + beforeEach(async () => { + const region = await RegionHelper.add() + const licence = await LicenceHelper.add({ regionId: region.regionId }) + const purposesUse = await PurposesUseHelper.add() + const chargeElement = await ChargeElement.add({ + adjustments: { s126: null, s127: false, s130: false, charge: null, winter: true, aggregate: null } + }) + await ChargePurpose.add({ chargeElementId: chargeElement.chargeElementId, purposeUseId: purposesUse.purposeUseId }) + const billingBatch = await BillingBatchHelper.add({ billRunNumber: 10029, regionId: region.regionId }) + const billingInvoice = await BillingInvoiceHelper.add({ billingBatchId: billingBatch.billingBatchId, invoiceNumber: 'TAI0000013T' }) + const billingInvoiceLicence = await BillingInvoiceLicenceHelper.add({ billingInvoiceId: billingInvoice.billingInvoiceId, licenceId: licence.licenceId }) + await BillingTransactionHelper.add({ + billingInvoiceLicenceId: billingInvoiceLicence.billingInvoiceLicenceId, + chargeElementId: chargeElement.chargeElementId, + endDate: new Date(2023, 2, 31, 2), + netAmount: 4200, + startDate: new Date(2022, 3, 1, 2), + grossValuesCalculated: { + baselineCharge: 1162, + supportedSourceCharge: 518 + } + }) + + billingBatchId = billingBatch.billingBatchId + }) + + it('returns the generated mock bill run', async () => { + const result = await GenerateBillRunService.go(billingBatchId) + + expect(result.billRunNumber).to.equal(10029) + }) + + it('adds a mock address to the bills', async () => { + const { bills: results } = await GenerateBillRunService.go(billingBatchId) + + expect(results[0].accountAddress).to.equal(['7 Fake Court', 'Fakechester', 'FO68 7EJ']) + }) + + it('adds a mock contact to the bills', async () => { + const { bills: results } = await GenerateBillRunService.go(billingBatchId) + + expect(results[0].contact).to.equal('Jason White') + }) + + it('masks the invoiceAccountNumber on the bills', async () => { + const { bills: results } = await GenerateBillRunService.go(billingBatchId) + + expect(results[0].account).to.equal('Z11345678A') + }) + + it('masks the invoiceNumber on the bills', async () => { + const { bills: results } = await GenerateBillRunService.go(billingBatchId) + + expect(results[0].number).to.equal('ZZI0000013T') + }) + + it('adds a mock licence holder to the licences', async () => { + const { bills: results } = await GenerateBillRunService.go(billingBatchId) + + expect(results[0].licences[0].licenceHolder).to.equal('Rebecca Barrett') + }) + + it('adds calculated transaction totals to the licences', async () => { + const { bills: results } = await GenerateBillRunService.go(billingBatchId) + + expect(results[0].licences[0].credit).to.equal('0.00') + expect(results[0].licences[0].debit).to.equal('42.00') + expect(results[0].licences[0].netTotal).to.equal('42.00') + }) + }) + + describe('when a billing batch with a matching ID does not exist', () => { + beforeEach(() => { + billingBatchId = 'b845bcc3-a5bd-4e42-9ed2-b3e27a837e85' + }) + + it('throws an error', async () => { + const error = await expect(GenerateBillRunService.go(billingBatchId)).to.reject() + + expect(error).to.be.an.error() + expect(error.message).to.equal('No matching bill run exists') + }) + }) +}) diff --git a/test/services/data/mock/generate-mock-data.service.test.js b/test/services/data/mock/generate-mock-data.service.test.js new file mode 100644 index 0000000000..771ad82206 --- /dev/null +++ b/test/services/data/mock/generate-mock-data.service.test.js @@ -0,0 +1,31 @@ +'use strict' + +// Test framework dependencies +const Lab = require('@hapi/lab') +const Code = require('@hapi/code') + +const { describe, it } = exports.lab = Lab.script() +const { expect } = Code + +// Thing under test +const GenerateMockDataService = require('../../../../app/services/data/mock/generate-mock-data.service.js') + +describe('Generate Bill Run service', () => { + describe('when called', () => { + it('generates a fake name', () => { + const result = GenerateMockDataService.go() + + expect(result.name).to.exist() + expect(result.name).to.be.a.string() + }) + + it('generates a fake address', () => { + const result = GenerateMockDataService.go() + + expect(result.address).to.exist() + expect(result.address).to.be.an.array() + expect(result.address).to.have.length(3) + expect(result.address[0]).to.be.a.string() + }) + }) +}) diff --git a/test/services/data/mock/mock.service.test.js b/test/services/data/mock/mock.service.test.js new file mode 100644 index 0000000000..e5eea7491c --- /dev/null +++ b/test/services/data/mock/mock.service.test.js @@ -0,0 +1,88 @@ +'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 ExpandedError = require('../../../../app/errors/expanded.error.js') + +// Things we need to stub +const GenerateBillRunService = require('../../../../app/services/data/mock/generate-bill-run.service.js') + +// Thing under test +const MockService = require('../../../../app/services/data/mock/mock.service.js') + +describe('Mock service', () => { + const billRunId = 'f55c824b-a3bb-4db1-a77a-bc264cad5a11' + + afterEach(() => { + Sinon.restore() + }) + + describe('if the call succeeds', () => { + describe("and the mock type is 'bill-run'", () => { + let generateBillRunServiceStub + + const expectedResult = { billRun: billRunId } + + beforeEach(async () => { + generateBillRunServiceStub = Sinon.stub(GenerateBillRunService, 'go').resolves(expectedResult) + }) + + it('calls the appropriate generate mock data service', async () => { + await MockService.go('bill-run', billRunId) + + expect(generateBillRunServiceStub.called).to.be.true() + }) + + it('returns the expected data', async () => { + const result = await MockService.go('bill-run', billRunId) + + expect(result).to.equal(expectedResult) + }) + }) + }) + + describe('if the call fails', () => { + describe('because no type was provided', () => { + it('throws an error', async () => { + const error = await expect(MockService.go()).to.reject() + + expect(error).to.be.an.error() + expect(error).to.be.an.instanceOf(ExpandedError) + expect(error.message).to.equal('Both type and ID are required for the mocking') + expect(error.type).to.be.undefined() + expect(error.id).to.be.undefined() + }) + }) + + describe('because no id was provided', () => { + it('throws an error', async () => { + const error = await expect(MockService.go('bill-run')).to.reject() + + expect(error).to.be.an.error() + expect(error).to.be.an.instanceOf(ExpandedError) + expect(error.message).to.equal('Both type and ID are required for the mocking') + expect(error.type).to.equal('bill-run') + expect(error.id).to.be.undefined() + }) + }) + + describe('because an invalid type was provided', () => { + it('throws an error', async () => { + const error = await expect(MockService.go('INVALID_TYPE', billRunId)).to.reject() + + expect(error).to.be.an.error() + expect(error).to.be.an.instanceOf(ExpandedError) + expect(error.message).to.equal('Mocking is not supported for this type') + expect(error.type).to.equal('INVALID_TYPE') + expect(error.id).to.equal(billRunId) + }) + }) + }) +})