-
Notifications
You must be signed in to change notification settings - Fork 9
/
Copy pathtest-data-generator.js
370 lines (350 loc) · 17.8 KB
/
test-data-generator.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
/* TODO Support specifying category in the CLI args e.g. "authorization"
This should be supported as well as feature, which is currently supported
This should look something like `npm run test-data-generator -- [category-or-feature]`
e.g. `npm run test-data-generator -- authorization` */
const fs = require('fs').promises;
const path = require('path');
const { DateTime } = require('luxon');
const { dissoc } = require('ramda');
const yargs = require('yargs/yargs');
const { getConfigVarOrThrow, SELLER_CONFIG } = require('../test/helpers/config-utils');
const { SellerCriteriaRequirements, OpportunityCriteriaRequirements } = require('../test/helpers/criteria-utils');
const { getSellerConfigFromSellerCriteria } = require('../test/helpers/sellers');
const { createTestInterfaceOpportunity } = require('../test/helpers/test-interface-opportunities');
/**
* @typedef {import('../documentation/generator').CategoriesJson} CategoriesJson
* @typedef {import('../documentation/generator').FeatureRequirementsJson} FeatureRequirementsJson
* @typedef {import('../test/types/TestInterfaceOpportunity').TestInterfaceOpportunity} TestInterfaceOpportunity
* @typedef {import('../test/types/OpportunityCriteria').SellerCriteria} SellerCriteria
*/
/**
* @template TListItem
* @typedef {{
* '@context': [
* 'https://openactive.io/',
* 'https://openactive.io/test-interface',
* ];
* '@type': 'ItemList';
* numberOfItems: number;
* itemListElement: TListItem[];
* }} ItemList
*/
/**
* @typedef {ItemList<OutputOpportunityTestDataListItem>} OutputOpportunityTestData
*
* @typedef {{
* '@type': 'ListItem',
* 'test:numberOfInstancesInDistribution': number,
* item: Omit<TestInterfaceOpportunity, '@context'>,
* }} OutputOpportunityTestDataListItem
*
* @typedef {ItemList<{
* '@type': 'ListItem',
* item: {
* '@type': string;
* };
* }>} OutputTestInterfaceActions
*/
// # Constants - File Paths
const FEATURE_REQUIREMENTS_JSON_FILE_PATH = path.join(__dirname, '..', 'test', 'features', 'feature-requirements.json');
const CATEGORIES_JSON_FILE_PATH = path.join(__dirname, '..', 'test', 'features', 'categories.json');
const DEFAULT_OUTPUT_DIR = path.join(__dirname, 'output');
// # Constants - Config
const IMPLEMENTED_FEATURES = getConfigVarOrThrow('integrationTests', 'implementedFeatures');
const BOOKABLE_OPPORTUNITY_TYPES_IN_SCOPE_OBJ = getConfigVarOrThrow('integrationTests', 'bookableOpportunityTypesInScope');
const USE_SHAPE_EXPRESSIONS = getConfigVarOrThrow('integrationTests', 'useShapeExpressions');
/** An array of those opportunity types which the Booking System is testing */
const IMPLEMENTED_OPPORTUNITY_TYPES = Object.entries(BOOKABLE_OPPORTUNITY_TYPES_IN_SCOPE_OBJ)
.filter(([, isInScope]) => isInScope)
.map(([opportunityType]) => opportunityType);
const BOOKING_FLOWS_IN_SCOPE_OBJ = getConfigVarOrThrow('integrationTests', 'bookingFlowsInScope');
const IMPLEMENTED_BOOKING_FLOWS = Object.entries(BOOKING_FLOWS_IN_SCOPE_OBJ)
.filter(([, isInScope]) => isInScope)
.map(([bookingFlow]) => bookingFlow);
// # Process CLI Args
const argv = yargs(process.argv.slice(2)) // eslint-disable-line prefer-destructuring
.command('$0 [category-or-feature]', 'OpenActive Test Data Generator', (yargsConfig) => {
yargsConfig.positional('category-or-feature', {
type: 'string',
describe: 'If included, only generate test data for this Category (e.g. core) or Feature (e.g. agent-broker)',
default: '*',
});
})
.options({
'output-dir': {
type: 'string',
alias: 'o',
description: 'Output Directory',
default: DEFAULT_OUTPUT_DIR,
},
})
.argv;
const { 'output-dir': outputDirectory, 'category-or-feature': categoryOrFeatureUntyped } = argv;
const outputOpportunityTestDataFilePath = path.join(outputDirectory, 'opportunity-test-data.json');
const outputTestInterfaceActionsFilePath = path.join(outputDirectory, 'test-interface-actions.json');
const categoryOrFeature = /** @type {string} */(categoryOrFeatureUntyped); // yargs can only properly type the option args - not the positional ones. Hence the TS coercion here
// # Main
(async () => {
// ## Load Requirements
console.info(`Reading: ${FEATURE_REQUIREMENTS_JSON_FILE_PATH}`);
const featureRequirementsJsonRaw = await fs.readFile(FEATURE_REQUIREMENTS_JSON_FILE_PATH);
/** @type {FeatureRequirementsJson} */
const featureRequirementsJson = JSON.parse(featureRequirementsJsonRaw.toString());
const featureIdentifiers = await getSelectedFeatureIdentifiers(categoryOrFeature);
// ## Tally the requirements from each implemented feature
const sellerRequirements = tallySellerCriteriaRequirements(
featureRequirementsJson,
featureIdentifiers,
);
// ## Create Test Data
const outputOpportunityTestData = createOutputOpportunityTestData(sellerRequirements);
const outputTestInterfaceActions = createOutputTestInterfaceActions(featureRequirementsJson, featureIdentifiers);
// ## Write Test Data
//
// Create the directory if it doesn't exist
await fs.mkdir(outputDirectory, { recursive: true });
await fs.writeFile(outputOpportunityTestDataFilePath, JSON.stringify(outputOpportunityTestData, null, 2));
console.log(`FILE SAVED: ${outputOpportunityTestDataFilePath}`);
await fs.writeFile(outputTestInterfaceActionsFilePath, JSON.stringify(outputTestInterfaceActions, null, 2));
console.log(`FILE SAVED: ${outputTestInterfaceActionsFilePath}`);
})();
// # Utils
/**
* @param {SellerCriteriaRequirements} sellerRequirements
*/
function createOutputOpportunityTestData(sellerRequirements) {
// One for each seller x opportunity criteria
const harvestStartTimeOverride = DateTime.now().toISO();
/** @type {OutputOpportunityTestDataListItem[]} */
const itemListElement = [];
let numberOfItems = 0;
for (const [sellerCriteria, opportunityCriteriaRequirements] of sellerRequirements) {
// We're overriding the seller config in this call because this function, by default,
// gets seller config from global.SELLER_CONFIG, which is set by broker.
// Broker is not running, so we override it with seller config directly from the config file.
const seller = getSellerConfigFromSellerCriteria(sellerCriteria, SELLER_CONFIG);
for (const [opportunityCriteria, numOpportunitiesRequired] of opportunityCriteriaRequirements) {
for (const bookingFlow of IMPLEMENTED_BOOKING_FLOWS) {
for (const opportunityType of IMPLEMENTED_OPPORTUNITY_TYPES) {
/* TODO: This currently over-counts the number of Opportunities
required because some tests implement `skipOpportunityTypes` or
`skipBookingFlows` in FeatureHelper. This means that, for these tests,
some opportunity types and/or booking flows should be ignored in the
count. Doing this would require adding more information to the
`feature-requirements.json` file that is output by documentation
generation.
It's not a huge issue as it's an over-count */
numberOfItems += numOpportunitiesRequired;
const testInterfaceOpportunity = createTestInterfaceOpportunity({
opportunityType,
testOpportunityCriteria: opportunityCriteria,
// @ts-expect-error <- Needed until we assert that bookingFlow is one of the allowed string values
bookingFlow,
sellerId: seller['@id'],
sellerType: seller['@type'],
harvestStartTimeOverride,
// This must be specified here as the global will not be available
// to `createTestInterfaceOpportunity`, as this is set up in
// testEnvironment.js, which is only evaluated for jest test runs.
useShapeExpressions: USE_SHAPE_EXPRESSIONS,
});
itemListElement.push({
'@type': 'ListItem',
'test:numberOfInstancesInDistribution': numOpportunitiesRequired,
item: dissoc('@context', testInterfaceOpportunity),
});
}
}
}
}
/** @type {OutputOpportunityTestData} */
const outputOpportunityTestData = {
'@context': ['https://openactive.io/', 'https://openactive.io/test-interface'],
'@type': 'ItemList',
numberOfItems,
itemListElement,
};
return outputOpportunityTestData;
}
/**
* @param {FeatureRequirementsJson} featureRequirementsJson
* @param {string[]} featureIdentifiers
* @returns {OutputTestInterfaceActions}
*/
function createOutputTestInterfaceActions(featureRequirementsJson, featureIdentifiers) {
const allActionsSet = Object.entries(featureRequirementsJson.features)
.reduce((acc, [feature, { testInterfaceActionImplementationRequirements }]) => {
// Ignore any features which weren't specified
if (!featureIdentifiers.includes(feature)) { return acc; }
for (const action of testInterfaceActionImplementationRequirements) {
acc.add(action);
}
return acc;
}, /** @type {Set<string>} */(new Set()));
/* If the Booking System supports approval flow, then every test which creates
a successful booking will need to use the
`SellerAcceptOrderProposalSimulateAction` (which is not explicitly annotated
for each test as it depends on the implemented booking flows).
! There is a sort-of bug here, which is that
test:SellerAcceptOrderProposalSimulateAction will be outputted even if Test
Data Generator has been called on a category or feature that doesn't include
successful bookings. This is technically incorrect. In the opinion of this
author, it's fine because every Booking System should be able to support
successful bookings (!) */
if (IMPLEMENTED_BOOKING_FLOWS.includes('OpenBookingApprovalFlow')) {
allActionsSet.add('test:SellerAcceptOrderProposalSimulateAction');
}
const sortedActions = [...allActionsSet].sort();
/** @type {OutputTestInterfaceActions['itemListElement']} */
const listItems = sortedActions.map(action => ({
'@type': 'ListItem',
item: {
'@type': action,
},
}));
return {
'@context': ['https://openactive.io/', 'https://openactive.io/test-interface'],
'@type': 'ItemList',
numberOfItems: listItems.length,
itemListElement: listItems,
};
}
/**
* Count opportunity requirements for each seller, given the features which are selected and implemented
*
* @param {FeatureRequirementsJson} featureRequirementsJson
* @param {string[]} featureIdentifiers Only generates test data for these feature identifiers.
* @returns {SellerCriteriaRequirements}
*/
function tallySellerCriteriaRequirements(featureRequirementsJson, featureIdentifiers) {
/**
* The SellerCriteriaRequirements for every implemented features
* @type {SellerCriteriaRequirements[]}
*/
const sellerCriteriaRequirementMaps = [];
for (const featureIdentifier of featureIdentifiers) {
const sellerCriteriaRequirementsObj = featureRequirementsJson.features[featureIdentifier].criteriaRequirements;
if (!sellerCriteriaRequirementsObj) {
console.log(`WARNING: Missing Criteria Requirements for '${featureIdentifier}'. Try running \`npm run doc-gen\`.`);
}
const sellerCriteriaRequirements = getSellerCriteriaRequirementsFromJsonObj(sellerCriteriaRequirementsObj || {});
sellerCriteriaRequirementMaps.push(sellerCriteriaRequirements);
}
return SellerCriteriaRequirements.combine(sellerCriteriaRequirementMaps);
}
/**
* @param {string} [categoryOrFeatureIdentifier]
* If included (and not '*'), only generates test data for this feature identifier
* If excluded, is equivalent to '*', in which case test data is generated for all features
* @returns {Promise<string[]>}
*/
async function getSelectedFeatureIdentifiers(categoryOrFeatureIdentifier) {
if (categoryOrFeatureIdentifier && categoryOrFeatureIdentifier !== '*') {
// **First, assume it's a category identifier.**
//
// Best to go with this first as, if a category and feature have the same name, it would
// be more frustrating to not be able to select the category than it would be to not be
// able to select the feature.
// Because if you cannot select a single category, you would have to just get test data
// for everything, which would make data generation take much longer for each test.
const categoriesJsonRaw = await fs.readFile(CATEGORIES_JSON_FILE_PATH);
/** @type {CategoriesJson} */
const categoriesJson = JSON.parse(categoriesJsonRaw.toString());
if (categoryOrFeatureIdentifier in categoriesJson.categories) {
// Return features in that category
return onlyIncludeImplementedFeatures(Object.keys(categoriesJson.categories[categoryOrFeatureIdentifier]));
}
// **Otherwise, it must be a feature identifier.**
return onlyIncludeImplementedFeatures([categoryOrFeatureIdentifier]);
}
// If no features have been selected, just use all the implemented features
return Object.entries(IMPLEMENTED_FEATURES)
.filter(([_, isImplemented]) => isImplemented) // eslint-disable-line no-unused-vars
.map(([featureIdentifier, _]) => featureIdentifier); // eslint-disable-line no-unused-vars
}
/**
* Narrow array of feature identifiers down so as to only include this which are implemented
*
* Note: This raises an error if none of the identifiers make it through the filter.
*
* @param {string[]} featureIdentifiers
*/
function onlyIncludeImplementedFeatures(featureIdentifiers) {
const implementedFeatureIdentifiers = featureIdentifiers.filter(featureIdentifier => IMPLEMENTED_FEATURES[featureIdentifier]);
if (implementedFeatureIdentifiers.length === 0) {
throw new Error(`None of the selected feature identifiers (${featureIdentifiers.join(', ')}) are implemented. Therefore, no test data can be generated.`);
}
return implementedFeatureIdentifiers;
}
// /**
// * ## Tally Seller Criteria Requirements
// *
// * Count opportunity requirements for each seller, given the features which are selected and implemented
// *
// * @param {CriteriaRequirementsJson} criteriaRequirementsJson
// * @param {string} [categoryOrFeatureIdentifier]
// * If included (and not '*'), only generates test data for this feature identifier
// * If excluded, is equivalent to '*', in which case test data is generated for all features
// * @returns {SellerCriteriaRequirements}
// */
// function tallySellerCriteriaRequirementsUh(criteriaRequirementsJson, categoryOrFeatureIdentifier = '*') {
// if (categoryOrFeatureIdentifier && categoryOrFeatureIdentifier !== '*') {
// // ### First, assume it's a category identifier.
// //
// // Best to go with this first as, if a category and feature have the same name, it would
// // be more frustrating to not be able to select the category than it would be to not be
// // able to select the feature.
// // Because if you cannot select a single category, you would have to just get test data
// // for everything, which would make data generation take much longer for each test.
// if (categoryOrFeatureIdentifier in criteriaRequirementsJson.criteriaRequirements) {
// /**
// * The SellerCriteriaRequirements for every implemented features
// * @type {SellerCriteriaRequirements[]}
// */
// const sellerCriteriaRequirementMaps = [];
// for (const [featureIdentifier, sellerCriteriaRequirementsObj] of Object.entries(criteriaRequirementsJson.criteriaRequirements[categoryOrFeatureIdentifier])) {
// // Feature isn't implemented, so we don't consider it
// if (!IMPLEMENTED_FEATURES[featureIdentifier]) { continue; }
// const sellerCriteriaRequirements = getSellerCriteriaRequirementsFromJsonObj(sellerCriteriaRequirementsObj);
// sellerCriteriaRequirementMaps.push(sellerCriteriaRequirements);
// }
// return SellerCriteriaRequirements.combine(sellerCriteriaRequirementMaps);
// }
// // ### Otherwise, it's a feature identifier
// for (const [categoryIdentifier, featureRequirements] of Object.entries(criteriaRequirementsJson.criteriaRequirements)) {
// }
// const sellerCriteriaRequirementsObj = criteriaRequirementsJson.criteriaRequirements[categoryOrFeatureIdentifier];
// if (!sellerCriteriaRequirementsObj) {
// throw new Error(`Feature not found: "${categoryOrFeatureIdentifier}"`);
// }
// return getSellerCriteriaRequirementsFromJsonObj(sellerCriteriaRequirementsObj);
// }
// // ### For All Features
// /**
// * The SellerCriteriaRequirements for every implemented features
// * @type {SellerCriteriaRequirements[]}
// */
// const sellerCriteriaRequirementMaps = [];
// for (const [featureIdentifier, sellerCriteriaRequirementsObj] of Object.entries(criteriaRequirementsJson.criteriaRequirements)) {
// // Feature isn't implemented, so we don't consider it
// if (!IMPLEMENTED_FEATURES[featureIdentifier]) { continue; }
// const sellerCriteriaRequirements = getSellerCriteriaRequirementsFromJsonObj(sellerCriteriaRequirementsObj);
// sellerCriteriaRequirementMaps.push(sellerCriteriaRequirements);
// }
// return SellerCriteriaRequirements.combine(sellerCriteriaRequirementMaps);
// }
/**
* @param {FeatureRequirementsJson['features'][string]['criteriaRequirements']} sellerCriteriaRequirementsObj
*/
function getSellerCriteriaRequirementsFromJsonObj(sellerCriteriaRequirementsObj) {
// Type casting is necessary here as Object.entries, in TypeScript, does not endow keys with the more
// specific key type (https://github.com/microsoft/TypeScript/issues/20322)
/** @type {[SellerCriteria, FeatureRequirementsJson['features'][string]['criteriaRequirements'][SellerCriteria]][]} */
const sellerCriteriaRequirementsObjEntries = /** @type {any} */(Object.entries(sellerCriteriaRequirementsObj));
// This is a bit baroque. It's converting the JSON object into Maps (specifically,
// our SellerCriteriaRequirements maps).
return new SellerCriteriaRequirements(sellerCriteriaRequirementsObjEntries.map(([sellerCriteria, opportunityCriteriaRequirements]) => ([
sellerCriteria,
new OpportunityCriteriaRequirements(Object.entries(opportunityCriteriaRequirements)),
])));
}