diff --git a/package.json b/package.json index f9bfae14..2cb0057b 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,13 @@ "axios": "^0.19.2", "currency-codes": "^1.5.1", "html-entities": "^1.3.1", + "iso8601-duration": "^1.3.0", "jsonpath": "^1.0.2", + "luxon": "^1.26.0", "lodash": "^4.17.21", "moment": "^2.24.0", - "rrule": "^2.6.2", + "moment-timezone": "^0.5.33", + "rrule": "2.6.4", "striptags": "^3.1.1", "uritemplate": "^0.3.4", "validator": "^10.11.0", diff --git a/src/errors/validation-error-type.js b/src/errors/validation-error-type.js index a2c2ccaf..83e4a809 100644 --- a/src/errors/validation-error-type.js +++ b/src/errors/validation-error-type.js @@ -40,6 +40,9 @@ const ValidationErrorType = { WRONG_BASE_TYPE: 'wrong_base_type', FIELD_NOT_ALLOWED: 'field_not_allowed', BELOW_MIN_VALUE_INCLUSIVE: 'below_min_value_inclusive', + URI_TEMPLATE_MISSING_PLACEHOLDER: 'uri_template_missing_placeholder', + EXCEPTION_DATES_NOT_IN_SCHEDULE: 'exception_dates_not_in_schedule', + INVALID_SCHEDULE_EVENT_TYPE: 'invalid_schedule_event_type', VALUE_OUTWITH_CONSTRAINT: 'value_outwith_constraint', INVALID_ID: 'invalid_id', FIELD_MUST_BE_ID_REFERENCE: 'FIELD_MUST_BE_ID_REFERENCE', diff --git a/src/helpers/datetime-helper.js b/src/helpers/datetime-helper.js new file mode 100644 index 00000000..206aa995 --- /dev/null +++ b/src/helpers/datetime-helper.js @@ -0,0 +1,13 @@ +const { DateTime } = require('luxon'); + +function getDateTime(ianaTimezone, dateString, timeString) { + // Node pulls the timezone from the system on initialisation using the TZ environment variable. + // We can change process.env.TZ to UTC. This will update the current Node process. + process.env.TZ = 'UTC'; + if (typeof dateString !== 'undefined' && typeof timeString !== 'undefined') { + return DateTime.fromISO(`${dateString}T${timeString}`, { zone: ianaTimezone }).toJSDate(); + } + return undefined; +} + +module.exports = getDateTime; diff --git a/src/helpers/frequency-converter.js b/src/helpers/frequency-converter.js new file mode 100644 index 00000000..fb463e66 --- /dev/null +++ b/src/helpers/frequency-converter.js @@ -0,0 +1,27 @@ +const { parse } = require('iso8601-duration'); +const { RRule } = require('rrule'); + +function getFrequency(repeatFrequency) { + if (typeof repeatFrequency !== 'undefined') { + const frequency = parse(repeatFrequency); + + if (frequency.hours !== 0) { + return { freq: RRule.HOURLY, interval: frequency.hours }; + } + if (frequency.days !== 0) { + return { freq: RRule.DAILY, interval: frequency.days }; + } + if (frequency.weeks !== 0) { + return { freq: RRule.WEEKLY, interval: frequency.weeks }; + } + if (frequency.months !== 0) { + return { freq: RRule.MONTHLY, interval: frequency.months }; + } + if (frequency.years !== 0) { + return { freq: RRule.YEARLY, interval: frequency.years }; + } + } + return { freq: undefined, interval: 0 }; +} + +module.exports = getFrequency; diff --git a/src/helpers/rrule-options.js b/src/helpers/rrule-options.js new file mode 100644 index 00000000..068ab6d8 --- /dev/null +++ b/src/helpers/rrule-options.js @@ -0,0 +1,39 @@ +const getDateTime = require('./datetime-helper'); + +function generateRRuleOptions(properties) { + const dtStart = getDateTime('UTC', properties.startDate, properties.startTime); + const dtEnd = getDateTime('UTC', properties.endDate, properties.endTime); + + const rruleOptions = {}; + + if (typeof properties.freq !== 'undefined') { + rruleOptions.freq = properties.freq; + } + if (typeof properties.interval !== 'undefined') { + rruleOptions.interval = properties.interval; + } + if (typeof dtStart !== 'undefined') { + rruleOptions.dtstart = dtStart; + } + if (typeof dtEnd !== 'undefined') { + rruleOptions.until = dtEnd; + } + if (typeof properties.byDay !== 'undefined') { + rruleOptions.byweekday = properties.byDay; + } + if (typeof properties.byMonth !== 'undefined') { + rruleOptions.bymonth = properties.byMonth; + } + if (typeof properties.byMonthDay !== 'undefined') { + rruleOptions.bymonthday = properties.byMonthDay; + } + if (typeof properties.count !== 'undefined') { + rruleOptions.count = properties.count; + } + if (typeof properties.scheduleTimezone !== 'undefined') { + rruleOptions.tzid = properties.scheduleTimezone; + } + return rruleOptions; +} + +module.exports = generateRRuleOptions; diff --git a/src/helpers/schedule-properties.js b/src/helpers/schedule-properties.js new file mode 100644 index 00000000..f732e2e7 --- /dev/null +++ b/src/helpers/schedule-properties.js @@ -0,0 +1,22 @@ +const getFrequency = require('./frequency-converter'); + +function getScheduleProperties(node) { + const { freq, interval } = getFrequency(node.getValue('repeatFrequency')); + const properties = { + freq, + interval, + byDay: node.getValue('byDay'), + byMonth: node.getValue('byMonth'), + byMonthDay: node.getValue('byMonthDay'), + startDate: node.getValue('startDate'), + startTime: node.getValue('startTime'), + endDate: node.getValue('endDate'), + endTime: node.getValue('endTime'), + count: node.getValue('count'), + scheduleTimezone: node.getValue('scheduleTimezone'), + exceptDate: node.getValue('exceptDate'), + }; + return properties; +} + +module.exports = getScheduleProperties; diff --git a/src/rules/data-quality/schedule-contains-recurrence-data-rule-spec.js b/src/rules/data-quality/schedule-contains-recurrence-data-rule-spec.js new file mode 100644 index 00000000..b6a3b509 --- /dev/null +++ b/src/rules/data-quality/schedule-contains-recurrence-data-rule-spec.js @@ -0,0 +1,131 @@ +const ValidRecurrenceRule = require('./schedule-contains-recurrence-data-rule'); +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +describe('ValidRecurrenceRule', () => { + const rule = new ValidRecurrenceRule(); + const model = new Model({ + type: 'Schedule', + fields: { + repeatFrequency: { + fieldname: 'byDay', + requiredType: 'https://schema.org/Duration', + }, + byDay: { + fieldname: 'byDay', + requiredType: 'ArrayOf#https://schema.org/DayOfWeek', + alternativeTypes: ['ArrayOf#https://schema.org/Text'], + }, + byMonth: { + fieldname: 'byMonth', + requiredType: 'https://schema.org/Integer', + }, + byMonthDay: { + fieldname: 'byMonthDay', + requiredType: 'https://schema.org/Integer', + }, + startDate: { + fieldname: 'startDate', + requiredType: 'https://schema.org/Date', + }, + EndDate: { + fieldname: 'EndDate', + requiredType: 'https://schema.org/Date', + }, + startTime: { + fieldname: 'startTime', + requiredType: 'https://schema.org/Time', + }, + EndTime: { + fieldname: 'EndTime', + requiredType: 'https://schema.org/Time', + }, + count: { + fieldname: 'count', + requiredType: 'https://schema.org/Integer', + }, + scheduleTimezone: { + fieldName: 'scheduleTimezone', + requiredType: 'https://schema.org/Text', + }, + }, + }, 'latest'); + + it('should target Schedule models', () => { + const isTargeted = rule.isModelTargeted(model); + expect(isTargeted).toBe(true); + }); + + it('should return errors when startDate is missing', async () => { + const data = { + '@type': 'Schedule', + startTime: '08:30', + endTime: '09:30', + scheduleTimezone: 'Europe/London', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(2); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.MISSING_REQUIRED_FIELD); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); + + it('should return errors when startTime is missing', async () => { + const data = { + '@type': 'Schedule', + startDate: '2021-03-19', + repeatFrequency: 'P1W', + count: 1, + scheduleTimezone: 'Europe/London', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(2); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.MISSING_REQUIRED_FIELD); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); + + it('should not return errors when there are sufficent properties to build a valid recurrence rule', async () => { + const data = { + '@type': 'Schedule', + startDate: '2021-03-19', + startTime: '08:30', + repeatFrequency: 'P1W', + count: 1, + scheduleTimezone: 'Europe/London', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(0); + }); +}); diff --git a/src/rules/data-quality/schedule-contains-recurrence-data-rule.js b/src/rules/data-quality/schedule-contains-recurrence-data-rule.js new file mode 100644 index 00000000..de4de825 --- /dev/null +++ b/src/rules/data-quality/schedule-contains-recurrence-data-rule.js @@ -0,0 +1,94 @@ +const { RRule } = require('rrule'); +const Rule = require('../rule'); +const generateRRuleOptions = require('../../helpers/rrule-options'); +const getScheduleProperties = require('../../helpers/schedule-properties'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +module.exports = class ValidRecurrenceRule extends Rule { + constructor(options) { + super(options); + this.targetModels = ['Schedule']; + this.meta = { + name: 'ValidRecurrenceRule', + description: + 'Validates that the Schedule contains the correct information to generate a valid iCal recurrence rule.', + tests: { + matchingFirstEvent: { + message: + 'The first event that is generated by the `Schedule` ({{firstEvent}}) does not match the `startDate` ({{startDate}}) and `startTime` ({{startTime}}).', + sampleValues: { + startTime: '08:30', + startDate: '2021-03-19', + firstEvent: '2021-03-20T09:40:00Z', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.MISSING_REQUIRED_FIELD, + }, + rruleCreation: { + message: + 'There was an error generating the RRule from the data provided. Error: {{error}}', + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.MISSING_REQUIRED_FIELD, + }, + dtStart: { + message: + 'The recurrence rule must contain a `startDate`, `startTime`, and `scheduledTimezone` to generate the schedule.', + sampleValues: { + startTime: '08:30', + startDate: '2021-03-19', + scheduleTimezone: 'Europe/London', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.MISSING_REQUIRED_FIELD, + }, + }, + }; + } + + validateModel(node) { + const errors = []; + + const properties = getScheduleProperties(node); + const rruleOptions = generateRRuleOptions(properties); + + if (typeof properties.startDate === 'undefined' + || typeof properties.startTime === 'undefined' + || typeof properties.scheduleTimezone === 'undefined') { + errors.push( + this.createError('dtStart', { + value: undefined, + path: node, + }), + ); + } + + try { + const rule = new RRule(rruleOptions); + const firstEvent = rule.all()[0]; + if (firstEvent.getTime() !== rruleOptions.dtstart.getTime()) { + errors.push( + this.createError('matchingFirstEvent', { + startDate: properties.startDate, + startTime: properties.startTime, + firstEvent, + path: node, + }), + ); + } + } catch (error) { + errors.push( + this.createError('rruleCreation', { + error, + path: node, + }), + ); + } + + return errors; + } +}; diff --git a/src/rules/data-quality/schedule-exceptdates-match-recurrence-dates-spec.js b/src/rules/data-quality/schedule-exceptdates-match-recurrence-dates-spec.js new file mode 100644 index 00000000..06dca867 --- /dev/null +++ b/src/rules/data-quality/schedule-exceptdates-match-recurrence-dates-spec.js @@ -0,0 +1,127 @@ +const ExceptDatesAreInSchedule = require('./schedule-exceptdates-match-recurrence-dates'); +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +describe('ExceptDatesAreInSchedule', () => { + const rule = new ExceptDatesAreInSchedule(); + const model = new Model({ + type: 'Schedule', + fields: { + repeatFrequency: { + fieldname: 'byDay', + requiredType: 'https://schema.org/Duration', + }, + byDay: { + fieldname: 'byDay', + requiredType: 'ArrayOf#https://schema.org/DayOfWeek', + alternativeTypes: ['ArrayOf#https://schema.org/Text'], + }, + byMonth: { + fieldname: 'byMonth', + requiredType: 'https://schema.org/Integer', + }, + byMonthDay: { + fieldname: 'byMonthDay', + requiredType: 'https://schema.org/Integer', + }, + startDate: { + fieldname: 'startDate', + requiredType: 'https://schema.org/Date', + }, + EndDate: { + fieldname: 'EndDate', + requiredType: 'https://schema.org/Date', + }, + startTime: { + fieldname: 'startTime', + requiredType: 'https://schema.org/Time', + }, + EndTime: { + fieldname: 'EndTime', + requiredType: 'https://schema.org/Time', + }, + count: { + fieldname: 'count', + requiredType: 'https://schema.org/Integer', + }, + scheduleTimezone: { + fieldName: 'scheduleTimezone', + requiredType: 'https://schema.org/Text', + }, + exceptDate: { + fieldName: 'exceptDate', + requiredType: 'ArrayOf#https://schema.org/DateTime', + alternativeTypes: ['ArrayOf#https://schema.org/Date'], + + }, + }, + }, 'latest'); + + it('should target Schedule models', () => { + const isTargeted = rule.isModelTargeted(model); + expect(isTargeted).toBe(true); + }); + + it('should return errors when exceptDate values are outside the recurrence rule series', async () => { + const data = { + '@type': 'Schedule', + startDate: '2021-03-19', + startTime: '08:30', + repeatFrequency: 'P1W', + count: 10, + exceptDate: ['2021-03-27T08:30:00Z'], + scheduleTimezone: 'Europe/London', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.EXCEPTION_DATES_NOT_IN_SCHEDULE); + expect(error.severity).toBe(ValidationErrorSeverity.WARNING); + } + }); + + it('should not return errors when the exceptDate is within the recurrence rule schedule', async () => { + const data = { + '@type': 'Schedule', + startDate: '2021-02-18', + startTime: '07:30', + count: 20, + repeatFrequency: 'P1W', + exceptDate: [ + '2021-02-18T07:30:00.000Z', + '2021-02-25T07:30:00.000Z', + '2021-03-04T07:30:00.000Z', + '2021-03-11T07:30:00.000Z', + '2021-03-18T07:30:00.000Z', + '2021-03-25T07:30:00.000Z', + '2021-04-01T06:30:00.000Z', + '2021-04-08T06:30:00.000Z', + '2021-04-15T06:30:00.000Z', + '2021-04-22T06:30:00.000Z', + ], + scheduleTimezone: 'Europe/London', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(0); + }); +}); diff --git a/src/rules/data-quality/schedule-exceptdates-match-recurrence-dates.js b/src/rules/data-quality/schedule-exceptdates-match-recurrence-dates.js new file mode 100644 index 00000000..9c7eca3c --- /dev/null +++ b/src/rules/data-quality/schedule-exceptdates-match-recurrence-dates.js @@ -0,0 +1,65 @@ +const { RRule } = require('rrule'); +const Rule = require('../rule'); +const generateRRuleOptions = require('../../helpers/rrule-options'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); +const getScheduleProperties = require('../../helpers/schedule-properties'); + +module.exports = class ExceptDatesAreInSchedule extends Rule { + constructor(options) { + super(options); + this.targetModels = ['Schedule']; + this.meta = { + name: 'ExceptDatesAreInSchedule', + description: + 'Validates that the Schedule contains exceptDates that are part of the recurrence rule.', + tests: { + exDate: { + message: + '{{date}} must be one of the events generated by the recurrence rule - {{allEvents}}', + sampleValues: { + value: '2020-03-23T12:30:00Z', + allEvents: ['202-03-16T12:30:00Z', '202-03-23T12:30:00Z', '202-03-30T12:30:00Z'], + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.WARNING, + type: ValidationErrorType.EXCEPTION_DATES_NOT_IN_SCHEDULE, + }, + }, + }; + } + + validateModel(node) { + const errors = []; + + const properties = getScheduleProperties(node); + const rruleOptions = generateRRuleOptions(properties); + + if (typeof properties.exceptDate === 'undefined') { + return []; + } + + try { + const rule = new RRule(rruleOptions); + const allEvents = rule.all(); + const simplifiedAllEvents = allEvents.map(event => event.getTime()); + for (const date of properties.exceptDate) { + const simplifiedDate = new Date(date).getTime(); + if (!simplifiedAllEvents.includes(simplifiedDate)) { + errors.push( + this.createError('exDate', { + date, + allEvents, + path: node, + }), + ); + } + } + } catch (error) { + return []; + } + + return errors; + } +}; diff --git a/src/rules/data-quality/schedule-templates-are-valid-rule-spec.js b/src/rules/data-quality/schedule-templates-are-valid-rule-spec.js new file mode 100644 index 00000000..afe15056 --- /dev/null +++ b/src/rules/data-quality/schedule-templates-are-valid-rule-spec.js @@ -0,0 +1,127 @@ +const ScheduleTemplatesValid = require('./schedule-templates-are-valid-rule'); +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +describe('ScheduleTemplatesValid', () => { + let model; + let rule; + + beforeEach(() => { + model = new Model({ + type: 'Schedule', + fields: { + idTemplate: { + fieldName: 'idTemplate', + sameAs: 'https://openactive.io/idTemplate', + requiredType: 'https://schema.org/Text', + example: 'https://api.example.org/session-series/123/{startDate}', + description: [ + 'An RFC6570 compliant URI template that can be used to generate a unique identifier (`@id`) for every event described by the schedule. This property is required if the data provider is supporting third-party booking via the Open Booking API, or providing complimentary individual `subEvent`s.', + ], + valueConstraint: 'UriTemplate', + }, + urlTemplate: { + fieldName: 'urlTemplate', + sameAs: 'https://schema.org/urlTemplate', + requiredType: 'https://schema.org/Text', + example: 'https://example.org/session-series/123/{startDate}', + description: [ + 'An RFC6570 compliant URI template that can be used to generate a unique `url` for every event described by the schedule. This property is required if the data provider wants to provide participants with a unique URL to book to attend an event.', + ], + valueConstraint: 'UriTemplate', + }, + }, + }, 'latest'); + rule = new ScheduleTemplatesValid(); + }); + + it('should target idTemplate and urlTemplate in Schedule model', () => { + let isTargeted = rule.isFieldTargeted(model, 'idTemplate'); + expect(isTargeted).toBe(true); + + isTargeted = rule.isFieldTargeted(model, 'urlTemplate'); + expect(isTargeted).toBe(true); + }); + + it('should return no errors if the urlTemplate is valid', async () => { + const data = { + '@type': 'Schedule', + urlTemplate: 'https://api.example.org/session-series/123/{startDate}', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(0); + }); + + it('should return no errors if the idTemplate is valid', async () => { + const data = { + '@type': 'Schedule', + idTemplate: 'https://api.example.org/session-series/123/{startDate}', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(0); + }); + + it('should return errors if the urlTemplate is not valid', async () => { + const data = { + '@type': 'Schedule', + urlTemplate: 'htts://api.example.org/session-series/123/', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.INVALID_FORMAT); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); + + it('should return errors if the idTemplate is not valid', async () => { + const data = { + '@type': 'Schedule', + idTemplate: 'htts://api.example.org/session-series/123/', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.INVALID_FORMAT); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); +}); diff --git a/src/rules/data-quality/schedule-templates-are-valid-rule.js b/src/rules/data-quality/schedule-templates-are-valid-rule.js new file mode 100644 index 00000000..17f7e786 --- /dev/null +++ b/src/rules/data-quality/schedule-templates-are-valid-rule.js @@ -0,0 +1,52 @@ +const Rule = require('../rule'); +const PropertyHelper = require('../../helpers/property'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +module.exports = class ScheduleTemplatesValid extends Rule { + constructor(options) { + super(options); + this.targetFields = { Schedule: ['urlTemplate', 'idTemplate'] }; + this.meta = { + name: 'ScheduleTemplatesValid', + description: 'Validates that Schedule urlTemplate and idTemplate fields are the correct format', + tests: { + default: { + description: 'Validates that Schedule idTemplate or urlTemplate fields are in the correct format.', + message: 'The field must contain a valid resource identifier. For example `{startDate}` in `https://api.example.org/session-series/123/{startDate}`', + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.INVALID_FORMAT, + }, + }, + }; + } + + validateField(node, field) { + const fieldObj = node.model.getField(field); + const fieldValue = node.getValue(field); + + if (typeof fieldValue !== 'string') { + return []; + } + + const errors = []; + + if (typeof fieldObj.valueConstraint !== 'undefined' + && (fieldObj.valueConstraint === 'UriTemplate' + && !PropertyHelper.isUrlTemplate(fieldValue))) { + errors.push( + this.createError( + 'default', + { + fieldValue, + path: node.getPath(field), + }, + ), + ); + } + + return errors; + } +}; diff --git a/src/rules/data-quality/schedule-templates-contain-startdate-rule-spec.js b/src/rules/data-quality/schedule-templates-contain-startdate-rule-spec.js new file mode 100644 index 00000000..d7b54b16 --- /dev/null +++ b/src/rules/data-quality/schedule-templates-contain-startdate-rule-spec.js @@ -0,0 +1,127 @@ +const ScheduleTemplatesContainStartDate = require('./schedule-templates-contain-startdate-rule'); +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +describe('ScheduleTemplatesContainStartDate', () => { + let model; + let rule; + + beforeEach(() => { + model = new Model({ + type: 'Schedule', + fields: { + idTemplate: { + fieldName: 'idTemplate', + sameAs: 'https://openactive.io/idTemplate', + requiredType: 'https://schema.org/Text', + example: 'https://api.example.org/session-series/123/{startDate}', + description: [ + 'An RFC6570 compliant URI template that can be used to generate a unique identifier (`@id`) for every event described by the schedule. This property is required if the data provider is supporting third-party booking via the Open Booking API, or providing complimentary individual `subEvent`s.', + ], + valueConstraint: 'UriTemplate', + }, + urlTemplate: { + fieldName: 'urlTemplate', + sameAs: 'https://schema.org/urlTemplate', + requiredType: 'https://schema.org/Text', + example: 'https://example.org/session-series/123/{startDate}', + description: [ + 'An RFC6570 compliant URI template that can be used to generate a unique `url` for every event described by the schedule. This property is required if the data provider wants to provide participants with a unique URL to book to attend an event.', + ], + valueConstraint: 'UriTemplate', + }, + }, + }, 'latest'); + rule = new ScheduleTemplatesContainStartDate(); + }); + + it('should target idTemplate and urlTemplate in Schedule model', () => { + let isTargeted = rule.isFieldTargeted(model, 'idTemplate'); + expect(isTargeted).toBe(true); + + isTargeted = rule.isFieldTargeted(model, 'urlTemplate'); + expect(isTargeted).toBe(true); + }); + + it('should return no errors if the urlTemplate contains {startDate}', async () => { + const data = { + '@type': 'Schedule', + urlTemplate: 'https://api.example.org/session-series/123/{startDate}', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(0); + }); + + it('should return no errors if the idTemplate contains {startDate}', async () => { + const data = { + '@type': 'Schedule', + idTemplate: 'https://api.example.org/session-series/123/{startDate}', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(0); + }); + + it('should return errors if the urlTemplate does not contain {startDate}', async () => { + const data = { + '@type': 'Schedule', + urlTemplate: 'htts://api.example.org/session-series/123/', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.URI_TEMPLATE_MISSING_PLACEHOLDER); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); + + it('should return errors if the idTemplate does not contain {startDate}', async () => { + const data = { + '@type': 'Schedule', + idTemplate: 'htts://api.example.org/session-series/123/', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.URI_TEMPLATE_MISSING_PLACEHOLDER); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); +}); diff --git a/src/rules/data-quality/schedule-templates-contain-startdate-rule.js b/src/rules/data-quality/schedule-templates-contain-startdate-rule.js new file mode 100644 index 00000000..3439e1f5 --- /dev/null +++ b/src/rules/data-quality/schedule-templates-contain-startdate-rule.js @@ -0,0 +1,48 @@ +const Rule = require('../rule'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +module.exports = class ScheduleTemplatesContainStartDate extends Rule { + constructor(options) { + super(options); + this.targetFields = { Schedule: ['urlTemplate', 'idTemplate'] }; + this.meta = { + name: 'ScheduleTemplatesContainStartDate', + description: 'Validates that the urlTemplate or idTemplate fields in a Schedule model contain the {startDate} placeholder.', + tests: { + default: { + description: 'Validates that the urlTemplate or idTemplate fields in a Schedule model contain the {startDate} placeholder.', + message: 'The urlTemplate or idTemplate field in a Schedule model must contain the {startDate} placeholder, e.g. `https://api.example.org/session-series/123/{startDate}`', + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.URI_TEMPLATE_MISSING_PLACEHOLDER, + }, + }, + }; + } + + validateField(node, field) { + const fieldValue = node.getValue(field); + + if (typeof fieldValue !== 'string') { + return []; + } + + const errors = []; + + if (!fieldValue.includes('{startDate}')) { + errors.push( + this.createError( + 'default', + { + fieldValue, + path: node.getPath(field), + }, + ), + ); + } + + return errors; + } +}; diff --git a/src/rules/data-quality/scheduleeventype-is-valid-event-subclass-rule-spec.js b/src/rules/data-quality/scheduleeventype-is-valid-event-subclass-rule-spec.js new file mode 100644 index 00000000..e894d3cd --- /dev/null +++ b/src/rules/data-quality/scheduleeventype-is-valid-event-subclass-rule-spec.js @@ -0,0 +1,118 @@ +const ScheduleEventTypeIsEventSubclass = require('./scheduleeventype-is-valid-event-subclass-rule'); +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +describe('ScheduleEventTypeIsEventSubclass', () => { + let rule; + + beforeEach(() => { + rule = new ScheduleEventTypeIsEventSubclass(); + }); + + it('should target models of any type', () => { + const model = new Model({ + type: 'Schedule', + }, 'latest'); + + const isTargeted = rule.isModelTargeted(model); + expect(isTargeted).toBe(true); + }); + + it('should return no errors if scheduleEventType is a subClass of Event', async () => { + const model = new Model({ + type: 'Schedule', + subClassGraph: ['#Event'], + }, 'latest'); + + const data = { + scheduledEventType: 'ScheduledSession', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(0); + }); + + it('should return errors if scheduleEventType is not a subClass of Event', async () => { + const model = new Model({ + type: 'Schedule', + subClassGraph: ['#Event'], + }, 'latest'); + + const data = { + scheduledEventType: 'Place', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.INVALID_SCHEDULE_EVENT_TYPE); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); + + it('should return errors if scheduleEventType does not have a valid model', async () => { + const model = new Model({ + type: 'Schedule', + subClassGraph: ['#Event'], + }, 'latest'); + + const data = { + scheduledEventType: 'Banana', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.INVALID_SCHEDULE_EVENT_TYPE); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); + + it('should not errors if scheduleEventType does not have a subClassGraph', async () => { + const model = new Model({ + type: 'Schedule', + subClassGraph: ['#Event'], + }, 'latest'); + + const data = { + scheduledEventType: 'DownloadData', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.INVALID_SCHEDULE_EVENT_TYPE); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); +}); diff --git a/src/rules/data-quality/scheduleeventype-is-valid-event-subclass-rule.js b/src/rules/data-quality/scheduleeventype-is-valid-event-subclass-rule.js new file mode 100644 index 00000000..7951b066 --- /dev/null +++ b/src/rules/data-quality/scheduleeventype-is-valid-event-subclass-rule.js @@ -0,0 +1,80 @@ +const Rule = require('../rule'); +const DataModelHelper = require('../../helpers/data-model'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +module.exports = class ScheduleEventTypeIsEventSubclass extends Rule { + constructor(options) { + super(options); + this.targetModels = 'Schedule'; + this.meta = { + name: 'ScheduleEventTypeIsEventSubclass', + description: 'The `scheduleEventType` in the `Schedule` is not a subclass of `Event`.', + tests: { + modelNotRecognised: { + message: 'The model described by `scheduleEventType` ({{value}}) is not a valid model type. See the [Inheritence Overview](https://developer.openactive.io/publishing-data/data-feeds/types-of-feed#schema-org-type-inheritance-overview) for more information.', + sampleValues: { + value: 'BigSwimEvent', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.INVALID_SCHEDULE_EVENT_TYPE, + }, + modelHasNoSubClassGraph: { + message: 'The model described by `scheduleEventType` ({{value}}) does not list any subclass types. See the [Inheritence Overview](https://developer.openactive.io/publishing-data/data-feeds/types-of-feed#schema-org-type-inheritance-overview) for more information.', + sampleValues: { + value: 'DownloadData', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.INVALID_SCHEDULE_EVENT_TYPE, + }, + modelIsNotEventSubClass: { + message: 'The `scheduleEventType` ({{value}}) in `Schedule` does not inherit from `Event`. See the [Inheritence Overview](https://developer.openactive.io/publishing-data/data-feeds/types-of-feed#schema-org-type-inheritance-overview) for more information.', + sampleValues: { + value: 'ScheduledSession', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.INVALID_SCHEDULE_EVENT_TYPE, + }, + }, + }; + } + + validateModel(node) { + const errors = []; + let model; + let errorCondition; + const scheduledEventType = node.getValue('scheduledEventType'); + + try { + model = DataModelHelper.loadModel(scheduledEventType, 'latest'); + } catch (error) { + model = undefined; + } + + if (typeof model === 'undefined') { + errorCondition = 'modelNotRecognised'; + } else if (typeof model.subClassGraph === 'undefined') { + errorCondition = 'modelHasNoSubClassGraph'; + } else if (model.subClassGraph.indexOf('#Event') === -1) { + errorCondition = 'modelIsNotEventSubClass'; + } + + if (errorCondition) { + errors.push( + this.createError( + errorCondition, + { + value: scheduledEventType, + path: node.getPath('scheduledEventType'), + }, + ), + ); + } + + return errors; + } +}; diff --git a/src/rules/data-quality/scheduletimezone-in-partialschedule-rule-spec.js b/src/rules/data-quality/scheduletimezone-in-partialschedule-rule-spec.js new file mode 100644 index 00000000..9b09a96c --- /dev/null +++ b/src/rules/data-quality/scheduletimezone-in-partialschedule-rule-spec.js @@ -0,0 +1,74 @@ +const TimezoneInPartialSchedule = require('./scheduletimezone-in-partialschedule-rule'); +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +describe('TimezoneInPartialSchedule', () => { + const rule = new TimezoneInPartialSchedule(); + const model = new Model({ + type: 'PartialSchedule', + fields: { + startTime: { + fieldName: 'startTime', + requiredType: 'https://schema.org/Time', + }, + endTime: { + fieldName: 'endTime', + requiredType: 'https://schema.org/Time', + }, + scheduleTimezone: { + fieldName: 'scheduleTimezone', + requiredType: 'https://schema.org/Text', + }, + }, + }, 'latest'); + + it('should target PartialSchedule models', () => { + const isTargeted = rule.isModelTargeted(model); + expect(isTargeted).toBe(true); + }); + + it('should not return errors when startTime, endTime, and scheduleTimezone are present', async () => { + const data = { + '@type': 'PartialSchedule', + startTime: '08:30', + endTime: '09:30', + scheduleTimezone: 'Europe/London', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(0); + }); + + it('should return errors when startTime and endTime are present, but scheduleTimezone is not', async () => { + const data = { + '@type': 'PartialSchedule', + startTime: '08:30', + endTime: '09:30', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.MISSING_REQUIRED_FIELD); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); +}); diff --git a/src/rules/data-quality/scheduletimezone-in-partialschedule-rule.js b/src/rules/data-quality/scheduletimezone-in-partialschedule-rule.js new file mode 100644 index 00000000..66124b3f --- /dev/null +++ b/src/rules/data-quality/scheduletimezone-in-partialschedule-rule.js @@ -0,0 +1,54 @@ +const Rule = require('../rule'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +module.exports = class TimezoneInPartialSchedule extends Rule { + constructor(options) { + super(options); + this.targetModels = ['PartialSchedule']; + this.meta = { + name: 'TimezoneInPartialSchedule', + description: 'Validates that `scheduleTimezone` is present when `startTime` or `endTime` are present.', + tests: { + default: { + message: '`scheduleTimezone` must be present when `startTime` or `endTime` are present', + sampleValues: { + field: 'scheduleTimezone', + allowedValues: 'Europe/London', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.MISSING_REQUIRED_FIELD, + }, + }, + }; + } + + validateModel(node) { + const startTime = node.getValue('startTime'); + const endTime = node.getValue('endTime'); + const scheduleTimezone = node.getValue('scheduleTimezone'); + + if (typeof startTime === 'undefined' + && typeof endTime === 'undefined' + ) { + return []; + } + const errors = []; + + if (typeof scheduleTimezone === 'undefined') { + errors.push( + this.createError( + 'default', + { + scheduleTimezone, + path: node.getPath('scheduleTimezone'), + }, + ), + ); + } + + return errors; + } +}; diff --git a/src/rules/format/scheduletimezone-format-rule-spec.js b/src/rules/format/scheduletimezone-format-rule-spec.js new file mode 100644 index 00000000..e378f996 --- /dev/null +++ b/src/rules/format/scheduletimezone-format-rule-spec.js @@ -0,0 +1,63 @@ +const ScheduleTimezoneMatchesIANAList = require('./scheduletimezone-format-rule'); +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +describe('ScheduleTimezoneMatchesIANAList', () => { + const rule = new ScheduleTimezoneMatchesIANAList(); + const model = new Model({ + type: 'PartialSchedule', + fields: { + scheduleTimezone: { + fieldName: 'scheduleTimezone', + requiredType: 'https://schema.org/Text', + }, + }, + }, 'latest'); + + it('should target PartialSchedule models', () => { + const isTargeted = rule.isModelTargeted(model); + expect(isTargeted).toBe(true); + }); + + it('should not return errors when scheduleTimezone is in the IANA database list', async () => { + const data = { + '@type': 'PartialSchedule', + scheduleTimezone: 'Europe/London', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(0); + }); + + it('should return errors when scheduleTimezone is not in the IANA database list', async () => { + const data = { + '@type': 'PartialSchedule', + scheduleTimezone: 'Europe/Slough', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.MISSING_REQUIRED_FIELD); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); +}); diff --git a/src/rules/format/scheduletimezone-format-rule.js b/src/rules/format/scheduletimezone-format-rule.js new file mode 100644 index 00000000..2eb412bc --- /dev/null +++ b/src/rules/format/scheduletimezone-format-rule.js @@ -0,0 +1,52 @@ +const momentTZ = require('moment-timezone'); +const Rule = require('../rule'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +module.exports = class ScheduleTimezoneMatchesIANAList extends Rule { + constructor(options) { + super(options); + this.targetModels = ['Schedule', 'PartialSchedule']; + this.meta = { + name: 'ScheduleTimezoneMatchesIANAList', + description: 'Validates that scheduleTimezone matches an IANA Timezone.', + tests: { + default: { + message: 'scheduleTimezone must be one of the timezones contained in the [IANA Timezone database](https://www.iana.org/time-zones)', + sampleValues: { + field: 'scheduleTimezone', + allowedValues: 'Europe/London', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.MISSING_REQUIRED_FIELD, + }, + }, + }; + } + + validateModel(node) { + const timezoneList = momentTZ.tz.names(); + const scheduleTimezone = node.getValue('scheduleTimezone'); + + if (typeof scheduleTimezone === 'undefined') { + return []; + } + const errors = []; + + if (!timezoneList.includes(scheduleTimezone)) { + errors.push( + this.createError( + 'default', + { + scheduleTimezone, + path: node.getPath('scheduleTimezone'), + }, + ), + ); + } + + return errors; + } +};