@planning-inspectorate/data-model 2.40.0 → 2.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,6 @@
1
+ LOAD_TEST_SIMULATE_SENDING=true
2
+ LOAD_TEST_SERVICE_BUS_NAMESPACE=###.servicebus.windows.net
3
+ APPEALS_LOAD_TEST_CASE_TYPE=S78
4
+ APPEALS_LOAD_TEST_CASE_COUNT=1
5
+ APPEALS_LOAD_TEST_DOCUMENTS_PER_CASE=2
6
+ APPEALS_LOAD_TEST_BATCH_SIZE=50
@@ -0,0 +1,35 @@
1
+ # Appeals Load Test Script
2
+
3
+ This script is designed to allow load testing of service bus message consumers without the capacity of the sender being a limiting factor. The service bus client/strategy is hopefully resuable for other services in future
4
+
5
+ `load-test-appeals` sends a specified quantity of appeal case messages with service users and documents to service bus
6
+
7
+ This was written as a quick solution to provide ODW with the ability to run a load test without having to generate actual appeals within the Appeals Service, as generating them Appeals side proved too slow
8
+
9
+ ## Requirements
10
+
11
+ - node
12
+ - Azure VPN
13
+ - Azure CLI interface
14
+ - Your user has the ability to send messages manually on the given service bus topic
15
+ - Unsure if this can be run in WSL currently as az login seems to fail
16
+
17
+ ## Setup
18
+
19
+ `npm ci`
20
+
21
+ Create an .env file in the route of the project based on the example
22
+
23
+ - LOAD_TEST_SIMULATE_SENDING: (true|false) logs to console if true rather than sending, usful for checking data before sending
24
+ - LOAD_TEST_SERVICE_BUS_NAMESPACE: the fully qualified namespace of the service bus instance
25
+ - APPEALS_LOAD_TEST_CASE_TYPE: (HAS|S78) the case type topic to send on
26
+ - APPEALS_LOAD_TEST_CASE_COUNT: how many appeals to generate
27
+ - APPEALS_LOAD_TEST_DOCUMENTS_PER_CASE: how many documents to generate for each case
28
+ - APPEALS_LOAD_TEST_BATCH_SIZE: how many appeals are sent to service bus in one go, used to improve the speed in which messages can be sent to service bus
29
+
30
+ ## Run
31
+
32
+ - set the env vars as needed, retrieve the correct Service Bus namespace from Azure
33
+ - connect to Azure VPN
34
+ - `az login` - login as yourself and connect to test env subscription
35
+ - `npm run load-test-appeals`
@@ -0,0 +1,456 @@
1
+ import crypto from 'node:crypto';
2
+ import Ajv from 'ajv';
3
+ import addFormats from 'ajv-formats';
4
+ import { loadAllSchemasSync } from '../../src/index.js';
5
+
6
+ const HAS_SCHEMA = 'appeal-has.schema.json';
7
+ const S78_SCHEMA = 'appeal-s78.schema.json';
8
+ const DOCUMENT_SCHEMA = 'appeal-document.schema.json';
9
+ const SERVICE_USER_SCHEMA = 'service-user.schema.json';
10
+
11
+ const now = new Date().toISOString();
12
+
13
+ const BASE_HAS_CASE = {
14
+ caseId: null,
15
+ caseReference: 'mollit dolore dolor non culpa',
16
+ submissionId: null,
17
+ caseStatus: 'ready_to_start',
18
+ caseType: 'ZP',
19
+ caseProcedure: 'inquiry',
20
+ lpaCode: 'nulla',
21
+ caseOfficerId: null,
22
+ inspectorId: 'mollit in ullamco Excepteur commodo',
23
+ allocationLevel: 'D',
24
+ allocationBand: 1,
25
+ caseSpecialisms: ['et culpa elit labore', 'minim Lorem'],
26
+ caseSubmittedDate: now,
27
+ caseCreatedDate: now,
28
+ caseUpdatedDate: now,
29
+ caseValidDate: now,
30
+ caseValidationDate: null,
31
+ caseValidationOutcome: 'invalid',
32
+ caseValidationInvalidDetails: null,
33
+ caseValidationIncompleteDetails: [
34
+ 'occaecat dolor labore ut ipsum',
35
+ 'eiusmod ullamco pariatur aliqua esse',
36
+ 'eiusmod Ut officia',
37
+ 'aute',
38
+ 'do minim sint labore aliquip'
39
+ ],
40
+ caseExtensionDate: null,
41
+ caseStartedDate: null,
42
+ casePublishedDate: null,
43
+ linkedCaseStatus: null,
44
+ leadCaseReference: null,
45
+ lpaQuestionnaireDueDate: null,
46
+ lpaQuestionnaireSubmittedDate: null,
47
+ lpaQuestionnaireCreatedDate: now,
48
+ lpaQuestionnairePublishedDate: now,
49
+ lpaQuestionnaireValidationOutcome: 'incomplete',
50
+ lpaQuestionnaireValidationOutcomeDate: now,
51
+ lpaQuestionnaireValidationDetails: ['dolore cillum in anim adipisicing'],
52
+ lpaStatement: 'Ut ipsum et',
53
+ caseWithdrawnDate: null,
54
+ caseTransferredDate: null,
55
+ transferredCaseClosedDate: null,
56
+ caseDecisionOutcomeDate: null,
57
+ caseDecisionPublishedDate: null,
58
+ caseDecisionOutcome: null,
59
+ caseCompletedDate: null,
60
+ enforcementNotice: null,
61
+ applicationReference: 'eu',
62
+ applicationDate: now,
63
+ applicationDecision: 'refused',
64
+ applicationDecisionDate: now,
65
+ applicationDecisionDueDate: null,
66
+ caseSubmissionDueDate: now,
67
+ siteAddressLine1: 'ullamco',
68
+ siteAddressLine2: null,
69
+ siteAddressTown: 'dolor reprehenderit ut',
70
+ siteAddressCounty: 'dolore eu exercitation enim',
71
+ siteAddressPostcode: 'est',
72
+ siteAccessDetails: [
73
+ 'Ut',
74
+ 'eiusmod magna aliqua ipsum',
75
+ 'sit non ut amet aliqua',
76
+ 'id consectetur cupidatat',
77
+ 'incididunt ut Lorem eu'
78
+ ],
79
+ siteSafetyDetails: null,
80
+ siteAreaSquareMetres: 123,
81
+ floorSpaceSquareMetres: null,
82
+ isCorrectAppealType: null,
83
+ isGreenBelt: true,
84
+ inConservationArea: null,
85
+ affectsScheduledMonument: true,
86
+ hasProtectedSpecies: true,
87
+ isAonbNationalLandscape: null,
88
+ designatedSitesNames: null,
89
+ hasInfrastructureLevy: false,
90
+ isInfrastructureLevyFormallyAdopted: false,
91
+ infrastructureLevyAdoptedDate: null,
92
+ infrastructureLevyExpectedDate: null,
93
+ lpaProcedurePreference: null,
94
+ lpaProcedurePreferenceDetails: null,
95
+ lpaProcedurePreferenceDuration: 1,
96
+ caseworkReason: 'elit reprehenderit',
97
+ importantInformation: null,
98
+ jurisdiction: null,
99
+ redeterminedIndicator: null,
100
+ dateCostsReportDespatched: null,
101
+ dateNotRecoveredOrDerecovered: null,
102
+ dateRecovered: null,
103
+ originalCaseDecisionDate: null,
104
+ targetDate: null,
105
+ ownsAllLand: true,
106
+ ownsSomeLand: null,
107
+ knowsOtherOwners: 'Some',
108
+ knowsAllOwners: null,
109
+ advertisedAppeal: null,
110
+ notificationMethod: null,
111
+ ownersInformed: null,
112
+ originalDevelopmentDescription: null,
113
+ changedDevelopmentDescription: null,
114
+ newConditionDetails: 'tempor anim adipisicing',
115
+ nearbyCaseReferences: null,
116
+ neighbouringSiteAddresses: [
117
+ {
118
+ neighbouringSiteAddressLine1: 'eu veniam',
119
+ neighbouringSiteAddressLine2: null,
120
+ neighbouringSiteAddressTown: 'pariatur dolore',
121
+ neighbouringSiteAddressCounty: 'fugiat minim anim aliqua aliquip',
122
+ neighbouringSiteAddressPostcode: 'ex anim',
123
+ neighbouringSiteAccessDetails: null,
124
+ neighbouringSiteSafetyDetails: 'veniam in sunt'
125
+ }
126
+ ],
127
+ reasonForNeighbourVisits: null,
128
+ affectedListedBuildingNumbers: ['Excepteur', 'in consequat sit', 'esse sunt velit culpa', 'voluptate esse'],
129
+ appellantCostsAppliedFor: null,
130
+ lpaCostsAppliedFor: null,
131
+ typeOfPlanningApplication: 'householder-planning',
132
+ siteGridReferenceEasting: '357144',
133
+ siteGridReferenceNorthing: '400534',
134
+ hasLandownersPermission: null,
135
+ isSiteInAreaOfSpecialControlAdverts: false,
136
+ wasApplicationRefusedDueToHighwayOrTraffic: null,
137
+ didAppellantSubmitCompletePhotosAndPlans: null,
138
+ advertDetails: [
139
+ {
140
+ isAdvertInPosition: false,
141
+ isSiteOnHighwayLand: false,
142
+ advertType: 'Other'
143
+ }
144
+ ],
145
+ padsSapId: null
146
+ };
147
+
148
+ const BASE_S78_CASE = {
149
+ caseId: 96989394,
150
+ caseReference: 'exercitation dolore',
151
+ submissionId: null,
152
+ caseStatus: 'transferred',
153
+ caseType: 'D',
154
+ caseProcedure: 'inquiry',
155
+ lpaCode: 'pariatur cillum non',
156
+ caseOfficerId: null,
157
+ inspectorId: 'amet magna dolor in labore',
158
+ allocationLevel: null,
159
+ allocationBand: null,
160
+ caseSpecialisms: ['laboris est proident minim', 'qui sunt ut', 'consectetur Lorem do sunt aliquip'],
161
+ caseSubmittedDate: now,
162
+ caseCreatedDate: now,
163
+ caseUpdatedDate: now,
164
+ caseValidDate: null,
165
+ caseValidationDate: null,
166
+ caseValidationOutcome: null,
167
+ caseValidationInvalidDetails: null,
168
+ caseValidationIncompleteDetails: null,
169
+ caseExtensionDate: null,
170
+ caseStartedDate: now,
171
+ casePublishedDate: null,
172
+ linkedCaseStatus: 'lead',
173
+ leadCaseReference: null,
174
+ lpaQuestionnaireDueDate: null,
175
+ lpaQuestionnaireSubmittedDate: null,
176
+ lpaQuestionnaireCreatedDate: null,
177
+ lpaQuestionnairePublishedDate: null,
178
+ lpaQuestionnaireValidationOutcome: null,
179
+ lpaQuestionnaireValidationOutcomeDate: null,
180
+ lpaQuestionnaireValidationDetails: ['cupidatat dolore in', 'officia in nostrud non', 'eu amet velit labore aute'],
181
+ lpaStatement: 'ipsum consectetur labore pariatur',
182
+ listOfDocumentsBeforeDecision: null,
183
+ caseWithdrawnDate: null,
184
+ caseTransferredDate: null,
185
+ transferredCaseClosedDate: null,
186
+ caseDecisionOutcomeDate: null,
187
+ caseDecisionPublishedDate: null,
188
+ caseDecisionOutcome: null,
189
+ caseCompletedDate: null,
190
+ enforcementNotice: null,
191
+ applicationReference: 'consequat ullamco in aute amet',
192
+ applicationDate: now,
193
+ applicationDecision: 'not_received',
194
+ applicationDecisionDate: null,
195
+ applicationDecisionDueDate: now,
196
+ caseSubmissionDueDate: null,
197
+ siteAddressLine1: 'dolore mollit',
198
+ siteAddressLine2: null,
199
+ siteAddressTown: 'in',
200
+ siteAddressCounty: 'ut incididunt velit in',
201
+ siteAddressPostcode: 'ut',
202
+ siteAccessDetails: ['ullamco', 'elit in', 'velit officia pariatur mollit', 'nostrud ex culpa'],
203
+ siteSafetyDetails: null,
204
+ siteAreaSquareMetres: 69381079.34849802,
205
+ floorSpaceSquareMetres: null,
206
+ isCorrectAppealType: null,
207
+ isGreenBelt: false,
208
+ inConservationArea: null,
209
+ ownsAllLand: null,
210
+ ownsSomeLand: null,
211
+ knowsOtherOwners: null,
212
+ knowsAllOwners: null,
213
+ advertisedAppeal: true,
214
+ notificationMethod: ['notice'],
215
+ ownersInformed: null,
216
+ originalDevelopmentDescription: 'est',
217
+ changedDevelopmentDescription: null,
218
+ newConditionDetails: null,
219
+ nearbyCaseReferences: ['veniam voluptate incididunt Excepteur'],
220
+ neighbouringSiteAddresses: [
221
+ {
222
+ neighbouringSiteAddressLine1: 'laborum',
223
+ neighbouringSiteAddressLine2: null,
224
+ neighbouringSiteAddressTown: 'aute ipsum dolore',
225
+ neighbouringSiteAddressCounty: null,
226
+ neighbouringSiteAddressPostcode: 'dolor ipsum anim fugiat',
227
+ neighbouringSiteAccessDetails: 'reprehenderit mollit Duis exercitation',
228
+ neighbouringSiteSafetyDetails: null
229
+ }
230
+ ],
231
+ reasonForNeighbourVisits: null,
232
+ affectedListedBuildingNumbers: ['tempor adipisicing in'],
233
+ changedListedBuildingNumbers: null,
234
+ preserveGrantLoan: true,
235
+ consultHistoricEngland: false,
236
+ appellantCostsAppliedFor: null,
237
+ lpaCostsAppliedFor: null,
238
+ agriculturalHolding: null,
239
+ tenantAgriculturalHolding: null,
240
+ otherTenantsAgriculturalHolding: null,
241
+ informedTenantsAgriculturalHolding: true,
242
+ appellantProcedurePreference: 'hearing',
243
+ appellantProcedurePreferenceDetails: null,
244
+ appellantProcedurePreferenceDuration: null,
245
+ appellantProcedurePreferenceWitnessCount: null,
246
+ statusPlanningObligation: 'cillum proident fugiat ex quis',
247
+ affectsScheduledMonument: true,
248
+ hasProtectedSpecies: true,
249
+ isAonbNationalLandscape: null,
250
+ designatedSitesNames: null,
251
+ isGypsyOrTravellerSite: false,
252
+ isPublicRightOfWay: true,
253
+ eiaEnvironmentalImpactSchedule: 'schedule-1',
254
+ eiaDevelopmentDescription: 'agriculture-aquaculture',
255
+ eiaSensitiveAreaDetails: 'dolore non anim ut deserunt',
256
+ eiaColumnTwoThreshold: null,
257
+ eiaScreeningOpinion: true,
258
+ eiaRequiresEnvironmentalStatement: null,
259
+ eiaCompletedEnvironmentalStatement: null,
260
+ hasStatutoryConsultees: true,
261
+ consultedBodiesDetails: null,
262
+ hasInfrastructureLevy: null,
263
+ isInfrastructureLevyFormallyAdopted: null,
264
+ infrastructureLevyAdoptedDate: null,
265
+ infrastructureLevyExpectedDate: null,
266
+ lpaProcedurePreference: null,
267
+ lpaProcedurePreferenceDetails: null,
268
+ lpaProcedurePreferenceDuration: 12,
269
+ caseworkReason: 'sit ipsum',
270
+ importantInformation: null,
271
+ jurisdiction: null,
272
+ redeterminedIndicator: null,
273
+ dateCostsReportDespatched: null,
274
+ dateNotRecoveredOrDerecovered: null,
275
+ dateRecovered: null,
276
+ originalCaseDecisionDate: null,
277
+ targetDate: null,
278
+ appellantCommentsSubmittedDate: null,
279
+ appellantStatementSubmittedDate: null,
280
+ appellantProofsSubmittedDate: null,
281
+ finalCommentsDueDate: null,
282
+ interestedPartyRepsDueDate: null,
283
+ lpaCommentsSubmittedDate: null,
284
+ lpaProofsSubmittedDate: null,
285
+ lpaStatementSubmittedDate: null,
286
+ proofsOfEvidenceDueDate: null,
287
+ siteNoticesSentDate: null,
288
+ statementDueDate: null,
289
+ caseManagementConferenceDate: null,
290
+ numberOfResidencesNetChange: 59800248.893144846,
291
+ siteGridReferenceEasting: '357144',
292
+ siteGridReferenceNorthing: '400534',
293
+ siteViewableFromRoad: null,
294
+ siteWithinSSSI: null,
295
+ typeOfPlanningApplication: 'full-appeal',
296
+ developmentType: 'other-minor',
297
+ statementOfCommonGroundDueDate: null,
298
+ planningObligationDueDate: null,
299
+ advertDetails: [
300
+ {
301
+ isAdvertInPosition: false,
302
+ isSiteOnHighwayLand: false,
303
+ advertType: 'Other'
304
+ }
305
+ ],
306
+ padsSapId: null,
307
+ applicationMadeUnderActSection: 'existing-development',
308
+ siteUseAtTimeOfApplication: 'Site use at time of application',
309
+ appealUnderActSection: 'proposed-use-of-a-development',
310
+ lpaConsiderAppealInvalid: true,
311
+ lpaAppealInvalidReasons: 'Reason(s) why LPA consider the appeal invalid'
312
+ };
313
+
314
+ function randomCaseReference(prefix, index) {
315
+ const suffix = String(index + 1).padStart(7, '0');
316
+ return `${prefix}${suffix}`;
317
+ }
318
+
319
+ function randomHex(length) {
320
+ return crypto
321
+ .randomBytes(Math.ceil(length / 2))
322
+ .toString('hex')
323
+ .slice(0, length);
324
+ }
325
+
326
+ function createCase(caseType, index) {
327
+ const template = caseType === 'HAS' ? BASE_HAS_CASE : BASE_S78_CASE;
328
+ const entity = structuredClone(template);
329
+ const referencePrefix = caseType === 'HAS' ? 'HAS' : 'S78';
330
+
331
+ entity.caseId = 500000 + index;
332
+ entity.caseReference = randomCaseReference(referencePrefix, index);
333
+ entity.caseCreatedDate = now;
334
+ entity.caseUpdatedDate = now;
335
+ entity.caseSubmittedDate = now;
336
+ entity.applicationReference = `LOAD-${referencePrefix}-${index + 1}`;
337
+ entity.lpaCode = 'LOAD';
338
+
339
+ return entity;
340
+ }
341
+
342
+ function createServiceUser(casePayload, serviceUserType, index) {
343
+ const typeName = serviceUserType === 'Agent' ? 'Agent' : 'Appellant';
344
+ return {
345
+ id: crypto.randomUUID(),
346
+ salutation: 'Ms',
347
+ firstName: `Load-Test`,
348
+ lastName: `${typeName}${index + 1}`,
349
+ addressLine1: '1 Test Street',
350
+ addressLine2: null,
351
+ addressTown: 'Testville',
352
+ addressCounty: 'Testshire',
353
+ postcode: 'TE1 1ST',
354
+ addressCountry: 'United Kingdom',
355
+ organisation: 'Load Test Org',
356
+ organisationType: 'Private Company',
357
+ role: typeName,
358
+ telephoneNumber: '+447123456789',
359
+ otherPhoneNumber: null,
360
+ faxNumber: null,
361
+ emailAddress: `${typeName.toLowerCase()}${index + 1}@example.com`,
362
+ webAddress: 'https://example.com',
363
+ serviceUserType: typeName,
364
+ caseReference: casePayload.caseReference,
365
+ sourceSystem: 'back-office-appeals',
366
+ sourceSuid: crypto.randomUUID()
367
+ };
368
+ }
369
+
370
+ function createDocument(casePayload, caseIndex, documentIndex) {
371
+ const documentId = crypto.randomUUID();
372
+ const filename = `LOAD-${casePayload.caseReference}-doc-${documentIndex + 1}.pdf`;
373
+ const caseType = typeof casePayload.caseType === 'string' ? casePayload.caseType : null;
374
+ const documentUri = `https://example.local/appeal/${casePayload.caseReference}/${documentId}/v1/${filename}`;
375
+
376
+ return {
377
+ documentId,
378
+ caseId: casePayload.caseId,
379
+ caseReference: casePayload.caseReference,
380
+ version: 1,
381
+ filename,
382
+ originalFilename: filename,
383
+ size: 1000 + documentIndex,
384
+ mime: 'application/pdf',
385
+ documentURI: documentUri,
386
+ publishedDocumentURI: documentUri,
387
+ virusCheckStatus: 'scanned',
388
+ fileMD5: randomHex(32),
389
+ dateCreated: now,
390
+ dateReceived: now,
391
+ datePublished: now,
392
+ lastModified: now,
393
+ caseType,
394
+ redactedStatus: 'no_redaction_required',
395
+ documentType: 'plansDrawings',
396
+ sourceSystem: 'back-office-appeals',
397
+ origin: 'pins',
398
+ owner: 'Load Test Team',
399
+ author: 'Load Test Team',
400
+ description: `Load test document ${documentIndex + 1}`,
401
+ caseStage: 'appellant-case',
402
+ horizonFolderId: null
403
+ };
404
+ }
405
+
406
+ function buildAjvValidator() {
407
+ const schemas = loadAllSchemasSync();
408
+ const flatSchemas = Object.values(schemas).reduce((acc, current) => ({ ...acc, ...current }), {});
409
+ const ajv = new Ajv({ schemas: flatSchemas, allErrors: true });
410
+ addFormats(ajv);
411
+ return ajv;
412
+ }
413
+
414
+ function validateOrThrow(ajv, schemaName, payload, label) {
415
+ const valid = ajv.validate(schemaName, payload);
416
+ if (!valid) {
417
+ const errors = ajv.errorsText(ajv.errors, { separator: '\n' });
418
+ throw new Error(`${label} failed schema ${schemaName}:\n${errors}`);
419
+ }
420
+ }
421
+
422
+ /**
423
+ * @param {'HAS'|'S78'} [caseType]
424
+ * @param {number} [documentsPerCase]
425
+ * @param {number} [caseIndex]
426
+ * @returns {{case: any, serviceUsers: any[], documents: any[]}}
427
+ */
428
+ export const generateAppealsLoadTestMessages = (caseType = 'HAS', documentsPerCase = 2, caseIndex = 1) => {
429
+ const caseSchema = caseType === 'HAS' ? HAS_SCHEMA : S78_SCHEMA;
430
+ const ajv = buildAjvValidator();
431
+
432
+ const casePayload = createCase(caseType, caseIndex);
433
+ validateOrThrow(ajv, caseSchema, casePayload, `case[${caseIndex}]`);
434
+
435
+ const serviceUsers = [
436
+ createServiceUser(casePayload, 'Agent', caseIndex),
437
+ createServiceUser(casePayload, 'Appellant', caseIndex)
438
+ ];
439
+
440
+ for (const serviceUser of serviceUsers) {
441
+ validateOrThrow(ajv, SERVICE_USER_SCHEMA, serviceUser, `${caseIndex} serviceUser[${serviceUser.role}]`);
442
+ }
443
+
444
+ const documents = [];
445
+ for (let docIndex = 0; docIndex < documentsPerCase; docIndex += 1) {
446
+ const document = createDocument(casePayload, caseIndex, docIndex);
447
+ validateOrThrow(ajv, DOCUMENT_SCHEMA, document, `document[${caseIndex}][${docIndex}]`);
448
+ documents.push(document);
449
+ }
450
+
451
+ return {
452
+ case: casePayload,
453
+ serviceUsers,
454
+ documents
455
+ };
456
+ };
@@ -0,0 +1,58 @@
1
+ import { generateAppealsLoadTestMessages } from './generate-appeals-messages.js';
2
+ import { ServiceBusEventClient } from '../service-bus-client.js';
3
+ import { MESSAGE_EVENT_TYPE } from '../../src/enums.js';
4
+
5
+ /** @type {'HAS'|'S78'} */ //@ts-ignore
6
+ const caseType = process.env.APPEALS_LOAD_TEST_CASE_TYPE || 'HAS'; // 'HAS' or 'S78'
7
+ const caseCount = parseInt(process.env.APPEALS_LOAD_TEST_CASE_COUNT || '1', 10);
8
+ const documentsPerCase = parseInt(process.env.APPEALS_LOAD_TEST_DOCUMENTS_PER_CASE || '2', 10);
9
+ const batchSize = parseInt(process.env.APPEALS_LOAD_TEST_BATCH_SIZE || '50', 10);
10
+
11
+ if (batchSize < 1) {
12
+ throw new Error('Batch size must be at least 1');
13
+ }
14
+
15
+ const caseTypeTopicMap = {
16
+ HAS: 'appeal-has',
17
+ S78: 'appeal-s78'
18
+ };
19
+
20
+ if (!caseTypeTopicMap[caseType]) {
21
+ throw new Error(`Unsupported case type: ${caseType}`);
22
+ }
23
+
24
+ const client = new ServiceBusEventClient();
25
+
26
+ let casesCreated = 0;
27
+ let documentsCreated = 0;
28
+ let serviceUsersCreated = 0;
29
+
30
+ try {
31
+ for (let batchIndex = 0; batchIndex < caseCount; batchIndex += batchSize) {
32
+ const batch = [];
33
+ const batchEnd = Math.min(batchIndex + batchSize, caseCount);
34
+
35
+ for (let index = batchIndex; index < batchEnd; index += 1) {
36
+ batch.push(generateAppealsLoadTestMessages(caseType, documentsPerCase, index));
37
+ }
38
+
39
+ const batchCases = batch.map((m) => m.case);
40
+ const batchServiceUsers = batch.flatMap((m) => m.serviceUsers);
41
+ const batchDocuments = batch.flatMap((m) => m.documents);
42
+
43
+ await Promise.all([
44
+ client.sendEvents(caseTypeTopicMap[caseType], batchCases, MESSAGE_EVENT_TYPE.CREATE),
45
+ client.sendEvents('appeal-service-user', batchServiceUsers, MESSAGE_EVENT_TYPE.CREATE),
46
+ client.sendEvents('appeal-document', batchDocuments, MESSAGE_EVENT_TYPE.CREATE)
47
+ ]);
48
+
49
+ casesCreated += batch.length;
50
+ documentsCreated += batchDocuments.length;
51
+ serviceUsersCreated += batchServiceUsers.length;
52
+ }
53
+ } finally {
54
+ await client.close();
55
+ }
56
+
57
+ console.log('Finished');
58
+ console.log({ casesCreated, documentsCreated, serviceUsersCreated });
@@ -0,0 +1,89 @@
1
+ import { ServiceBusClient } from '@azure/service-bus';
2
+ import { DefaultAzureCredential } from '@azure/identity';
3
+
4
+ /**
5
+ * @typedef {object} ServiceBusEventClientOptions
6
+ * @property {string | undefined} [connectionString]
7
+ * @property {import('@azure/core-auth').TokenCredential | undefined} [credential]
8
+ */
9
+
10
+ /**
11
+ * Event client used to broadcast messages to service bus topics.
12
+ * @class
13
+ */
14
+ export class ServiceBusEventClient {
15
+ constructor() {
16
+ this.client = new ServiceBusClient(process.env.LOAD_TEST_SERVICE_BUS_NAMESPACE, new DefaultAzureCredential());
17
+ this.simulateSending = process.env.LOAD_TEST_SIMULATE_SENDING === 'true'; // true logs to console, false sends to service bus
18
+ }
19
+
20
+ /**
21
+ * @param {string} topic
22
+ * @returns Instance of the service bus sender from the service bus client
23
+ */
24
+ #createSender = (topic) => {
25
+ return this.client.createSender(topic);
26
+ };
27
+
28
+ /**
29
+ * Returns unix timestamp representing the trace id
30
+ * @returns {number}
31
+ */
32
+ #createTraceId = () => {
33
+ return Date.now();
34
+ };
35
+
36
+ /**
37
+ * @param {object[]} events
38
+ * @param {number} traceId
39
+ * @param {import('../utils/event-type').EventType} type
40
+ * @returns {import('@azure/service-bus').ServiceBusMessage []}
41
+ */
42
+ #transformMessagesToSend = (events, traceId, type) => {
43
+ return events.map((body) => ({
44
+ body,
45
+ contentType: 'application/json',
46
+ applicationProperties: {
47
+ version: '0.1',
48
+ traceId,
49
+ type
50
+ }
51
+ }));
52
+ };
53
+
54
+ /**
55
+ * @param {string} topic
56
+ * @param {any[]} events
57
+ * @param {import('../utils/event-type').EventType} eventType
58
+ */
59
+ sendEvents = async (topic, events, eventType) => {
60
+ const sender = this.#createSender(topic);
61
+
62
+ try {
63
+ const traceId = this.#createTraceId();
64
+
65
+ console.log(`Publishing ${events.length} events to topic ${topic} with trace id ${traceId}`);
66
+
67
+ const messages = this.#transformMessagesToSend(events, traceId, eventType);
68
+
69
+ if (this.simulateSending) {
70
+ console.log(`Simulating sending messages: ${JSON.stringify(messages)}`);
71
+ } else {
72
+ await sender.sendMessages(messages);
73
+ }
74
+
75
+ return events;
76
+ } catch (error) {
77
+ console.error(`Error sending messages to topic ${topic}:`, error);
78
+ throw error;
79
+ } finally {
80
+ if (!sender.isClosed) {
81
+ await sender.close();
82
+ }
83
+ }
84
+ };
85
+
86
+ close = async () => {
87
+ await this.client.close();
88
+ };
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/data-model",
3
- "version": "2.40.0",
3
+ "version": "2.41.0",
4
4
  "description": "JSON Schemas for the Planning Inspectorate's Data Model",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -16,6 +16,7 @@
16
16
  "gen-types": "node src/lib/gen-types.js",
17
17
  "gen-cjs": "npx rollup ./src/index.js --file ./src/index.cjs --format cjs && npx rollup ./src/enums.js --file ./src/enums.cjs --format cjs",
18
18
  "lint": "npx eslint .",
19
+ "load-test-appeals": "node --env-file=.env load-test/appeals/run-load-script.js",
19
20
  "format": "prettier --write .",
20
21
  "format-check": "prettier --check .",
21
22
  "prepare": "node .husky/install.mjs"
@@ -30,6 +31,8 @@
30
31
  },
31
32
  "devDependencies": {
32
33
  "@adobe/jsonschema2md": "^8.0.3",
34
+ "@azure/identity": "^4.13.0",
35
+ "@azure/service-bus": "^7.9.1",
33
36
  "@commitlint/cli": "^21.0.0",
34
37
  "@commitlint/config-conventional": "^21.0.0",
35
38
  "@eslint/js": "^10.0.1",