diff --git a/.vscode/launch.json b/.vscode/launch.json index 790c7b3ab5..f789625312 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -98,6 +98,16 @@ "NODE_APP_INSTANCE": "dev" }, }, + { + "name": "broker-microservice - jest tests", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/packages/openactive-broker-microservice", + "runtimeExecutable": "npm", + "runtimeVersion": "18.17.1", + "runtimeArgs": ["run-script", "debug-jest"], + "port": 9229, + }, { "name": "test-interface-criteria - unit tests", "type": "node", diff --git a/packages/openactive-broker-microservice/package-lock.json b/packages/openactive-broker-microservice/package-lock.json index 29bd8b6bb8..f4298c64eb 100644 --- a/packages/openactive-broker-microservice/package-lock.json +++ b/packages/openactive-broker-microservice/package-lock.json @@ -12,7 +12,7 @@ "@openactive/data-model-validator": "^2.0.78", "@openactive/data-models": "^2.0.316", "@openactive/dataset-utils": "^1.0.1", - "@openactive/harvesting-utils": "github:openactive/harvesting-utils#3eb7749", + "@openactive/harvesting-utils": "github:openactive/harvesting-utils#1b2877834055549572fa059a491ac17d306942fd", "@openactive/openactive-openid-browser-automation": "file:../openactive-openid-browser-automation", "@openactive/openactive-openid-client": "file:../openactive-openid-client", "@openactive/rpde-validator": "^2.0.20", @@ -14723,7 +14723,8 @@ }, "node_modules/@openactive/harvesting-utils": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/openactive/harvesting-utils.git#3eb77490dd0d1c2d7fc85a7ed7c02e633c91b5c7", + "resolved": "git+ssh://git@github.com/openactive/harvesting-utils.git#1b2877834055549572fa059a491ac17d306942fd", + "integrity": "sha512-jjT4kad88PKp/JXDBOjkD47KCradEu6KmvNIfWKsHh2q6mfQHP5NIrWZreeeWkVlOO1t++lZESGpbmPfnopXmg==", "license": "MIT", "dependencies": { "@openactive/rpde-validator": "^2.0.19", diff --git a/packages/openactive-broker-microservice/package.json b/packages/openactive-broker-microservice/package.json index 5bacfb456c..a6e13ae73b 100644 --- a/packages/openactive-broker-microservice/package.json +++ b/packages/openactive-broker-microservice/package.json @@ -9,7 +9,8 @@ "validate-feeds": "node app.js --validate-only", "test": "npm run lint && tsc && jest", "debug": "node --inspect app.js", - "lint": "eslint \"*.js\" \"src/**/*.js\"", + "debug-jest": "node --inspect ./node_modules/.bin/jest --runInBand", + "lint": "eslint \"*.js\" \"src/**/*.js\" \"test/**/*.js\"", "lint-fix": "npm run lint -- --fix" }, "author": "OpenActive Community ", @@ -18,7 +19,7 @@ "@openactive/data-model-validator": "^2.0.78", "@openactive/data-models": "^2.0.316", "@openactive/dataset-utils": "^1.0.1", - "@openactive/harvesting-utils": "github:openactive/harvesting-utils#3eb7749", + "@openactive/harvesting-utils": "github:openactive/harvesting-utils#1b2877834055549572fa059a491ac17d306942fd", "@openactive/openactive-openid-browser-automation": "file:../openactive-openid-browser-automation", "@openactive/openactive-openid-client": "file:../openactive-openid-client", "@openactive/rpde-validator": "^2.0.20", diff --git a/packages/openactive-broker-microservice/src/broker-config.js b/packages/openactive-broker-microservice/src/broker-config.js index 1e48bf0310..a33f7e2759 100644 --- a/packages/openactive-broker-microservice/src/broker-config.js +++ b/packages/openactive-broker-microservice/src/broker-config.js @@ -4,55 +4,92 @@ const path = require('path'); const config = require('config'); -const PORT = normalizePort(process.env.PORT || '3000'); -const MICROSERVICE_BASE_URL = `http://localhost:${PORT}`; - -const VALIDATE_ONLY = process.argv.includes('--validate-only'); -const ITEM_VALIDATION_MODE = VALIDATE_ONLY ? 'RPDEFeed' : 'BookableRPDEFeed'; - -const DATASET_SITE_URL = VALIDATE_ONLY ? process.argv[3] : config.get('broker.datasetSiteUrl'); -const REQUEST_LOGGING_ENABLED = config.get('broker.requestLogging'); -const WAIT_FOR_HARVEST = VALIDATE_ONLY ? false : config.get('broker.waitForHarvestCompletion'); -const VERBOSE = config.get('broker.verbose'); -const OUTPUT_PATH = config.get('broker.outputPath'); -const IS_RUNNING_IN_CI = config.has('ci') ? config.get('ci') : false; -// TODO: move this property to the root of the config as it is used in both -// broker and the integration tests. Broker should only access config from -// either the root or within `.broker` -const USE_RANDOM_OPPORTUNITIES = config.get('integrationTests.useRandomOpportunities'); - -const HARVEST_START_TIME = (new Date()).toISOString(); -/** @type {import('./models/core').OrderFeedIdentifier} */ -const ORDERS_FEED_IDENTIFIER = 'OrdersFeed'; -/** @type {import('./models/core').OrderFeedIdentifier} */ -const ORDER_PROPOSALS_FEED_IDENTIFIER = 'OrderProposalsFeed'; - -const BOOKING_PARTNER_IDENTIFIERS = Object.entries(config.get('broker.bookingPartners')).map(([key, value]) => { - if (value) return key; - return null; -}).filter(Boolean); - -// These options are not recommended for general use, but are available for specific test environment configuration and debugging -const OPPORTUNITY_FEED_REQUEST_HEADERS = config.has('broker.opportunityFeedRequestHeaders') ? config.get('broker.opportunityFeedRequestHeaders') : {}; -const DATASET_DISTRIBUTION_OVERRIDE = config.has('broker.datasetDistributionOverride') ? config.get('broker.datasetDistributionOverride') : []; -const DO_NOT_FILL_BUCKETS = config.has('broker.disableBucketAllocation') ? config.get('broker.disableBucketAllocation') : false; -const DO_NOT_HARVEST_ORDERS_FEED = config.has('broker.disableOrdersFeedHarvesting') ? config.get('broker.disableOrdersFeedHarvesting') : false; -const DISABLE_BROKER_TIMEOUT = config.has('broker.disableBrokerMicroserviceTimeout') ? config.get('broker.disableBrokerMicroserviceTimeout') : false; -const LOG_AUTH_CONFIG = config.has('broker.logAuthConfig') ? config.get('broker.logAuthConfig') : false; - -const BUTTON_SELECTORS = config.has('broker.loginPagesSelectors') ? config.get('broker.loginPagesSelectors') : { - username: "[name='username' i]", - password: "[name='password' i]", - button: '.btn-primary', -}; -const CONSOLE_OUTPUT_LEVEL = config.has('consoleOutputLevel') ? config.get('consoleOutputLevel') : 'detailed'; - -const HEADLESS_AUTH = config.has('broker.headlessAuth') ? config.get('broker.headlessAuth') : true; - -/** Directory for Validator remote JSON cache (https://github.com/openactive/data-model-validator#remotejsoncachepath) */ -const VALIDATOR_TMP_DIR = './tmp'; -/** Input files for the Validator Worker Pool are saved in this directory */ -const VALIDATOR_INPUT_TMP_DIR = path.join(__dirname, '..', 'tmp-validator-input'); +/** + * @typedef {typeof brokerConfig} BrokerConfig + */ + +const brokerConfig = getBrokerConfig(); + +function getBrokerConfig() { + const PORT = normalizePort(process.env.PORT || '3000'); + const MICROSERVICE_BASE_URL = `http://localhost:${PORT}`; + + const VALIDATE_ONLY = process.argv.includes('--validate-only'); + const ITEM_VALIDATION_MODE = VALIDATE_ONLY ? 'RPDEFeed' : 'BookableRPDEFeed'; + + const DATASET_SITE_URL = VALIDATE_ONLY ? process.argv[3] : config.get('broker.datasetSiteUrl'); + const REQUEST_LOGGING_ENABLED = config.get('broker.requestLogging'); + const WAIT_FOR_HARVEST = VALIDATE_ONLY ? false : config.get('broker.waitForHarvestCompletion'); + const VERBOSE = config.get('broker.verbose'); + const OUTPUT_PATH = config.get('broker.outputPath'); + const IS_RUNNING_IN_CI = config.has('ci') ? config.get('ci') : false; + // TODO: move this property to the root of the config as it is used in both + // broker and the integration tests. Broker should only access config from + // either the root or within `.broker` + const USE_RANDOM_OPPORTUNITIES = config.get('integrationTests.useRandomOpportunities'); + + const HARVEST_START_TIME = (new Date()).toISOString(); + /** @type {import('./models/core').OrderFeedIdentifier} */ + const ORDERS_FEED_IDENTIFIER = 'OrdersFeed'; + /** @type {import('./models/core').OrderFeedIdentifier} */ + const ORDER_PROPOSALS_FEED_IDENTIFIER = 'OrderProposalsFeed'; + + const BOOKING_PARTNER_IDENTIFIERS = Object.entries(config.get('broker.bookingPartners')).map(([key, value]) => { + if (value) return key; + return null; + }).filter(Boolean); + + // These options are not recommended for general use, but are available for specific test environment configuration and debugging + const OPPORTUNITY_FEED_REQUEST_HEADERS = config.has('broker.opportunityFeedRequestHeaders') ? config.get('broker.opportunityFeedRequestHeaders') : {}; + const DATASET_DISTRIBUTION_OVERRIDE = config.has('broker.datasetDistributionOverride') ? config.get('broker.datasetDistributionOverride') : []; + const DO_NOT_FILL_BUCKETS = config.has('broker.disableBucketAllocation') ? config.get('broker.disableBucketAllocation') : false; + const DO_NOT_HARVEST_ORDERS_FEED = config.has('broker.disableOrdersFeedHarvesting') ? config.get('broker.disableOrdersFeedHarvesting') : false; + const DISABLE_BROKER_TIMEOUT = config.has('broker.disableBrokerMicroserviceTimeout') ? config.get('broker.disableBrokerMicroserviceTimeout') : false; + const LOG_AUTH_CONFIG = config.has('broker.logAuthConfig') ? config.get('broker.logAuthConfig') : false; + + const BUTTON_SELECTORS = config.has('broker.loginPagesSelectors') ? config.get('broker.loginPagesSelectors') : { + username: "[name='username' i]", + password: "[name='password' i]", + button: '.btn-primary', + }; + const CONSOLE_OUTPUT_LEVEL = config.has('consoleOutputLevel') ? config.get('consoleOutputLevel') : 'detailed'; + + const HEADLESS_AUTH = config.has('broker.headlessAuth') ? config.get('broker.headlessAuth') : true; + + /** Directory for Validator remote JSON cache (https://github.com/openactive/data-model-validator#remotejsoncachepath) */ + const VALIDATOR_TMP_DIR = './tmp'; + /** Input files for the Validator Worker Pool are saved in this directory */ + const VALIDATOR_INPUT_TMP_DIR = path.join(__dirname, '..', 'tmp-validator-input'); + + return { + PORT, + MICROSERVICE_BASE_URL, + VALIDATE_ONLY, + ITEM_VALIDATION_MODE, + DATASET_SITE_URL, + REQUEST_LOGGING_ENABLED, + WAIT_FOR_HARVEST, + VERBOSE, + OUTPUT_PATH, + IS_RUNNING_IN_CI, + USE_RANDOM_OPPORTUNITIES, + HARVEST_START_TIME, + ORDERS_FEED_IDENTIFIER, + ORDER_PROPOSALS_FEED_IDENTIFIER, + OPPORTUNITY_FEED_REQUEST_HEADERS, + DATASET_DISTRIBUTION_OVERRIDE, + DO_NOT_FILL_BUCKETS, + DO_NOT_HARVEST_ORDERS_FEED, + DISABLE_BROKER_TIMEOUT, + LOG_AUTH_CONFIG, + BUTTON_SELECTORS, + CONSOLE_OUTPUT_LEVEL, + HEADLESS_AUTH, + VALIDATOR_TMP_DIR, + VALIDATOR_INPUT_TMP_DIR, + BOOKING_PARTNER_IDENTIFIERS, + }; +} /** * Normalize a port into a number, string, or false. @@ -75,31 +112,4 @@ function normalizePort(val) { return false; } -module.exports = { - PORT, - MICROSERVICE_BASE_URL, - VALIDATE_ONLY, - ITEM_VALIDATION_MODE, - DATASET_SITE_URL, - REQUEST_LOGGING_ENABLED, - WAIT_FOR_HARVEST, - VERBOSE, - OUTPUT_PATH, - IS_RUNNING_IN_CI, - USE_RANDOM_OPPORTUNITIES, - HARVEST_START_TIME, - ORDERS_FEED_IDENTIFIER, - ORDER_PROPOSALS_FEED_IDENTIFIER, - OPPORTUNITY_FEED_REQUEST_HEADERS, - DATASET_DISTRIBUTION_OVERRIDE, - DO_NOT_FILL_BUCKETS, - DO_NOT_HARVEST_ORDERS_FEED, - DISABLE_BROKER_TIMEOUT, - LOG_AUTH_CONFIG, - BUTTON_SELECTORS, - CONSOLE_OUTPUT_LEVEL, - HEADLESS_AUTH, - VALIDATOR_TMP_DIR, - VALIDATOR_INPUT_TMP_DIR, - BOOKING_PARTNER_IDENTIFIERS, -}; +module.exports = brokerConfig; diff --git a/packages/openactive-broker-microservice/src/core.js b/packages/openactive-broker-microservice/src/core.js index 1d8fabd7d7..33752a3cbb 100644 --- a/packages/openactive-broker-microservice/src/core.js +++ b/packages/openactive-broker-microservice/src/core.js @@ -41,16 +41,25 @@ const { BOOKING_PARTNER_IDENTIFIERS, } = require('./broker-config'); const { TwoPhaseListeners } = require('./twoPhaseListeners/twoPhaseListeners'); -const { state, getLockedOpportunityIdsInTestDataset, getAllLockedOpportunityIds, setGlobalValidatorWorkerPool, getGlobalValidatorWorkerPool } = require('./state'); +const { state, setGlobalValidatorWorkerPool, getGlobalValidatorWorkerPool } = require('./state'); const { orderFeedContextIdentifier } = require('./util/feed-context-identifier'); const { withOrdersRpdeHeaders, getOrdersFeedHeader } = require('./util/request-utils'); const { OrderUuidTracking } = require('./order-uuid-tracking/order-uuid-tracking'); const { error400IfExpressParamsAreMissing } = require('./util/api-utils'); const { ValidatorWorkerPool } = require('./validator/validator-worker-pool'); const { setUpValidatorInputs, cleanUpValidatorInputs, createAndSaveValidatorInputsFromRpdePage } = require('./validator/validator-inputs'); -const { renderSampleOpportunities } = require('./sample-opportunities'); const { invertFacilityUseItem: invertFacilityUseItemIfPossible, createItemFromSubEvent } = require('./util/item-transforms'); const { extractJSONLDfromDatasetSiteUrl } = require('./util/extract-jsonld-utils'); +const { getOrphanStats, getStatus } = require('./util/get-status'); +const { getOrphanJson } = require('./util/get-orphans'); +const { getOpportunityMergedWithParentById } = require('./util/get-opportunity-by-id-from-cache'); +const { getMergedJsonLdContext } = require('./util/jsonld-utils'); +const { jsonLdHasReferencedParent } = require('./util/jsonld-utils'); +const { getRandomBookableOpportunity } = require('./util/get-random-bookable-opportunity'); +const { getLockedOpportunityIdsInTestDataset } = require('./util/state-utils'); +const { detectOpportunityType } = require('./util/opportunity-utils'); +const { detectSellerId } = require('./util/opportunity-utils'); +const { getSampleOpportunities } = require('./util/sample-opportunities'); /** * @typedef {import('./models/core').OrderFeedType} OrderFeedType @@ -129,7 +138,7 @@ function getDatasetSiteRoute(req, res) { * @param {import('express').Response} res */ function getOrphansRoute(req, res) { - res.send(getOrphanJson()); + res.send(getOrphanJson(state)); } /** @@ -137,17 +146,9 @@ function getOrphansRoute(req, res) { * @param {import('express').Response} res */ function getStatusRoute(req, res) { - const { childOrphans, totalChildren, percentageChildOrphans, totalOpportunities } = getOrphanStats(); - res.send({ - elapsedTime: millisToMinutesAndSeconds((new Date()).getTime() - state.startTime.getTime()), - harvestingStatus: state.pauseResume.pauseHarvestingStatus, - feeds: mapToObjectSummary(state.feedContextMap), - orphans: { - children: `${childOrphans} of ${totalChildren} (${percentageChildOrphans}%)`, - }, - totalOpportunitiesHarvested: totalOpportunities, - buckets: DO_NOT_FILL_BUCKETS ? null : mapToObjectSummary(state.criteriaOrientedOpportunityIdCache), - }); + res.send(getStatus({ + DO_NOT_FILL_BUCKETS, + }, state)); } /** @@ -183,7 +184,7 @@ function getOpportunityCacheByIdRoute(req, res) { if (req.params.id) { const { id } = req.params; - const cachedResponse = getOpportunityMergedWithParentById(id); + const cachedResponse = getOpportunityMergedWithParentById(state, id); if (cachedResponse) { if (CONSOLE_OUTPUT_LEVEL === 'dot') { @@ -263,7 +264,7 @@ function getRandomOpportunityRoute(req, res) { // converts e.g. https://openactive.io/test-interface#OpenBookingApproval -> OpenBookingApproval. const bookingFlow = opportunity['test:testOpenBookingFlow'].replace('https://openactive.io/test-interface#', ''); - const result = getRandomBookableOpportunity({ + const result = getRandomBookableOpportunity(state, { sellerId, bookingFlow, opportunityType, criteriaName, testDatasetIdentifier, }); if (result && result.opportunity) { @@ -340,30 +341,9 @@ function assertUnmatchedCriteriaRoute(req, res) { * @param {import('express').Response} res */ function getSampleOpportunitiesRoute(req, res) { - // Get random opportunity ID - const opportunity = req.body; - const opportunityType = detectOpportunityType(opportunity); - const sellerId = detectSellerId(opportunity); - const testDatasetIdentifier = 'sample-opportunities'; - - const criteriaName = opportunity['test:testOpportunityCriteria'].replace('https://openactive.io/test-interface#', ''); - const bookingFlow = opportunity['test:testOpenBookingFlow'].replace('https://openactive.io/test-interface#', ''); - - const bookableOpportunity = getRandomBookableOpportunity({ - sellerId, bookingFlow, opportunityType, criteriaName, testDatasetIdentifier, - }); - - if (bookableOpportunity.opportunity) { - const opportunityWithParent = getOpportunityMergedWithParentById( - bookableOpportunity.opportunity['@id'], - ); - const json = renderSampleOpportunities(opportunityWithParent, criteriaName, sellerId); - res.json(json); - } else { - res.json({ - error: bookableOpportunity, - }); - } + res.json(getSampleOpportunities({ + HARVEST_START_TIME, + }, state, req.body)); } /** @@ -430,59 +410,6 @@ function withOpportunityRpdeHeaders(getHeadersFn) { }); } -/** - * Get a random opportunity from Broker Microservice's cache that matches the - * criteria. - * - * @param {object} args - * @param {string} args.sellerId - * @param {string} args.bookingFlow - * @param {string} args.opportunityType - * @param {string} args.criteriaName - * @param {string} args.testDatasetIdentifier - * @returns {any} - */ -function getRandomBookableOpportunity({ sellerId, bookingFlow, opportunityType, criteriaName, testDatasetIdentifier }) { - const typeBucket = CriteriaOrientedOpportunityIdCache.getTypeBucket(state.criteriaOrientedOpportunityIdCache, { - criteriaName, bookingFlow, opportunityType, - }); - const sellerCompartment = typeBucket.contents.get(sellerId); - if (!sellerCompartment || sellerCompartment.size === 0) { - const availableSellers = mapToObjectSummary(typeBucket.contents); - const noCriteriaErrors = bookingFlow === 'OpenBookingApprovalFlow' - ? "Ensure that some Offers have an 'openBookingFlowRequirement' property that includes the value 'https://openactive.io/OpenBookingApproval'" - : "Ensure that some Offers have an 'openBookingFlowRequirement' property that DOES NOT include the value 'https://openactive.io/OpenBookingApproval'"; - const criteriaErrors = !typeBucket.criteriaErrors || typeBucket.criteriaErrors?.size === 0 ? noCriteriaErrors : Object.fromEntries(typeBucket.criteriaErrors); - return { - suggestion: availableSellers ? 'Try setting sellers.primary.@id in the JSON config to one of the availableSellers below.' : `Check criteriaErrors below for reasons why '${opportunityType}' items in your feeds are not matching the criteria '${criteriaName}'.${typeBucket.criteriaErrors?.size > 0 ? ' The number represents the number of items that do not match.' : ''}`, - availableSellers, - criteriaErrors: typeBucket.criteriaErrors ? criteriaErrors : undefined, - }; - } // Seller has no items - - const allLockedOpportunityIds = getAllLockedOpportunityIds(); - const unusedBucketItems = Array.from(sellerCompartment).filter((x) => !allLockedOpportunityIds.has(x)); - - if (unusedBucketItems.length === 0) { - return { - suggestion: `No enough items matching criteria '${criteriaName}' were included in your feeds to run all tests. Try adding more test data to your system, or consider using 'Controlled Mode'.`, - }; - } - - const id = unusedBucketItems[Math.floor(Math.random() * unusedBucketItems.length)]; - - // Add the item to the testDataset to ensure it does not get reused - getLockedOpportunityIdsInTestDataset(testDatasetIdentifier).add(id); - - return { - opportunity: { - '@context': 'https://openactive.io/', - '@type': getTypeFromOpportunityType(opportunityType), - '@id': id, - }, - }; -} - /** * @param {object} args * @param {string} args.opportunityType @@ -502,59 +429,11 @@ function assertOpportunityCriteriaNotFound({ opportunityType, criteriaName, book * @param {string} testDatasetIdentifier */ function releaseOpportunityLocks(testDatasetIdentifier) { - const testDataset = getLockedOpportunityIdsInTestDataset(testDatasetIdentifier); + const testDataset = getLockedOpportunityIdsInTestDataset(state, testDatasetIdentifier); log(`Cleared dataset '${testDatasetIdentifier}' of opportunity locks ${Array.from(testDataset).join(', ')}`); testDataset.clear(); } -/** - * For a given `childOpportunityId`, fetch the full opportunity from the cache. - * If the opportunity has a parent, the full opportunity for the parent will be - * fetched and merged into the `superEvent` or `facilityUse` property. - * - * @param {string} childOpportunityId - */ -function getOpportunityMergedWithParentById(childOpportunityId) { - const opportunity = state.opportunityCache.childMap.get(childOpportunityId); - if (!opportunity) { - return null; - } - if (!jsonLdHasReferencedParent(opportunity)) { - return opportunity; - } - const superEvent = state.opportunityCache.parentMap.get(/** @type {string} */(opportunity.superEvent)); - const facilityUse = state.opportunityCache.parentMap.get(/** @type {string} */(opportunity.facilityUse)); - if (superEvent || facilityUse) { - const mergedContexts = getMergedJsonLdContext(opportunity, superEvent, facilityUse); - delete opportunity['@context']; - const returnObj = { - '@context': mergedContexts, - ...opportunity, - }; - if (superEvent) { - const superEventWithoutContext = { - ...superEvent, - }; - delete superEventWithoutContext['@context']; - return { - ...returnObj, - superEvent: superEventWithoutContext, - }; - } - if (facilityUse) { - const facilityUseWithoutContext = { - ...facilityUse, - }; - delete facilityUseWithoutContext['@context']; - return { - ...returnObj, - facilityUse: facilityUseWithoutContext, - }; - } - } - return null; -} - /** * Call this function on a feed when the last page has been fetched, indicating * that we have, as of the time of the last page fetch, read and cached all @@ -591,7 +470,7 @@ async function setFeedIsUpToDate(validatorWorkerPool, feedIdentifier, { multibar log('Harvesting is up-to-date'); // Run some assertions to ensure that feed harvesting has lead to the correct state. - const { childOrphans, totalChildren, percentageChildOrphans, totalOpportunities } = getOrphanStats(); + const { childOrphans, totalChildren, percentageChildOrphans, totalOpportunities } = getOrphanStats(state); let validationPassed = true; @@ -602,7 +481,7 @@ async function setFeedIsUpToDate(validatorWorkerPool, feedIdentifier, { multibar } else if (totalChildren !== 0 && childOrphans === totalChildren) { logError(`\nFATAL ERROR: 100% of the ${totalChildren} harvested opportunities that reference a parent do not have a matching parent item from the parent feed, so all integration tests will fail.`); logError('Please ensure that the value of the `subEvent` or `facilityUse` property in each opportunity exactly matches an `@id` from the parent feed.\n'); - await fs.writeFile(`${OUTPUT_PATH}orphans.json`, JSON.stringify(getOrphanJson(), null, 2)); + await fs.writeFile(`${OUTPUT_PATH}orphans.json`, JSON.stringify(getOrphanJson(state), null, 2)); if (!VALIDATE_ONLY && !IS_RUNNING_IN_CI) { logError(`See ${OUTPUT_PATH}orphans.json for more information or visit http://localhost:${PORT}/orphans for more information\n`); } else { @@ -612,7 +491,7 @@ async function setFeedIsUpToDate(validatorWorkerPool, feedIdentifier, { multibar } else if (childOrphans > 0) { logError(`\nFATAL ERROR: ${childOrphans} of ${totalChildren} opportunities that reference a parent (${percentageChildOrphans}%) do not have a matching parent item from the parent feed.`); logError('Please ensure that the value of the `subEvent` or `facilityUse` property in each opportunity exactly matches an `@id` from the parent feed.\n'); - await fs.writeFile(`${OUTPUT_PATH}orphans.json`, JSON.stringify(getOrphanJson(), null, 2)); + await fs.writeFile(`${OUTPUT_PATH}orphans.json`, JSON.stringify(getOrphanJson(state), null, 2)); if (!VALIDATE_ONLY && !IS_RUNNING_IN_CI) { logError(`See ${OUTPUT_PATH}orphans.json for more information or visit http://localhost:${PORT}/orphans for more information\n`); } else { @@ -679,114 +558,6 @@ function getConfig() { }; } -/** - * Convert an ES Map to a plain object (recursively), summarising some aspects - * as follows: - * - * - Any value that is an ES Set will be replaced with its size - * - Hide any properties which start with the character '_' in objects - * - OpportunityIdCacheTypeBucket objects have special handling - * - * @param {Map | Set} map - * @returns {{[k: string]: any} | number} - */ -function mapToObjectSummary(map) { - if (map instanceof Map) { - // Return a object representation of a Map - const obj = Object.assign(Object.create(null), ...[...map].map((v) => (typeof v[1] === 'object' && v[1].size === 0 - ? {} - : { - [v[0]]: mapToObjectSummary(v[1]), - }))); - if (JSON.stringify(obj) === JSON.stringify({})) { - return undefined; - } - return obj; - } - if (map instanceof Set) { - // Return just the size of a Set, to render at the leaf nodes of the resulting tree, - // instead of outputting the whole set contents. This reduces the size of the output for display. - return map.size; - } - // Special handling for OpportunityIdCacheTypeBuckets - // @ts-ignore - if (map.contents) { - // @ts-ignore - const result = mapToObjectSummary(map.contents); - if (result && Object.keys(result).length > 0) { - // @ts-ignore - return result; - } - // @ts-ignore - if (map.criteriaErrors && map.criteriaErrors.size > 0) { - return { - // @ts-ignore - criteriaErrors: Object.fromEntries(map.criteriaErrors), - }; - } - return undefined; - } - // @ts-ignore - if (map instanceof Object) { - // Hide any properties that start with the character '_' in objects, as these are not intended for display - return Object.fromEntries(Object.entries(map).filter(([k]) => k.charAt(0) !== '_')); - } - return map; -} - -/** - * @param {number} millis - */ -function millisToMinutesAndSeconds(millis) { - const minutes = Math.floor(millis / 60000); - const seconds = ((millis % 60000) / 1000); - return `${minutes}:${seconds < 10 ? '0' : ''}${seconds.toFixed(0)}`; -} - -function getOrphanJson() { - const rows = Array.from(state.opportunityItemRowCache.store.values()).filter((x) => x.jsonLdParentId !== null); - return { - children: { - matched: rows.filter((x) => !x.waitingForParentToBeIngested).length, - orphaned: rows.filter((x) => x.waitingForParentToBeIngested).length, - total: rows.length, - orphanedList: rows.filter((x) => x.waitingForParentToBeIngested).slice(0, 1000).map((({ jsonLdType, id, modified, jsonLd, jsonLdId, jsonLdParentId }) => ({ - jsonLdType, - id, - modified, - jsonLd, - jsonLdId, - jsonLdParentId, - }))), - }, - }; -} - -/** - * @typedef {Object} OrphanStats - * @property {number} childOrphans - * @property {number} totalChildren - * @property {string} percentageChildOrphans - * @property {number} totalOpportunities - */ - -/** - * @returns {OrphanStats} - */ -function getOrphanStats() { - const childRows = Array.from(state.opportunityItemRowCache.store.values()).filter((x) => x.jsonLdParentId !== null); - const childOrphans = childRows.filter((x) => x.waitingForParentToBeIngested).length; - const totalChildren = childRows.length; - const totalOpportunities = Array.from(state.opportunityItemRowCache.store.values()).filter((x) => !x.waitingForParentToBeIngested).length; - const percentageChildOrphans = totalChildren > 0 ? ((childOrphans / totalChildren) * 100).toFixed(2) : '0'; - return { - childOrphans, - totalChildren, - percentageChildOrphans, - totalOpportunities, - }; -} - /** * For an Opportunity being harvested from RPDE, check if there is a two-phase * listener listening for it. @@ -828,7 +599,7 @@ function doOnePhaseListenForOpportunity(opportunityId, useCacheIfAvailable, does state.onePhaseListeners.opportunity.createListener(opportunityId, doesItemMatchCriteria, res); if (useCacheIfAvailable) { - const cachedResponse = getOpportunityMergedWithParentById(opportunityId); + const cachedResponse = getOpportunityMergedWithParentById(state, opportunityId); if (cachedResponse) { if (CONSOLE_OUTPUT_LEVEL === 'dot') { logCharacter('.'); @@ -852,84 +623,6 @@ function doOnePhaseListenForOpportunity(opportunityId, useCacheIfAvailable, does } } -/** - * @param {string} opportunityType - */ -function getTypeFromOpportunityType(opportunityType) { - const mapping = { - ScheduledSession: 'ScheduledSession', - FacilityUseSlot: 'Slot', - IndividualFacilityUseSlot: 'Slot', - CourseInstance: 'CourseInstance', - HeadlineEvent: 'HeadlineEvent', - Event: 'Event', - HeadlineEventSubEvent: 'Event', - CourseInstanceSubEvent: 'Event', - OnDemandEvent: 'OnDemandEvent', - }; - return mapping[opportunityType]; -} - -/** - * @param {import('./models/core').Opportunity} opportunity - */ -function detectSellerId(opportunity) { - const organizer = opportunity.organizer - || opportunity.superEvent?.organizer - || opportunity.superEvent?.superEvent?.organizer - || opportunity?.facilityUse?.provider - || opportunity?.facilityUse?.aggregateFacilityUse?.provider; - - if (typeof organizer === 'string') return organizer; - - return organizer?.['@id'] || organizer?.id; -} - -/** - * @param {import('./models/core').Opportunity} opportunity - */ -function detectOpportunityType(opportunity) { - switch (opportunity['@type'] || opportunity.type) { - case 'ScheduledSession': - if (opportunity.superEvent && (opportunity.superEvent['@type'] || opportunity.superEvent.type) === 'SessionSeries') { - return 'ScheduledSession'; - } - throw new Error('ScheduledSession must have superEvent of SessionSeries'); - - case 'Slot': - if (opportunity.facilityUse && (opportunity.facilityUse['@type'] || opportunity.facilityUse.type) === 'IndividualFacilityUse') { - return 'IndividualFacilityUseSlot'; - } - if (opportunity.facilityUse && (opportunity.facilityUse['@type'] || opportunity.facilityUse.type) === 'FacilityUse') { - return 'FacilityUseSlot'; - } - - throw new Error('Slot must have facilityUse of FacilityUse or IndividualFacilityUse'); - - case 'CourseInstance': - return 'CourseInstance'; - case 'HeadlineEvent': - return 'HeadlineEvent'; - case 'OnDemandEvent': - return 'OnDemandEvent'; - case 'Event': - switch (opportunity.superEvent && (opportunity.superEvent['@type'] || opportunity.superEvent.type)) { - case 'HeadlineEvent': - return 'HeadlineEventSubEvent'; - case 'CourseInstance': - return 'CourseInstanceSubEvent'; - case 'EventSeries': - case null: - case undefined: - return 'Event'; - default: - throw new Error('Event has unrecognised @type of superEvent'); - } - default: - throw new Error('Only bookable opportunities are permitted in the test interface'); - } -} - /** * @param {import('./models/core').Opportunity} opportunity * @returns {string[]} An opportunity can support multiple Booking Flows as these are in the Offers. An Opportunity @@ -1198,39 +891,6 @@ async function storeChildOpportunityItem(item) { } } -/** - * Does this Opportunity have a reference to a Parent Opportunity? (i.e. is it a Child Opportunity?) - * - * @param {import('./models/core').Opportunity} data - */ -function jsonLdHasReferencedParent(data) { - return typeof data?.superEvent === 'string' || typeof data?.facilityUse === 'string'; -} - -/** - * Sort JSON-LD `@context` so that `https://openactive.io/` and - * `https://schema.org/` are at the top, which is useful for consistency - * and required by validator. - * - * @param {string[]} context - */ -function sortJsonLdContextWithOpenActiveOnTop(context) { - const firstList = []; - if (context.includes('https://openactive.io/')) firstList.push('https://openactive.io/'); - if (context.includes('https://schema.org/')) firstList.push('https://schema.org/'); - const remainingList = context.filter((x) => x !== 'https://openactive.io/' && x !== 'https://schema.org/'); - return firstList.concat(remainingList.sort()); -} - -/** - * Merge and sort JSON-LD `@context` from multiple Opportunities. - * - * @param {...import('./models/core').Opportunity} opportunities - */ -function getMergedJsonLdContext(...opportunities) { - return sortJsonLdContextWithOpenActiveOnTop([...new Set(opportunities.flatMap((x) => x && x['@context']).filter((x) => x))]); -} - /** * Merge a child- and parent- opportunity and then save them to the * criteria-oriented cache and notify any listeners. @@ -1308,7 +968,7 @@ async function processOpportunityItem(item) { // Store opportunity to criteria-oriented cache const matchingCriteria = []; - let unmetCriteriaDetails = []; + const unmetCriteriaDetails = []; if (!DO_NOT_FILL_BUCKETS) { const opportunityType = detectOpportunityType(item.data); @@ -1322,26 +982,24 @@ async function processOpportunityItem(item) { }), }))) { for (const bookingFlow of bookingFlows) { - const typeBucket = CriteriaOrientedOpportunityIdCache.getTypeBucket(state.criteriaOrientedOpportunityIdCache, { - criteriaName, opportunityType, bookingFlow, - }); - if (!typeBucket.contents.has(sellerId)) typeBucket.contents.set(sellerId, new Set()); - const sellerCompartment = typeBucket.contents.get(sellerId); if (criteriaResult.matchesCriteria) { - sellerCompartment.add(id); - matchingCriteria.push(criteriaName); - // Hide criteriaErrors if at least one matching item is found - typeBucket.criteriaErrors = undefined; + CriteriaOrientedOpportunityIdCache.setOpportunityMatchesCriteria( + state.criteriaOrientedOpportunityIdCache, + id, + { + criteriaName, bookingFlow, opportunityType, sellerId, + }, + ); } else { - sellerCompartment.delete(id); - unmetCriteriaDetails = unmetCriteriaDetails.concat(criteriaResult.unmetCriteriaDetails); - // Ignore errors if criteriaErrors is already hidden - if (typeBucket.criteriaErrors) { - for (const error of criteriaResult.unmetCriteriaDetails) { - if (!typeBucket.criteriaErrors.has(error)) typeBucket.criteriaErrors.set(error, 0); - typeBucket.criteriaErrors.set(error, typeBucket.criteriaErrors.get(error) + 1); - } - } + CriteriaOrientedOpportunityIdCache.setOpportunityDoesNotMatchCriteria( + state.criteriaOrientedOpportunityIdCache, + id, + criteriaResult.unmetCriteriaDetails, + { + criteriaName, bookingFlow, opportunityType, sellerId, + }, + ); + unmetCriteriaDetails.push(...criteriaResult.unmetCriteriaDetails); } } } diff --git a/packages/openactive-broker-microservice/src/sample-opportunities.js b/packages/openactive-broker-microservice/src/sample-opportunities.js deleted file mode 100644 index 3720c2f279..0000000000 --- a/packages/openactive-broker-microservice/src/sample-opportunities.js +++ /dev/null @@ -1,35 +0,0 @@ -const { getRelevantOffers } = require('@openactive/test-interface-criteria'); -const { HARVEST_START_TIME } = require('./broker-config'); - -// NOTE: duplicated from openactive-integration-tests/test/helpers/flow-stages/fetch-opportunities.js -function getRandomRelevantOffer(opportunity, criteriaName) { - const relevantOffers = getRelevantOffers(criteriaName, opportunity, { - harvestStartTime: HARVEST_START_TIME, - }); - if (relevantOffers.length === 0) { return null; } - - return relevantOffers[Math.floor(Math.random() * relevantOffers.length)]; -} - -function renderSampleOpportunities(opportunity, criteriaName, sellerId) { - const offer = getRandomRelevantOffer(opportunity, criteriaName); - - return { - sampleOpportunities: [ - opportunity, - ], - sellerId, - exampleOrderItems: [ - { - '@type': 'OrderItem', - position: 0, - acceptedOffer: offer['@id'], - orderedItem: opportunity['@id'], - }, - ], - }; -} - -module.exports = { - renderSampleOpportunities, -}; diff --git a/packages/openactive-broker-microservice/src/state.js b/packages/openactive-broker-microservice/src/state.js index 87aa4367d1..45a7a5acfc 100644 --- a/packages/openactive-broker-microservice/src/state.js +++ b/packages/openactive-broker-microservice/src/state.js @@ -221,24 +221,6 @@ const state = { healthCheckResponsesWaitingForHarvest: [], }; -/** - * All Opportunity IDs that are considered "locked" (because they have already - * been used in a test) for the specified [Test Dataset](https://openactive.io/test-interface/#datasets-endpoints). - * - * @param {string} testDatasetIdentifier - * @returns {Set} - */ -function getLockedOpportunityIdsInTestDataset(testDatasetIdentifier) { - if (!state.lockedOpportunityIdsByTestDataset.has(testDatasetIdentifier)) { - state.lockedOpportunityIdsByTestDataset.set(testDatasetIdentifier, new Set()); - } - return state.lockedOpportunityIdsByTestDataset.get(testDatasetIdentifier); -} - -function getAllLockedOpportunityIds() { - return new Set(Array.from(state.lockedOpportunityIdsByTestDataset.values()).flatMap((x) => Array.from(x.values()))); -} - /** * @param {ValidatorWorkerPoolType} validatorWorkerPool */ @@ -250,10 +232,12 @@ function getGlobalValidatorWorkerPool() { return state._validatorWorkerPool; } +/** + * @typedef {typeof state} State + */ + module.exports = { state, - getLockedOpportunityIdsInTestDataset, - getAllLockedOpportunityIds, setGlobalValidatorWorkerPool, getGlobalValidatorWorkerPool, }; diff --git a/packages/openactive-broker-microservice/src/util/criteria-oriented-opportunity-id-cache.js b/packages/openactive-broker-microservice/src/util/criteria-oriented-opportunity-id-cache.js index 56375d310c..d3581638d9 100644 --- a/packages/openactive-broker-microservice/src/util/criteria-oriented-opportunity-id-cache.js +++ b/packages/openactive-broker-microservice/src/util/criteria-oriented-opportunity-id-cache.js @@ -2,7 +2,13 @@ const { criteria } = require('@openactive/test-interface-criteria'); /** * @typedef {Set} OpportunityIdCacheSellerCompartment - * @typedef {{contents: Map, criteriaErrors: Map }} OpportunityIdCacheTypeBucket + * @typedef {{ + * contents: Map, + * criteriaErrors: Map | undefined + * }} OpportunityIdCacheTypeBucket If `criteriaErrors` is `undefined`, it + * means that at least one item has matched the criteria and so criteria + * errors are irrelevant. We only care about what types of errors are causing + * opportunities to not match a criteria if none of them are. * @typedef {Map} OpportunityIdCacheBookingFlowBucket * @typedef {Map} OpportunityIdCacheCriteriaBucket * @typedef {Map} OpportunityIdCacheType @@ -76,6 +82,61 @@ const CriteriaOrientedOpportunityIdCache = { if (!typeBucket) throw new Error(`The specified opportunityType (${opportunityType}) is not currently supported.`); return typeBucket; }, + + /** + * ! `cache` is mutated. + * + * @param {OpportunityIdCacheType} cache + * @param {string} opportunityId + * @param {object} args + * @param {string} args.criteriaName + * @param {string} args.bookingFlow + * @param {string} args.opportunityType + * @param {string} args.sellerId + */ + setOpportunityMatchesCriteria(cache, opportunityId, { criteriaName, bookingFlow, opportunityType, sellerId }) { + const typeBucket = CriteriaOrientedOpportunityIdCache.getTypeBucket(cache, { + criteriaName, bookingFlow, opportunityType, + }); + if (!typeBucket.contents.has(sellerId)) { + typeBucket.contents.set(sellerId, new Set()); + } + const sellerCompartment = typeBucket.contents.get(sellerId); + sellerCompartment.add(opportunityId); + // Hide criteriaErrors if at least one matching item is found + typeBucket.criteriaErrors = undefined; + }, + + /** + * ! `cache` is mutated. + * + * @param {OpportunityIdCacheType} cache + * @param {string} opportunityId + * @param {string[]} unmetCriteriaDetails + * @param {object} args + * @param {string} args.criteriaName + * @param {string} args.bookingFlow + * @param {string} args.opportunityType + * @param {string} args.sellerId + */ + setOpportunityDoesNotMatchCriteria(cache, opportunityId, unmetCriteriaDetails, { criteriaName, bookingFlow, opportunityType, sellerId }) { + const typeBucket = CriteriaOrientedOpportunityIdCache.getTypeBucket(cache, { + criteriaName, bookingFlow, opportunityType, + }); + if (!typeBucket.contents.has(sellerId)) { + typeBucket.contents.set(sellerId, new Set()); + } + const sellerCompartment = typeBucket.contents.get(sellerId); + // Delete it in case it had previously matched + sellerCompartment.delete(opportunityId); + // Ignore errors if criteriaErrors is already hidden + if (typeBucket.criteriaErrors) { + for (const error of unmetCriteriaDetails) { + if (!typeBucket.criteriaErrors.has(error)) typeBucket.criteriaErrors.set(error, 0); + typeBucket.criteriaErrors.set(error, typeBucket.criteriaErrors.get(error) + 1); + } + } + }, }; module.exports = { diff --git a/packages/openactive-broker-microservice/src/util/get-opportunity-by-id-from-cache.js b/packages/openactive-broker-microservice/src/util/get-opportunity-by-id-from-cache.js new file mode 100644 index 0000000000..dd81743a21 --- /dev/null +++ b/packages/openactive-broker-microservice/src/util/get-opportunity-by-id-from-cache.js @@ -0,0 +1,54 @@ +const { jsonLdHasReferencedParent, getMergedJsonLdContext } = require('./jsonld-utils'); + +/** + * For a given `childOpportunityId`, fetch the full opportunity from the cache. + * If the opportunity has a parent, the full opportunity for the parent will be + * fetched and merged into the `superEvent` or `facilityUse` property. + * + * @param {Pick} state + * @param {string} childOpportunityId + */ +function getOpportunityMergedWithParentById(state, childOpportunityId) { + const opportunity = state.opportunityCache.childMap.get(childOpportunityId); + if (!opportunity) { + return null; + } + if (!jsonLdHasReferencedParent(opportunity)) { + return opportunity; + } + const superEvent = state.opportunityCache.parentMap.get(/** @type {string} */(opportunity.superEvent)); + const facilityUse = state.opportunityCache.parentMap.get(/** @type {string} */(opportunity.facilityUse)); + if (superEvent || facilityUse) { + const mergedContexts = getMergedJsonLdContext(opportunity, superEvent, facilityUse); + delete opportunity['@context']; + const returnObj = { + '@context': mergedContexts, + ...opportunity, + }; + if (superEvent) { + const superEventWithoutContext = { + ...superEvent, + }; + delete superEventWithoutContext['@context']; + return { + ...returnObj, + superEvent: superEventWithoutContext, + }; + } + if (facilityUse) { + const facilityUseWithoutContext = { + ...facilityUse, + }; + delete facilityUseWithoutContext['@context']; + return { + ...returnObj, + facilityUse: facilityUseWithoutContext, + }; + } + } + return null; +} + +module.exports = { + getOpportunityMergedWithParentById, +}; diff --git a/packages/openactive-broker-microservice/src/util/get-orphans.js b/packages/openactive-broker-microservice/src/util/get-orphans.js new file mode 100644 index 0000000000..f65265d070 --- /dev/null +++ b/packages/openactive-broker-microservice/src/util/get-orphans.js @@ -0,0 +1,25 @@ +/** + * @param {Pick} state + */ +function getOrphanJson(state) { + const rows = Array.from(state.opportunityItemRowCache.store.values()).filter((x) => x.jsonLdParentId !== null); + return { + children: { + matched: rows.filter((x) => !x.waitingForParentToBeIngested).length, + orphaned: rows.filter((x) => x.waitingForParentToBeIngested).length, + total: rows.length, + orphanedList: rows.filter((x) => x.waitingForParentToBeIngested).slice(0, 1000).map((({ jsonLdType, id, modified, jsonLd, jsonLdId, jsonLdParentId }) => ({ + jsonLdType, + id, + modified, + jsonLd, + jsonLdId, + jsonLdParentId, + }))), + }, + }; +} + +module.exports = { + getOrphanJson, +}; diff --git a/packages/openactive-broker-microservice/src/util/get-random-bookable-opportunity.js b/packages/openactive-broker-microservice/src/util/get-random-bookable-opportunity.js new file mode 100644 index 0000000000..6e35139b41 --- /dev/null +++ b/packages/openactive-broker-microservice/src/util/get-random-bookable-opportunity.js @@ -0,0 +1,80 @@ +const { CriteriaOrientedOpportunityIdCache } = require('./criteria-oriented-opportunity-id-cache'); +const { mapToObjectSummary } = require('./map-to-object-summary'); +const { getAllLockedOpportunityIds, getLockedOpportunityIdsInTestDataset } = require('./state-utils'); + +/** + * Get a random opportunity from Broker Microservice's cache that matches the + * criteria. + * + * @param {Pick} state + * ! Mutates `state` by adding the selected opportunity to the test dataset's locked opportunity IDs. + * @param {object} args + * @param {string} args.sellerId + * @param {string} args.bookingFlow + * @param {string} args.opportunityType + * @param {string} args.criteriaName + * @param {string} args.testDatasetIdentifier + * @returns {any} + */ +function getRandomBookableOpportunity(state, { sellerId, bookingFlow, opportunityType, criteriaName, testDatasetIdentifier }) { + const typeBucket = CriteriaOrientedOpportunityIdCache.getTypeBucket(state.criteriaOrientedOpportunityIdCache, { + criteriaName, bookingFlow, opportunityType, + }); + const sellerCompartment = typeBucket.contents.get(sellerId); + if (!sellerCompartment || sellerCompartment.size === 0) { + const availableSellers = mapToObjectSummary(typeBucket.contents); + const noCriteriaErrors = bookingFlow === 'OpenBookingApprovalFlow' + ? "Ensure that some Offers have an 'openBookingFlowRequirement' property that includes the value 'https://openactive.io/OpenBookingApproval'" + : "Ensure that some Offers have an 'openBookingFlowRequirement' property that DOES NOT include the value 'https://openactive.io/OpenBookingApproval'"; + const criteriaErrors = !typeBucket.criteriaErrors || typeBucket.criteriaErrors?.size === 0 ? noCriteriaErrors : Object.fromEntries(typeBucket.criteriaErrors); + return { + suggestion: availableSellers ? 'Try setting sellers.primary.@id in the JSON config to one of the availableSellers below.' : `Check criteriaErrors below for reasons why '${opportunityType}' items in your feeds are not matching the criteria '${criteriaName}'.${typeBucket.criteriaErrors?.size > 0 ? ' The number represents the number of items that do not match.' : ''}`, + availableSellers, + criteriaErrors: typeBucket.criteriaErrors ? criteriaErrors : undefined, + }; + } // Seller has no items + + const allLockedOpportunityIds = getAllLockedOpportunityIds(state); + const unusedBucketItems = Array.from(sellerCompartment).filter((x) => !allLockedOpportunityIds.has(x)); + + if (unusedBucketItems.length === 0) { + return { + suggestion: `No enough items matching criteria '${criteriaName}' were included in your feeds to run all tests. Try adding more test data to your system, or consider using 'Controlled Mode'.`, + }; + } + + const id = unusedBucketItems[Math.floor(Math.random() * unusedBucketItems.length)]; + + // Add the item to the testDataset to ensure it does not get reused + getLockedOpportunityIdsInTestDataset(state, testDatasetIdentifier).add(id); + + return { + opportunity: { + '@context': 'https://openactive.io/', + '@type': getTypeFromOpportunityType(opportunityType), + '@id': id, + }, + }; +} + +/** + * @param {string} opportunityType + */ +function getTypeFromOpportunityType(opportunityType) { + const mapping = { + ScheduledSession: 'ScheduledSession', + FacilityUseSlot: 'Slot', + IndividualFacilityUseSlot: 'Slot', + CourseInstance: 'CourseInstance', + HeadlineEvent: 'HeadlineEvent', + Event: 'Event', + HeadlineEventSubEvent: 'Event', + CourseInstanceSubEvent: 'Event', + OnDemandEvent: 'OnDemandEvent', + }; + return mapping[opportunityType]; +} + +module.exports = { + getRandomBookableOpportunity, +}; diff --git a/packages/openactive-broker-microservice/src/util/get-status.js b/packages/openactive-broker-microservice/src/util/get-status.js new file mode 100644 index 0000000000..314ed12736 --- /dev/null +++ b/packages/openactive-broker-microservice/src/util/get-status.js @@ -0,0 +1,59 @@ +/** + * @typedef {Object} OrphanStats + * @property {number} childOrphans + * @property {number} totalChildren + * @property {string} percentageChildOrphans + * @property {number} totalOpportunities + */ + +const { mapToObjectSummary } = require('./map-to-object-summary'); + +/** + * @param {Pick} config + * @param {Pick} state + */ +function getStatus(config, state) { + const { childOrphans, totalChildren, percentageChildOrphans, totalOpportunities } = getOrphanStats(state); + return { + elapsedTime: millisToMinutesAndSeconds((new Date()).getTime() - state.startTime.getTime()), + harvestingStatus: state.pauseResume.pauseHarvestingStatus, + feeds: mapToObjectSummary(state.feedContextMap), + orphans: { + children: `${childOrphans} of ${totalChildren} (${percentageChildOrphans}%)`, + }, + totalOpportunitiesHarvested: totalOpportunities, + buckets: config.DO_NOT_FILL_BUCKETS ? null : mapToObjectSummary(state.criteriaOrientedOpportunityIdCache), + }; +} + +/** + * @param {Pick} state + * @returns {OrphanStats} + */ +function getOrphanStats(state) { + const childRows = Array.from(state.opportunityItemRowCache.store.values()).filter((x) => x.jsonLdParentId !== null); + const childOrphans = childRows.filter((x) => x.waitingForParentToBeIngested).length; + const totalChildren = childRows.length; + const totalOpportunities = Array.from(state.opportunityItemRowCache.store.values()).filter((x) => !x.waitingForParentToBeIngested).length; + const percentageChildOrphans = totalChildren > 0 ? ((childOrphans / totalChildren) * 100).toFixed(2) : '0'; + return { + childOrphans, + totalChildren, + percentageChildOrphans, + totalOpportunities, + }; +} + +/** + * @param {number} millis + */ +function millisToMinutesAndSeconds(millis) { + const minutes = Math.floor(millis / 60000); + const seconds = ((millis % 60000) / 1000); + return `${minutes}:${seconds < 10 ? '0' : ''}${seconds.toFixed(0)}`; +} + +module.exports = { + getOrphanStats, + getStatus, +}; diff --git a/packages/openactive-broker-microservice/src/util/jsonld-utils.js b/packages/openactive-broker-microservice/src/util/jsonld-utils.js new file mode 100644 index 0000000000..0c742dbb80 --- /dev/null +++ b/packages/openactive-broker-microservice/src/util/jsonld-utils.js @@ -0,0 +1,36 @@ +/** + * Merge and sort JSON-LD `@context` from multiple Opportunities. + * + * @param {...import('../models/core').Opportunity} opportunities + */ +function getMergedJsonLdContext(...opportunities) { + return sortJsonLdContextWithOpenActiveOnTop([...new Set(opportunities.flatMap((x) => x && x['@context']).filter((x) => x))]); +} + +/** + * Does this Opportunity have a reference to a Parent Opportunity? (i.e. is it a Child Opportunity?) + * + * @param {import('../models/core').Opportunity} data + */ +function jsonLdHasReferencedParent(data) { + return typeof data?.superEvent === 'string' || typeof data?.facilityUse === 'string'; +} +/** + * Sort JSON-LD `@context` so that `https://openactive.io/` and + * `https://schema.org/` are at the top, which is useful for consistency + * and required by validator. + * + * @param {string[]} context + */ +function sortJsonLdContextWithOpenActiveOnTop(context) { + const firstList = []; + if (context.includes('https://openactive.io/')) firstList.push('https://openactive.io/'); + if (context.includes('https://schema.org/')) firstList.push('https://schema.org/'); + const remainingList = context.filter((x) => x !== 'https://openactive.io/' && x !== 'https://schema.org/'); + return firstList.concat(remainingList.sort()); +} + +module.exports = { + getMergedJsonLdContext, + jsonLdHasReferencedParent, +}; diff --git a/packages/openactive-broker-microservice/src/util/map-to-object-summary.js b/packages/openactive-broker-microservice/src/util/map-to-object-summary.js new file mode 100644 index 0000000000..0a99e808c7 --- /dev/null +++ b/packages/openactive-broker-microservice/src/util/map-to-object-summary.js @@ -0,0 +1,58 @@ +/** + * Convert an ES Map to a plain object (recursively), summarising some aspects + * as follows: + * + * - Any value that is an ES Set will be replaced with its size + * - Hide any properties which start with the character '_' in objects + * - OpportunityIdCacheTypeBucket objects have special handling + * + * @param {Map | Set} map + * @returns {{[k: string]: any} | number} + */ +function mapToObjectSummary(map) { + if (map instanceof Map) { + // Return a object representation of a Map + const obj = Object.assign(Object.create(null), ...[...map].map((v) => (typeof v[1] === 'object' && v[1].size === 0 + ? {} + : { + [v[0]]: mapToObjectSummary(v[1]), + }))); + if (JSON.stringify(obj) === JSON.stringify({})) { + return undefined; + } + return obj; + } + if (map instanceof Set) { + // Return just the size of a Set, to render at the leaf nodes of the resulting tree, + // instead of outputting the whole set contents. This reduces the size of the output for display. + return map.size; + } + // Special handling for OpportunityIdCacheTypeBuckets + // @ts-ignore + if (map.contents) { + // @ts-ignore + const result = mapToObjectSummary(map.contents); + if (result && Object.keys(result).length > 0) { + // @ts-ignore + return result; + } + // @ts-ignore + if (map.criteriaErrors && map.criteriaErrors.size > 0) { + return { + // @ts-ignore + criteriaErrors: Object.fromEntries(map.criteriaErrors), + }; + } + return undefined; + } + // @ts-ignore + if (map instanceof Object) { + // Hide any properties that start with the character '_' in objects, as these are not intended for display + return Object.fromEntries(Object.entries(map).filter(([k]) => k.charAt(0) !== '_')); + } + return map; +} + +module.exports = { + mapToObjectSummary, +}; diff --git a/packages/openactive-broker-microservice/src/util/opportunity-utils.js b/packages/openactive-broker-microservice/src/util/opportunity-utils.js new file mode 100644 index 0000000000..176687fe50 --- /dev/null +++ b/packages/openactive-broker-microservice/src/util/opportunity-utils.js @@ -0,0 +1,64 @@ +/** + * @param {import('../models/core').Opportunity} opportunity + */ +function detectOpportunityType(opportunity) { + switch (opportunity['@type'] || opportunity.type) { + case 'ScheduledSession': + if (opportunity.superEvent && (opportunity.superEvent['@type'] || opportunity.superEvent.type) === 'SessionSeries') { + return 'ScheduledSession'; + } + throw new Error('ScheduledSession must have superEvent of SessionSeries'); + + case 'Slot': + if (opportunity.facilityUse && (opportunity.facilityUse['@type'] || opportunity.facilityUse.type) === 'IndividualFacilityUse') { + return 'IndividualFacilityUseSlot'; + } + if (opportunity.facilityUse && (opportunity.facilityUse['@type'] || opportunity.facilityUse.type) === 'FacilityUse') { + return 'FacilityUseSlot'; + } + + throw new Error('Slot must have facilityUse of FacilityUse or IndividualFacilityUse'); + + case 'CourseInstance': + return 'CourseInstance'; + case 'HeadlineEvent': + return 'HeadlineEvent'; + case 'OnDemandEvent': + return 'OnDemandEvent'; + case 'Event': + switch (opportunity.superEvent && (opportunity.superEvent['@type'] || opportunity.superEvent.type)) { + case 'HeadlineEvent': + return 'HeadlineEventSubEvent'; + case 'CourseInstance': + return 'CourseInstanceSubEvent'; + case 'EventSeries': + case null: + case undefined: + return 'Event'; + default: + throw new Error('Event has unrecognised @type of superEvent'); + } + default: + throw new Error('Only bookable opportunities are permitted in the test interface'); + } +} + +/** + * @param {import('../models/core').Opportunity} opportunity + */ +function detectSellerId(opportunity) { + const organizer = opportunity.organizer + || opportunity.superEvent?.organizer + || opportunity.superEvent?.superEvent?.organizer + || opportunity?.facilityUse?.provider + || opportunity?.facilityUse?.aggregateFacilityUse?.provider; + + if (typeof organizer === 'string') return organizer; + + return organizer?.['@id'] || organizer?.id; +} + +module.exports = { + detectOpportunityType, + detectSellerId, +}; diff --git a/packages/openactive-broker-microservice/src/util/sample-opportunities.js b/packages/openactive-broker-microservice/src/util/sample-opportunities.js new file mode 100644 index 0000000000..c2b5e75067 --- /dev/null +++ b/packages/openactive-broker-microservice/src/util/sample-opportunities.js @@ -0,0 +1,81 @@ +const { getRelevantOffers } = require('@openactive/test-interface-criteria'); +const { getOpportunityMergedWithParentById } = require('./get-opportunity-by-id-from-cache'); +const { getRandomBookableOpportunity } = require('./get-random-bookable-opportunity'); +const { detectOpportunityType, detectSellerId } = require('./opportunity-utils'); + +/** + * @param {Pick} config + * @param {Pick} state + * @param {import('../models/core').Opportunity} opportunity + */ +function getSampleOpportunities(config, state, opportunity) { + // Get random opportunity ID + const opportunityType = detectOpportunityType(opportunity); + const sellerId = detectSellerId(opportunity); + const testDatasetIdentifier = 'sample-opportunities'; + + const criteriaName = opportunity['test:testOpportunityCriteria'].replace('https://openactive.io/test-interface#', ''); + const bookingFlow = opportunity['test:testOpenBookingFlow'].replace('https://openactive.io/test-interface#', ''); + + const bookableOpportunity = getRandomBookableOpportunity(state, { + sellerId, bookingFlow, opportunityType, criteriaName, testDatasetIdentifier, + }); + + if (bookableOpportunity.opportunity) { + const opportunityWithParent = getOpportunityMergedWithParentById( + state, + bookableOpportunity.opportunity['@id'], + ); + const json = renderSampleOpportunities( + config, opportunityWithParent, criteriaName, sellerId, + ); + return json; + } + return { + error: bookableOpportunity, + }; +} + +// NOTE: duplicated from openactive-integration-tests/test/helpers/flow-stages/fetch-opportunities.js +/** + * @param {string} harvestStartTime + * @param {import('../models/core').Opportunity} opportunity + * @param {string} criteriaName + */ +function getRandomRelevantOffer(harvestStartTime, opportunity, criteriaName) { + const relevantOffers = getRelevantOffers(criteriaName, opportunity, { + harvestStartTime, + }); + if (relevantOffers.length === 0) { return null; } + + return relevantOffers[Math.floor(Math.random() * relevantOffers.length)]; +} + +/** + * @param {Pick} config + * @param {import('../models/core').Opportunity} opportunity + * @param {string} criteriaName + * @param {string} sellerId + */ +function renderSampleOpportunities(config, opportunity, criteriaName, sellerId) { + const offer = getRandomRelevantOffer(config.HARVEST_START_TIME, opportunity, criteriaName); + + return { + sampleOpportunities: [ + opportunity, + ], + sellerId, + exampleOrderItems: [ + { + '@type': 'OrderItem', + position: 0, + acceptedOffer: offer['@id'], + orderedItem: opportunity['@id'], + }, + ], + }; +} + +module.exports = { + getSampleOpportunities, +}; diff --git a/packages/openactive-broker-microservice/src/util/state-utils.js b/packages/openactive-broker-microservice/src/util/state-utils.js new file mode 100644 index 0000000000..6ba655a7bd --- /dev/null +++ b/packages/openactive-broker-microservice/src/util/state-utils.js @@ -0,0 +1,30 @@ +/** + * @param {Pick} state + */ +function getAllLockedOpportunityIds(state) { + return new Set( + Array.from(state.lockedOpportunityIdsByTestDataset.values()).flatMap((x) => ( + Array.from(x.values()) + )), + ); +} + +/** + * All Opportunity IDs that are considered "locked" (because they have already + * been used in a test) for the specified [Test Dataset](https://openactive.io/test-interface/#datasets-endpoints). + * + * @param {Pick} state + * @param {string} testDatasetIdentifier + * @returns {Set} + */ +function getLockedOpportunityIdsInTestDataset(state, testDatasetIdentifier) { + if (!state.lockedOpportunityIdsByTestDataset.has(testDatasetIdentifier)) { + state.lockedOpportunityIdsByTestDataset.set(testDatasetIdentifier, new Set()); + } + return state.lockedOpportunityIdsByTestDataset.get(testDatasetIdentifier); +} + +module.exports = { + getAllLockedOpportunityIds, + getLockedOpportunityIdsInTestDataset, +}; diff --git a/packages/openactive-broker-microservice/test/user-facing-endpoints-test.js b/packages/openactive-broker-microservice/test/user-facing-endpoints-test.js new file mode 100644 index 0000000000..cbacb7ad1e --- /dev/null +++ b/packages/openactive-broker-microservice/test/user-facing-endpoints-test.js @@ -0,0 +1,449 @@ +const { expect } = require('chai'); +const { getStatus } = require('../src/util/get-status'); +const { CriteriaOrientedOpportunityIdCache } = require('../src/util/criteria-oriented-opportunity-id-cache'); +const PauseResume = require('../src/util/pause-resume'); +const { getOrphanJson } = require('../src/util/get-orphans'); +const { getOpportunityMergedWithParentById } = require('../src/util/get-opportunity-by-id-from-cache'); +const { getSampleOpportunities } = require('../src/util/sample-opportunities'); + +const testDataGenerators = { + opportunityItemRowCacheStoreItems: { + /** + * @param {string} id + * @param {string} parentId + * @returns {[string, import('../src/models/core').OpportunityItemRow]} + */ + hasAParent: (id, parentId) => [ + id, { + id, + jsonLdId: id, + jsonLdParentId: parentId, + jsonLdType: 'ScheduledSession', + jsonLd: { + '@type': 'ScheduledSession', + }, + feedModified: String(Date.now()), + deleted: false, + modified: '123', + waitingForParentToBeIngested: false, + }, + ], + /** + * @param {string} id + * @returns {[string, import('../src/models/core').OpportunityItemRow]} + */ + hasNoParentButDoesntNeedOne: (id) => [ + id, { + id, + jsonLdId: id, + jsonLdParentId: null, + jsonLdType: 'ScheduledSession', + jsonLd: { + '@type': 'ScheduledSession', + }, + feedModified: String(Date.now()), + deleted: false, + modified: '123', + waitingForParentToBeIngested: false, + }, + ], + /** + * @param {string} id + * @param {string} parentId + * @returns {[string, import('../src/models/core').OpportunityItemRow]} + */ + hasNoParentAndShouldHaveOne: (id, parentId) => [ + id, { + id, + jsonLdId: id, + jsonLdParentId: parentId, + jsonLdType: 'ScheduledSession', + jsonLd: { + '@type': 'ScheduledSession', + }, + feedModified: String(Date.now()), + deleted: false, + modified: '123', + waitingForParentToBeIngested: true, + }, + ], + }, +}; + +describe('user-facing endpoints', () => { + describe('GET /status', () => { + it('should include stats about orphans and criteria matches', () => { + const cooiCache = CriteriaOrientedOpportunityIdCache.create(); + CriteriaOrientedOpportunityIdCache.setOpportunityMatchesCriteria(cooiCache, 'id1', { + criteriaName: 'TestOpportunityBookable', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityMatchesCriteria(cooiCache, 'id1', { + criteriaName: 'TestOpportunityBookableFree', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityDoesNotMatchCriteria(cooiCache, 'id1', ['does not have one space'], { + criteriaName: 'TestOpportunityBookableOneSpace', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityMatchesCriteria(cooiCache, 'id2', { + criteriaName: 'TestOpportunityBookable', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityDoesNotMatchCriteria(cooiCache, 'id2', ['is not free'], { + criteriaName: 'TestOpportunityBookableFree', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityDoesNotMatchCriteria(cooiCache, 'id1', ['does not have one space'], { + criteriaName: 'TestOpportunityBookableOneSpace', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + const result = getStatus({ + DO_NOT_FILL_BUCKETS: false, + }, { + startTime: new Date(), + opportunityItemRowCache: { + store: new Map([ + // Has a parent + testDataGenerators.opportunityItemRowCacheStoreItems.hasAParent('id1', 'parentid1'), + // Has no parent but doesn't need one either + testDataGenerators.opportunityItemRowCacheStoreItems.hasNoParentButDoesntNeedOne('id2'), + // Has no parent and should have one + testDataGenerators.opportunityItemRowCacheStoreItems.hasNoParentAndShouldHaveOne('id3', 'parentid3'), + ]), + parentIdIndex: new Map(), + }, + criteriaOrientedOpportunityIdCache: cooiCache, + feedContextMap: new Map(), + pauseResume: new PauseResume(), + }); + // Only two of the three opportunities are "child opportunities" i.e. have + // a parent + expect(result.orphans.children).to.equal('1 of 2 (50.00%)'); + // Only two of the three opportunities count as being harvested — those + // that are not still waiting for parent + expect(result.totalOpportunitiesHarvested).to.equal(2); + expect(/** @type {any} */(result.buckets).TestOpportunityBookable.OpenBookingSimpleFlow.ScheduledSession).to.deep.equal({ + seller1: 2, + }); + expect(/** @type {any} */(result.buckets).TestOpportunityBookableFree.OpenBookingSimpleFlow.ScheduledSession).to.deep.equal({ + seller1: 1, + }); + expect(/** @type {any} */(result.buckets).TestOpportunityBookableOneSpace.OpenBookingSimpleFlow.ScheduledSession).to.deep.equal({ + criteriaErrors: { + 'does not have one space': 2, + }, + }); + }); + }); + describe('GET /orphans', () => { + it('should return stats about which opportunities are orphans i.e. have no parents', () => { + const result = getOrphanJson({ + opportunityItemRowCache: { + store: new Map([ + testDataGenerators.opportunityItemRowCacheStoreItems.hasAParent('id1', 'parentid1'), + testDataGenerators.opportunityItemRowCacheStoreItems.hasNoParentButDoesntNeedOne('id2'), + testDataGenerators.opportunityItemRowCacheStoreItems.hasNoParentAndShouldHaveOne('id3', 'parentid3'), + ]), + parentIdIndex: new Map(), + }, + }); + expect(result).to.deep.equal({ + children: { + // id1 + matched: 1, + // id3 + orphaned: 1, + // id2 is discounted because it doesn't need a parent + total: 2, + orphanedList: [{ + id: 'id3', + jsonLdId: 'id3', + jsonLdParentId: 'parentid3', + jsonLdType: 'ScheduledSession', + jsonLd: { + '@type': 'ScheduledSession', + }, + modified: '123', + }], + }, + }); + }); + }); + describe('GET /opportunity-cache/:id', () => { + it('should get an opportunity from the cache, merged with its parent', () => { + /** @type {import('../src/state').State['opportunityCache']} */ + const opportunityCache = { + parentMap: new Map([ + ['parentid1', { + '@context': ['https://openactive.io/', 'https://openactive.io/ns-beta'], + '@type': 'FacilityUse', + name: 'Facility 1', + }], + ['parentid2', { + '@context': ['https://openactive.io/', 'https://openactive.io/ns-beta'], + '@type': 'SessionSeries', + description: 'come have fun besties <3', + }], + ]), + childMap: new Map([ + ['id1', { + '@context': ['https://openactive.io/'], + '@type': 'Slot', + facilityUse: 'parentid1', + startDate: '2001-01-01T00:00:00Z', + }], + ['id2', { + '@context': ['https://openactive.io/'], + '@type': 'ScheduledSession', + superEvent: 'parentid2', + name: 'ScheduledSession 1', + }], + ]), + }; + const slotResult = getOpportunityMergedWithParentById({ + opportunityCache, + }, 'id1'); + expect(slotResult).to.deep.equal({ + '@context': ['https://openactive.io/', 'https://openactive.io/ns-beta'], + '@type': 'Slot', + facilityUse: { + '@type': 'FacilityUse', + name: 'Facility 1', + }, + startDate: '2001-01-01T00:00:00Z', + }); + const scsResult = getOpportunityMergedWithParentById({ + opportunityCache, + }, 'id2'); + expect(scsResult).to.deep.equal({ + '@context': ['https://openactive.io/', 'https://openactive.io/ns-beta'], + '@type': 'ScheduledSession', + superEvent: { + '@type': 'SessionSeries', + description: 'come have fun besties <3', + }, + name: 'ScheduledSession 1', + }); + }); + }); + describe('GET /sample-opportunities', () => { + it('should get a random opportunity matching a criteria, and then lock it', () => { + /** + * @param {any} result + * @param {string[]} idAllowlist + * @param {string[]} parentIdAllowlist + * @param {string[]} offerIdAllowlist + */ + const testResult = (result, idAllowlist, parentIdAllowlist, offerIdAllowlist) => { + expect(result).to.have.property('sampleOpportunities').that.has.lengthOf(1); + const [sampleOpportunity] = result.sampleOpportunities; + expect(sampleOpportunity).to.have.property('@type', 'ScheduledSession'); + expect(sampleOpportunity).to.have.property('@id').which.is.oneOf(idAllowlist); + expect(sampleOpportunity).to.have.nested.property('superEvent.@type', 'SessionSeries'); + expect(sampleOpportunity).to.have.nested.property('superEvent.@id').which.is.oneOf(parentIdAllowlist); + expect(result).to.have.property('exampleOrderItems').that.has.lengthOf(1); + const [exampleOrderItem] = result.exampleOrderItems; + expect(exampleOrderItem).to.include({ + '@type': 'OrderItem', + position: 0, + }); + expect(exampleOrderItem).to.have.property('acceptedOffer').which.is.oneOf(offerIdAllowlist); + expect(exampleOrderItem).to.have.property('orderedItem', sampleOpportunity['@id']); + }; + + const cooiCache = CriteriaOrientedOpportunityIdCache.create(); + CriteriaOrientedOpportunityIdCache.setOpportunityMatchesCriteria(cooiCache, 'id1', { + criteriaName: 'TestOpportunityBookable', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityMatchesCriteria(cooiCache, 'id1', { + criteriaName: 'TestOpportunityBookableFree', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityDoesNotMatchCriteria(cooiCache, 'id1', ['does not have one space'], { + criteriaName: 'TestOpportunityBookableOneSpace', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityMatchesCriteria(cooiCache, 'id2', { + criteriaName: 'TestOpportunityBookable', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityDoesNotMatchCriteria(cooiCache, 'id2', ['is not free'], { + criteriaName: 'TestOpportunityBookableFree', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + CriteriaOrientedOpportunityIdCache.setOpportunityDoesNotMatchCriteria(cooiCache, 'id1', ['does not have one space'], { + criteriaName: 'TestOpportunityBookableOneSpace', + bookingFlow: 'OpenBookingSimpleFlow', + opportunityType: 'ScheduledSession', + sellerId: 'seller1', + }); + /** @type {import('../src/state').State['opportunityCache']} */ + const opportunityCache = { + parentMap: new Map([ + ['parentid1', { + '@context': ['https://openactive.io/', 'https://openactive.io/ns-beta'], + '@type': 'SessionSeries', + '@id': 'parentid1', + name: 'Session 1', + organizer: { + '@type': 'Organization', + isOpenBookingAllowed: true, + }, + offers: [{ + '@type': 'Offer', + '@id': 'offer1', + name: 'offer1', + price: 10, + }, { + '@type': 'Offer', + '@id': 'offer2', + name: 'offer2', + price: 0, + }, { + '@type': 'Offer', + '@id': 'offer3', + name: 'offer3', + // This one should not be bookable as "now" is Jan 1st 2001 and the + // session starts Jan 2nd. + validFromBeforeStartDate: 'PT1S', + price: 20, + }], + }], + ['parentid2', { + '@context': ['https://openactive.io/', 'https://openactive.io/ns-beta'], + '@type': 'SessionSeries', + '@id': 'parentid2', + description: 'Session 2', + organizer: { + '@type': 'Organization', + isOpenBookingAllowed: true, + }, + offers: [{ + '@type': 'Offer', + '@id': 'offer1', + name: 'offer1', + price: 10, + }, { + '@type': 'Offer', + '@id': 'offer2', + name: 'offer2', + price: 0, + }, { + '@type': 'Offer', + '@id': 'offer3', + name: 'offer3', + // This one should not be bookable as "now" is Jan 1st 2001 and the + // session starts Jan 2nd. + validThroughBeforeStartDate: 'P100D', + price: 20, + }], + }], + ]), + childMap: new Map([ + ['id1', { + '@context': ['https://openactive.io/'], + '@type': 'ScheduledSession', + '@id': 'id1', + superEvent: 'parentid1', + startDate: '2001-01-02T00:00:00Z', + }], + ['id2', { + '@context': ['https://openactive.io/'], + '@type': 'ScheduledSession', + '@id': 'id2', + superEvent: 'parentid2', + name: 'ScheduledSession 2', + startDate: '2001-01-02T00:00:00Z', + }], + ]), + }; + const lockedOpportunityIdsByTestDataset = new Map(); + const brokerConfig = { + HARVEST_START_TIME: '2001-01-01T00:00:00Z', + }; + const state = { + criteriaOrientedOpportunityIdCache: cooiCache, + opportunityCache, + lockedOpportunityIdsByTestDataset, + }; + /** + * @param {string} criteria + */ + const makeReqBody = (criteria) => ({ + '@context': [ + 'https://openactive.io/', + 'https://openactive.io/test-interface', + ], + '@type': 'ScheduledSession', + superEvent: { + '@type': 'SessionSeries', + organizer: { + '@type': 'Organization', + '@id': 'seller1', + }, + }, + 'test:testOpportunityCriteria': criteria, + 'test:testOpenBookingFlow': 'https://openactive.io/test-interface#OpenBookingSimpleFlow', + }); + const result1 = getSampleOpportunities( + brokerConfig, + state, + makeReqBody('https://openactive.io/test-interface#TestOpportunityBookable'), + ); + testResult(result1, ['id1', 'id2'], ['parentid1', 'parentid2'], ['offer1', 'offer2']); + // That item should now have been locked. So another call should get the other item + const result2 = getSampleOpportunities( + brokerConfig, + state, + makeReqBody('https://openactive.io/test-interface#TestOpportunityBookable'), + ); + const isResult1Id1 = result1.sampleOpportunities[0]['@id'] === 'id1'; + testResult( + result2, + isResult1Id1 ? ['id2'] : ['id1'], + isResult1Id1 ? ['parentid2'] : ['parentid1'], + ['offer1', 'offer2'], + ); + // And then another call should get nothing + const result3 = getSampleOpportunities( + brokerConfig, + state, + makeReqBody('https://openactive.io/test-interface#TestOpportunityBookable'), + ); + expect(result3).to.have.nested.property('error.suggestion'); + // We reset by clearing the locks + lockedOpportunityIdsByTestDataset.clear(); + const result4 = getSampleOpportunities( + brokerConfig, + state, + makeReqBody('https://openactive.io/test-interface#TestOpportunityBookableFree'), + ); + // Only this combo supports free bookings + testResult(result4, ['id1'], ['parentid1'], ['offer2']); + }); + }); +}); diff --git a/packages/openactive-broker-microservice/test/util/item-transforms-test.js b/packages/openactive-broker-microservice/test/util/item-transforms-test.js index d9d12bb872..f7748f6582 100644 --- a/packages/openactive-broker-microservice/test/util/item-transforms-test.js +++ b/packages/openactive-broker-microservice/test/util/item-transforms-test.js @@ -11,12 +11,16 @@ describe('test/utils/item-transforms-test', () => { '@context': 'https://openactive.io/', name: 'Facility Use 1', '@type': 'FacilityUse', - individualFacilityUse: [{ '@type': 'IndividualFacilityUse', '@id': '1' }, { '@type': 'IndividualFacilityUse', '@id': '2' }] - } + individualFacilityUse: [{ + '@type': 'IndividualFacilityUse', '@id': '1', + }, { + '@type': 'IndividualFacilityUse', '@id': '2', + }], + }, }; // Test - const result = invertFacilityUseItem(a); + const result = invertFacilityUseItem(/** @type {any} */(a)); // Assertions expect(result).to.have.lengthOf(2); @@ -25,15 +29,15 @@ describe('test/utils/item-transforms-test', () => { expect(result[0].kind).to.equal('IndividualFacilityUse'); expect(result[0].data).to.have.property('@context', 'https://openactive.io/'); expect(result[0].data).to.have.property('aggregateFacilityUse'); - expect(result[0].data.aggregateFacilityUse).to.have.property('name', 'Facility Use 1'); - expect(result[0].data.aggregateFacilityUse).to.not.have.property('@context'); + expect(/** @type {any} */(result[0].data).aggregateFacilityUse).to.have.property('name', 'Facility Use 1'); + expect(/** @type {any} */(result[0].data).aggregateFacilityUse).to.not.have.property('@context'); expect(result[1]).to.have.property('id', '2'); expect(result[1].kind).to.equal('IndividualFacilityUse'); expect(result[1].data).to.have.property('@context', 'https://openactive.io/'); expect(result[1].data).to.have.property('aggregateFacilityUse'); - expect(result[1].data.aggregateFacilityUse).to.have.property('name', 'Facility Use 1'); - expect(result[1].data.aggregateFacilityUse).to.not.have.property('@context'); + expect(/** @type {any} */(result[1].data).aggregateFacilityUse).to.have.property('name', 'Facility Use 1'); + expect(/** @type {any} */(result[1].data).aggregateFacilityUse).to.not.have.property('@context'); }); it('should not invert FacilityUse items if there are no IndividualFacilityUses', () => { // Test Objects @@ -43,11 +47,11 @@ describe('test/utils/item-transforms-test', () => { '@context': 'https://openactive.io/', name: 'Facility Use 1', '@type': 'FacilityUse', - } + }, }; // Test - const result = invertFacilityUseItem(a); + const result = invertFacilityUseItem(/** @type {any} */(a)); // Assertions expect(result).to.have.lengthOf(1); @@ -93,5 +97,3 @@ describe('test/utils/item-transforms-test', () => { }); }); }); - - diff --git a/packages/openactive-broker-microservice/tsconfig.json b/packages/openactive-broker-microservice/tsconfig.json index 75cf0c4193..081e98b22f 100644 --- a/packages/openactive-broker-microservice/tsconfig.json +++ b/packages/openactive-broker-microservice/tsconfig.json @@ -11,6 +11,8 @@ "include": [ "app.js", "src/**/*.js", - "src/**/*.d.ts" + "src/**/*.d.ts", + "test/**/*.js", + "test/**/*.d.ts" ] } \ No newline at end of file diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookable.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookable.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookable.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookable.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableAdditionalDetails.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableAdditionalDetails.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableAdditionalDetails.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableAdditionalDetails.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableAttendeeDetails.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableAttendeeDetails.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableAttendeeDetails.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableAttendeeDetails.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableCancellable.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableCancellable.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableCancellable.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableCancellable.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableCancellableNoWindow.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableCancellableNoWindow.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableCancellableNoWindow.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableCancellableNoWindow.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableCancellableOutsideWindow.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableCancellableOutsideWindow.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableCancellableOutsideWindow.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableCancellableOutsideWindow.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableCancellableWithinWindow.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableCancellableWithinWindow.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableCancellableWithinWindow.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableCancellableWithinWindow.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFiveSpaces.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFiveSpaces.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFiveSpaces.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFiveSpaces.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFree.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFree.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFree.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFree.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFreeCancellable.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFreeCancellable.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFreeCancellable.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFreeCancellable.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFreePrepaymentOptional.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFreePrepaymentOptional.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFreePrepaymentOptional.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFreePrepaymentOptional.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFreePrepaymentRequired.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFreePrepaymentRequired.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableFreePrepaymentRequired.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableFreePrepaymentRequired.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableInPast.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableInPast.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableInPast.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableInPast.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNoSpaces.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNoSpaces.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNoSpaces.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNoSpaces.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFree.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFree.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFree.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFree.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreeCancellable.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreeCancellable.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreeCancellable.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreeCancellable.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreePrepaymentOptional.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreePrepaymentOptional.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreePrepaymentOptional.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreePrepaymentOptional.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreePrepaymentRequired.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreePrepaymentRequired.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreePrepaymentRequired.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreePrepaymentRequired.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreePrepaymentUnavailable.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreePrepaymentUnavailable.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreePrepaymentUnavailable.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreePrepaymentUnavailable.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreeTaxGross.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreeTaxGross.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreeTaxGross.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreeTaxGross.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreeTaxNet.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreeTaxNet.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNonFreeTaxNet.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNonFreeTaxNet.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNotCancellable.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNotCancellable.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableNotCancellable.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableNotCancellable.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableOneSpace.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableOneSpace.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableOneSpace.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableOneSpace.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableOutsideValidFromBeforeStartDate.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableOutsideValidFromBeforeStartDate.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableOutsideValidFromBeforeStartDate.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableOutsideValidFromBeforeStartDate.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableSellerTermsOfService.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableSellerTermsOfService.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableSellerTermsOfService.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableSellerTermsOfService.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableUsingPayment.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableUsingPayment.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableUsingPayment.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableUsingPayment.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableWithNegotiation.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableWithNegotiation.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableWithNegotiation.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableWithNegotiation.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableWithinValidFromBeforeStartDate.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableWithinValidFromBeforeStartDate.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityBookableWithinValidFromBeforeStartDate.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityBookableWithinValidFromBeforeStartDate.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityOfflineBookable.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityOfflineBookable.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityOfflineBookable.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityOfflineBookable.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/TestOpportunityOnlineBookable.d.ts b/packages/test-interface-criteria/built-types/criteria/TestOpportunityOnlineBookable.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/TestOpportunityOnlineBookable.d.ts rename to packages/test-interface-criteria/built-types/criteria/TestOpportunityOnlineBookable.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/criteriaUtils.d.ts b/packages/test-interface-criteria/built-types/criteria/criteriaUtils.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/criteriaUtils.d.ts rename to packages/test-interface-criteria/built-types/criteria/criteriaUtils.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/index.d.ts b/packages/test-interface-criteria/built-types/criteria/index.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/index.d.ts rename to packages/test-interface-criteria/built-types/criteria/index.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/internal/InternalCriteriaFutureScheduledAndDoesNotRequireDetails.d.ts b/packages/test-interface-criteria/built-types/criteria/internal/InternalCriteriaFutureScheduledAndDoesNotRequireDetails.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/internal/InternalCriteriaFutureScheduledAndDoesNotRequireDetails.d.ts rename to packages/test-interface-criteria/built-types/criteria/internal/InternalCriteriaFutureScheduledAndDoesNotRequireDetails.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/internal/InternalCriteriaFutureScheduledOpportunity.d.ts b/packages/test-interface-criteria/built-types/criteria/internal/InternalCriteriaFutureScheduledOpportunity.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/internal/InternalCriteriaFutureScheduledOpportunity.d.ts rename to packages/test-interface-criteria/built-types/criteria/internal/InternalCriteriaFutureScheduledOpportunity.d.ts diff --git a/packages/test-interface-criteria/built-types/src/criteria/sharedConstraints.d.ts b/packages/test-interface-criteria/built-types/criteria/sharedConstraints.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/criteria/sharedConstraints.d.ts rename to packages/test-interface-criteria/built-types/criteria/sharedConstraints.d.ts diff --git a/packages/test-interface-criteria/built-types/src/index.d.ts b/packages/test-interface-criteria/built-types/index.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/index.d.ts rename to packages/test-interface-criteria/built-types/index.d.ts diff --git a/packages/test-interface-criteria/built-types/test/testShapeDataMatchesConstraints.d.ts b/packages/test-interface-criteria/built-types/test/testShapeDataMatchesConstraints.d.ts deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/packages/test-interface-criteria/built-types/test/testShapeDataMatchesConstraints.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/test-interface-criteria/built-types/src/testDataShape.d.ts b/packages/test-interface-criteria/built-types/testDataShape.d.ts similarity index 100% rename from packages/test-interface-criteria/built-types/src/testDataShape.d.ts rename to packages/test-interface-criteria/built-types/testDataShape.d.ts diff --git a/packages/test-interface-criteria/package.json b/packages/test-interface-criteria/package.json index ec7bceb9be..b7ed6c1d0b 100644 --- a/packages/test-interface-criteria/package.json +++ b/packages/test-interface-criteria/package.json @@ -7,7 +7,7 @@ "scripts": { "gen-types-clean": "rimraf \"built-types/*\"", "gen-types-copy-d-ts-files": "copyfiles --up 1 \"src/**/*.d.ts\" built-types/", - "gen-types": "npm run gen-types-clean && tsc && npm run gen-types-copy-d-ts-files", + "gen-types": "npm run gen-types-clean && tsc --project tsconfig.gen.json && npm run gen-types-copy-d-ts-files", "run-tests": "jest", "test": "npm run lint && tsc && npm run run-tests", "lint": "eslint \"src/**/*.js\" \"test/**/*.js\"", diff --git a/packages/test-interface-criteria/tsconfig.gen.json b/packages/test-interface-criteria/tsconfig.gen.json new file mode 100644 index 0000000000..a86f064342 --- /dev/null +++ b/packages/test-interface-criteria/tsconfig.gen.json @@ -0,0 +1,16 @@ +/* For emitting .d.ts files, we use a separate tsconfig file, because we do not +want to emit .d.ts files for unit tests */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "built-types/", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.js" + // Exclude test files when generating + ] +} \ No newline at end of file diff --git a/packages/test-interface-criteria/tsconfig.json b/packages/test-interface-criteria/tsconfig.json index 857dc07425..8ebb57db26 100644 --- a/packages/test-interface-criteria/tsconfig.json +++ b/packages/test-interface-criteria/tsconfig.json @@ -1,18 +1,23 @@ +/* This tsconfig.json is used to check the types of all files in this library. +There is another, tsconfig.gen.json, which is used to emit .d.ts files for +clients of this library. These have to be separate because we cannot emit .d.ts +files for unit tests. +This one is the base because it encourages IDEs like VSCode to use it and thus +properly engage in type checking across all files. */ { "compilerOptions": { - "outDir": "built-types/", + "noEmit": true, "allowJs": true, "checkJs": true, "downlevelIteration": true, "target": "ES2019", "moduleResolution": "node", "resolveJsonModule": true, - "declaration": true, - "emitDeclarationOnly": true }, "include": [ "src/**/*.d.ts", "src/**/*.js", - "test/**/*.js" + "test/**/*.js", + "test/**/*.d.ts" ] } \ No newline at end of file