Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

365 - schedule validation #383

Open
wants to merge 33 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
41d1c19
data-quality: Add rule for scheduledTimezone in partialSchedule
Mar 17, 2021
b24a116
data-quality: Remove dangling comment
Mar 17, 2021
cedb66c
data-quality: Add rule for repeatCount is positive integer
Mar 17, 2021
cbe7f3a
package: Install moment-timezone
Mar 17, 2021
7f0725d
data-quality: Add rule to ensure timezone matches IANA list
Mar 17, 2021
a9dc16a
tests: rename import
Mar 17, 2021
26d0ffa
rules: Refactor repeatCount rule to use minValueInclusive
Mar 19, 2021
b1f4a3e
data-quality: Ensure Schedule templates (id & url) are valid UriTempl…
Mar 24, 2021
be6d53d
data-quality: schedule-templates: Add ensure {startDate} rule
Mar 24, 2021
b0ff0fb
data-quality: Add rule to check for recurrence data in schedule
Mar 19, 2021
8d6707a
data-quality: schedule-exception-dates: Add rule that checks if excep…
Mar 26, 2021
c397c08
data-helpers: refactor tests to use generateRRuleOptions
Mar 26, 2021
876e0f0
data-helpers: Remove old logging
Mar 26, 2021
5bc8b22
helpers: Separate concerns between helper files
Mar 26, 2021
0402176
data-quality: repeatCount rule now superseded by minValueInclusive ru…
Mar 31, 2021
76ea071
data-quality: schedules: Update messages and rule tests
Mar 31, 2021
e1312f8
rebase: Resolve duplicate
Mar 31, 2021
7d4b784
data-quality: recurrence rule: Update tests to include more information
Mar 31, 2021
d729577
data-quality: Update exception date rule test message
Mar 31, 2021
d9ec6f0
validation types: Correct case for consistency
Mar 31, 2021
4c7f8a9
data-quality: schedule: Exception dates error type update
Mar 31, 2021
4bb1e75
data-quality: schedule: Check scheduleEventType is a valid event subC…
Apr 1, 2021
95c782f
data-quality: schedule: Catch errors when the model or subclassgraph …
Apr 1, 2021
aff4abc
data-quality: schedules: Convert to UTC for RRule
Apr 5, 2021
a758210
helpers: Refactor date function calls
Apr 5, 2021
f867830
helpers: Ensure node uses UTC envvar for datetime functions
Apr 5, 2021
6823bf8
helpers: Set node process timezone
Apr 8, 2021
df598b8
data-quality: schedule: Use daylight savings shift for test
Apr 8, 2021
ac3566e
package.json: Fix rrule version
Apr 8, 2021
dc3ee61
helpers: Update datetime function
Apr 8, 2021
aba2103
schedule rule: spec: Simplify count
Apr 9, 2021
baf2035
Merge branch 'master' into 365/schedule-validation
nickevansuk May 10, 2021
46f84cd
Merge branch 'master' into 365/schedule-validation
nickevansuk Aug 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,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",
Expand Down
3 changes: 3 additions & 0 deletions src/errors/validation-error-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 13 additions & 0 deletions src/helpers/datetime-helper.js
Original file line number Diff line number Diff line change
@@ -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;
27 changes: 27 additions & 0 deletions src/helpers/frequency-converter.js
Original file line number Diff line number Diff line change
@@ -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;
39 changes: 39 additions & 0 deletions src/helpers/rrule-options.js
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 22 additions & 0 deletions src/helpers/schedule-properties.js
Original file line number Diff line number Diff line change
@@ -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;
131 changes: 131 additions & 0 deletions src/rules/data-quality/schedule-contains-recurrence-data-rule-spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
94 changes: 94 additions & 0 deletions src/rules/data-quality/schedule-contains-recurrence-data-rule.js
Original file line number Diff line number Diff line change
@@ -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',
Lathrisk marked this conversation as resolved.
Show resolved Hide resolved
},
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;
}
};
Loading
Loading