From 0d70a9880a712a087fc284fdbdac9f062041bfdb Mon Sep 17 00:00:00 2001 From: Gareth Bowen Date: Sun, 3 Nov 2024 13:55:32 +0100 Subject: [PATCH] fix(#8806): merge extra validations with pupil so rules can have both #8806 #8402 --- .../test/unit/pregnancy_registration.js | 8 +- shared-libs/validation/src/pupil.js | 54 ++-- shared-libs/validation/src/validation.js | 304 ++---------------- .../validation/src/validation_result.js | 30 -- .../validation/src/validation_utils.js | 136 ++++++++ shared-libs/validation/src/validator.js | 12 +- .../validation/src/validator_functions.js | 53 ++- shared-libs/validation/test/validations.js | 238 +++++++++++++- 8 files changed, 460 insertions(+), 375 deletions(-) delete mode 100644 shared-libs/validation/src/validation_result.js create mode 100644 shared-libs/validation/src/validation_utils.js diff --git a/shared-libs/transitions/test/unit/pregnancy_registration.js b/shared-libs/transitions/test/unit/pregnancy_registration.js index badc6678b34..d1ea05fa3f0 100644 --- a/shared-libs/transitions/test/unit/pregnancy_registration.js +++ b/shared-libs/transitions/test/unit/pregnancy_registration.js @@ -379,7 +379,7 @@ describe('pregnancy registration with weeks since LMP', () => { return transition.onMatch({ doc: doc }).then(function(changed) { assert.equal(changed, true); assert.equal(doc.patient_id, undefined); - assert.equal(getMessage(doc), 'Invalid patient name. Invalid LMP; must be between 0-40 weeks.'); + assert.equal(getMessage(doc), 'Invalid LMP; must be between 0-40 weeks. Invalid patient name.'); }); }); @@ -584,9 +584,9 @@ describe('pregnancy registration with exact LMP date', () => { assert.equal(doc.patient_id, undefined); assert.equal(doc.lmp_date, null); assert.equal(getMessage(doc), - 'Invalid patient name. ' + - ' Date should be later than 40 weeks ago. ' + - ' Date should be older than 8 weeks ago.'); + 'Date should be later than 40 weeks ago. ' + + ' Date should be older than 8 weeks ago. ' + + ' Invalid patient name.'); }); }); diff --git a/shared-libs/validation/src/pupil.js b/shared-libs/validation/src/pupil.js index 2d990478326..6bff0ec2d8b 100644 --- a/shared-libs/validation/src/pupil.js +++ b/shared-libs/validation/src/pupil.js @@ -1,53 +1,37 @@ -const validator_functions = require('./validator_functions.js'); -const validation_result = require('./validation_result.js'); - const lexer = require('./lexer.js'); const parser = require('./parser.js'); const validator = require('./validator.js'); const ruleCache = {}; -const addFunction = function(name, callable) { - validator_functions[name.toLowerCase()] = callable; +const getEntities = (rule) => { + if (!ruleCache[rule]) { + const tokens = lexer.tokenize(rule); + const entities = parser.parse(tokens); + ruleCache[rule] = entities; + } + return ruleCache[rule]; }; -const validate = function(rules, values) { - const results = {}; - - // Start by defaulting all given values' validation results to "passing" - Object.keys(values).forEach((key) => { - results[key] = true; - }); - - // And then run the rules - Object.keys(rules).forEach((index) => { - if (typeof values[index] === 'undefined' || values[index] === null) { - values[index] = ''; - } - - const rule = rules[index]; - let tokens; - let entities; - - if (ruleCache[rule]) { - entities = ruleCache[rule]; - } else { - tokens = lexer.tokenize(rule); - entities = parser.parse(tokens); +const validate = async function(validations, values) { + const results = []; - ruleCache[rule] = entities; + for (const validation of validations) { + const key = validation.property; + if (typeof values[key] === 'undefined' || values[key] === null) { + values[key] = ''; } - results[index] = validator.validate(entities, values, index); - }); + const rule = validation.rule; + const entities = getEntities(rule); + const valid = await validator.validate(entities, values, key); + results.push({ valid, validation }); + } - return validation_result.create(results); + return results; }; module.exports = { - addFunction, - lexer, - parser, validate }; diff --git a/shared-libs/validation/src/validation.js b/shared-libs/validation/src/validation.js index e29a182e336..84165b3420c 100644 --- a/shared-libs/validation/src/validation.js +++ b/shared-libs/validation/src/validation.js @@ -1,147 +1,32 @@ -const _ = require('lodash/core'); -const moment = require('moment'); -const messages = require('@medic/message-utils'); -const phoneNumberParser = require('@medic/phone-number'); -const logger = require('@medic/logger'); -const config = require('../../transitions/src/config'); +const messageUtils = require('@medic/message-utils'); const pupil = require('./pupil'); +const validationUtils = require('./validation_utils'); let db; let settings; let translate; let inited = false; -const _parseDuration = (duration) => { - const parts = duration.split(' '); - return moment.duration(parseInt(parts[0]), parts[1]); -}; - -const _getIntersection = responses => { - let ids = responses.pop().rows.map(row => row.id); - responses.forEach(response => { - ids = ids.filter(id => _.find(response.rows, { id: id })); - }); - return ids; -}; - -const _executeExistsRequest = (options) => { - return db.medic.query('medic-client/reports_by_freetext', options); -}; - -const lowerCaseString = obj => typeof obj === 'string' ? obj.toLowerCase() : obj; - -const _exists = (doc, fields, options = {}) => { - if (!fields.length) { - return Promise.reject('No arguments provided to "exists" validation function'); - } - - const requestOptions = fields.map(field => { - return { key: [`${field}:${lowerCaseString(doc[field])}`] }; - }); - if (options.additionalFilter) { - requestOptions.push({ key: [lowerCaseString(options.additionalFilter)] }); - } - let promiseChain = Promise.resolve([]); - requestOptions.forEach(options => { - promiseChain = promiseChain.then((responses) => { - return _executeExistsRequest(options).then(response => { - responses.push(response); - return responses; - }); - }); - }); - return promiseChain.then(responses => { - const ids = _getIntersection(responses).filter(id => id !== doc._id); - if (!ids.length) { - return false; - } - - return db.medic.allDocs({ keys: ids, include_docs: true }).then(result => { - // filter out docs with errors - const found = result.rows.some(row => { - const doc = row.doc; - return ( - (!doc.errors || doc.errors.length === 0) && - (!options.startDate || doc.reported_date >= options.startDate) - ); - }); - return found; - }); - }); -}; - -const extractErrors = (result, messages, ignores = []) => { +const extractErrors = (results, ignores, locale) => { // wrap single item in array; defaults to empty array if (!Array.isArray(ignores)) { ignores = [ignores]; } - const errors = []; - Object.keys(result).forEach(key => { - const valid = result[key]; - if (!valid && !ignores.includes(key)) { - errors.push({ - code: 'invalid_' + key, - message: messages[key], - }); - } - }); - return errors; -}; - -const getMessages = (validations, locale) => { - const validationMessages = {}; - validations.forEach(validation => { - if ( - validation.property && - (validation.message || validation.translation_key) - ) { - validationMessages[validation.property] = messages.getMessage(validation, translate, locale); - } - }); - return validationMessages; -}; - -const getRules = (validations) => { - const rules = {}; - validations.forEach(validation => { - if (validation.property && validation.rule) { - rules[validation.property] = validation.rule; - } - }); - return rules; + return results + .filter(result => !result.valid && !ignores.includes(result.validation.property)) + .map(result => { + const code = 'invalid_' + result.validation.property; + const message = translateMessage(result.validation, locale); + return { code, message }; + }); }; -const compareDate = (doc, validation, checkAfter = false) => { - const fields = [...validation.funcArgs]; - try { - const duration = _parseDuration(fields.pop()); - if (!duration.isValid()) { - logger.error('date constraint validation: the duration is invalid'); - return Promise.resolve(false); - } - const testDate = moment(doc[validation.field]); - const controlDate = checkAfter ? - moment(doc.reported_date).add(duration) : - moment(doc.reported_date).subtract(duration); - if (!testDate.isValid() || !controlDate.isValid()) { - logger.error('date constraint validation: the date is invalid'); - return Promise.resolve(false); - } - - if (checkAfter && testDate.isSameOrAfter(controlDate, 'days')) { - return Promise.resolve(true); - } - if (!checkAfter && testDate.isSameOrBefore(controlDate, 'days')) { - return Promise.resolve(true); - } - - logger.error('date constraint validation failed'); - return Promise.resolve(false); - } catch (err) { - logger.error('date constraint validation: the date or duration is invalid: %o', err); - return Promise.resolve(false); +const translateMessage = (validation, locale) => { + if (!validation.message && !validation.translation_key) { + return; } + return messageUtils.getMessage(validation, translate, locale); }; module.exports = { @@ -149,82 +34,10 @@ module.exports = { db = options.db; translate = options.translate; settings = options.settings || options.config; + validationUtils.init(db); inited = true; }, - - // Custom validations in addition to pupil but follows Pupil API - extra_validations: { - // Check if fields on a doc are unique in the db, return true if unique false otherwise. - unique: (doc, validation) => { - return _exists(doc, validation.funcArgs) - .catch(err => { - logger.error('Error running "unique" validation: %o', err); - }) - .then(result => !result); - }, - uniquePhone: (doc, validation) => { - return db.medic - .query('medic-client/contacts_by_phone', { key: doc[validation.field] }) - .then(results => !(results && results.rows && results.rows.length)); - }, - validPhone: (doc, validation) => { - const appSettings = config.getAll(); - const validPhone = phoneNumberParser.validate(appSettings, doc[validation.field]); - return Promise.resolve(validPhone); - }, - uniqueWithin: (doc, validation) => { - const fields = [...validation.funcArgs]; - const duration = _parseDuration(fields.pop()); - const startDate = moment() - .subtract(duration) - .valueOf(); - return _exists(doc, fields, { startDate }) - .catch(err => { - logger.error('Error running "uniqueWithin" validation: %o', err); - }) - .then(result => !result); - }, - exists: (doc, validation) => { - const formName = validation.funcArgs[0]; - const fieldName = validation.funcArgs[1]; - return _exists(doc, [fieldName], { additionalFilter: `form:${formName}` }).catch(err => { - logger.error('Error running "exists" validation: %o', err); - }); - }, - // Check if the week is a valid ISO week given a year. - isISOWeek: (doc, validation) => { - const weekFieldName = validation.funcArgs[0]; - const yearFieldName = validation.funcArgs[1] || null; - if ( - !_.has(doc, weekFieldName) || - (yearFieldName && !_.has(doc, yearFieldName)) - ) { - logger.error('isISOWeek validation failed: input field(s) do not exist'); - return Promise.resolve(false); - } - - const year = yearFieldName ? doc[yearFieldName] : new Date().getFullYear(); - const isValidISOWeek = - /^\d{1,2}$/.test(doc[weekFieldName]) && - /^\d{4}$/.test(year) && - doc[weekFieldName] >= 1 && - doc[weekFieldName] <= - moment() - .year(year) - .isoWeeksInYear(); - if (isValidISOWeek) { - return Promise.resolve(true); - } - - logger.error('isISOWeek validation failed: the number of week is greater than the maximum'); - return Promise.resolve(false); - }, - - isAfter: (doc, validation) => compareDate(doc, validation, true), - - isBefore: (doc, validation) => compareDate(doc, validation, false), - }, /** * Validation settings may consist of Pupil.js rules and custom rules. * These cannot be combined as part of the same rule. @@ -269,94 +82,19 @@ module.exports = { * @param {String[]} [ignores=[]] Keys of doc that is always considered valid * @returns {Promise} Array of errors if validation failed, empty array otherwise. */ - validate: (doc, validations = [], ignores = []) => { + validate: async (doc, validations=[], ignores=[]) => { if (!inited) { throw new Error('Validation module not initialized'); } - let result = {}; - let errors = []; - - // Modify validation objects that are calling a custom validation - // function. Add function name and args and append the function name to - // the property value so pupil.validate() will still work and error - // messages can be generated. - const extraValidationKeys = Object.keys(module.exports.extra_validations); - _.forEach(validations, (config, idx) => { - let entities; - try { - logger.debug(`validation rule ${config.rule}`); - entities = pupil.parser.parse(pupil.lexer.tokenize(config.rule)); - } catch (e) { - logger.error('error parsing validation: %o', e); - return errors.push('Error on pupil validations: ' + JSON.stringify(e)); - } - _.forEach(entities, (entity) => { - logger.debug('validation rule entity: %o', entity); - if (entity.sub && entity.sub.length > 0) { - _.forEach(entity.sub, (entitySub) => { - logger.debug(`validation rule entity sub ${entitySub.funcName}`); - if (extraValidationKeys.includes(entitySub.funcName)) { - const validation = validations[idx]; - // only update the first time through - if (!validation.property.includes('_' + entitySub.funcName)) { - validation.funcName = entitySub.funcName; - validation.funcArgs = entitySub.funcArgs; - validation.field = config.property; - validation.property += '_' + entitySub.funcName; - } - } - }); - } - }); - }); - - // trouble parsing pupil rules - if (errors.length > 0) { - return Promise.resolve(errors); - } - - const attributes = Object.assign({}, doc, doc.fields); - try { - result = pupil.validate(getRules(validations), attributes); + const attributes = Object.assign({}, doc, doc.fields); + const result = await pupil.validate(validations, attributes); + const locale = messageUtils.getLocale(settings, doc); + return extractErrors(result, ignores, locale); } catch (e) { - errors.push('Error on pupil validations: ' + JSON.stringify(e)); - return Promise.resolve(errors); + return ['Error on pupil validations: ' + JSON.stringify(e)]; } - // Run async/extra validations in series and collect results. - let promiseChain = Promise.resolve(); - _.forEach(validations, validation => { - promiseChain = promiseChain.then(() => { - if (!validation.funcName) { - return; - } - - return module.exports.extra_validations[validation.funcName] - .call(this, attributes, validation) - .then(res => { - // Be careful to not to make an invalid pupil result valid, - // only assign false values. If async result is true then do - // nothing since default is already true. Fields are valid - // unless proven otherwise. - if (res === false) { - result.results[validation.property] = res; - } - }); - }); - }); - - return promiseChain.then(() => { - errors = errors.concat( - extractErrors( - result.fields(), - getMessages(validations, messages.getLocale(settings, doc)), - ignores - ) - ); - - return errors; - }); }, }; diff --git a/shared-libs/validation/src/validation_result.js b/shared-libs/validation/src/validation_result.js deleted file mode 100644 index d58a57c1104..00000000000 --- a/shared-libs/validation/src/validation_result.js +++ /dev/null @@ -1,30 +0,0 @@ -const isValid = (results) => { - for (const index in results) { - if (!results[index]) { - return false; - } - } - return true; -}; - -const getErrors = (results) => { - const errors = []; - for (const index in results) { - if (!results[index]) { - errors.push(index); - } - } - return errors; -}; - -module.exports = { - create: (results) => { - return { - results, - isValid: () => isValid(results), - hasErrors: () => !isValid(results), - errors: () => getErrors(results), - fields: () => results - }; - } -}; diff --git a/shared-libs/validation/src/validation_utils.js b/shared-libs/validation/src/validation_utils.js new file mode 100644 index 00000000000..a20c1b0f96e --- /dev/null +++ b/shared-libs/validation/src/validation_utils.js @@ -0,0 +1,136 @@ +const _ = require('lodash/core'); +const moment = require('moment'); +const logger = require('@medic/logger'); +const phoneNumberParser = require('@medic/phone-number'); +const config = require('../../transitions/src/config'); + +let db; + +const lowerCaseString = obj => typeof obj === 'string' ? obj.toLowerCase() : obj; + +const executeExistsRequest = async (options) => { + return await db.medic.query('medic-client/reports_by_freetext', options); +}; + +const getIntersection = responses => { + let ids = responses.pop().rows.map(row => row.id); + responses.forEach(response => { + ids = ids.filter(id => _.find(response.rows, { id })); + }); + return ids; +}; + +const parseDuration = (duration) => { + const parts = duration.split(' '); + return moment.duration(parseInt(parts[0]), parts[1]); +}; + +const parseStartDate = (duration) => { + if (!duration) { + return; + } + const parsed = parseDuration(duration); + return moment().subtract(parsed).valueOf(); +}; + +const exists = async (doc, fields, options = {}) => { + if (!fields.length) { + return Promise.reject('No arguments provided to "exists" validation function'); + } + const requestOptions = fields.map(field => { + return { key: [`${field}:${lowerCaseString(doc[field])}`] }; + }); + if (options.additionalFilter) { + requestOptions.push({ key: [lowerCaseString(options.additionalFilter)] }); + } + const responses = []; + for (const options of requestOptions) { + const response = await executeExistsRequest(options); + responses.push(response); + } + + const ids = getIntersection(responses).filter(id => id !== doc._id); + if (!ids.length) { + return false; + } + + const result = await db.medic.allDocs({ keys: ids, include_docs: true }); + const startDate = parseStartDate(options.duration); + // filter out docs with errors + const found = result.rows.some(row => { + const doc = row.doc; + return ( + (!doc.errors || doc.errors.length === 0) && + (!startDate || doc.reported_date >= startDate) + ); + }); + return found; +}; + +const compareDateAfter = (testDate, reportedDate, duration) => { + const controlDate = reportedDate.add(duration); + return testDate.isSameOrAfter(controlDate, 'days'); +}; + +const checkDateBefore = (testDate, reportedDate, duration) => { + const controlDate = reportedDate.subtract(duration); + return testDate.isSameOrBefore(controlDate, 'days'); +}; + +const compareDate = (doc, date, durationString, checkAfter=false) => { + try { + const duration = parseDuration(durationString); + if (!duration.isValid()) { + logger.error('date constraint validation: the duration is invalid'); + return false; + } + const testDate = moment(date); + if (!testDate.isValid()) { + logger.error('date constraint validation: the date is invalid'); + return false; + } + const reportedDate = moment(doc.reported_date); + if (checkAfter) { + return compareDateAfter(testDate, reportedDate, duration); + } + return checkDateBefore(testDate, reportedDate, duration); + } catch (err) { + logger.error('date constraint validation: the date or duration is invalid: %o', err); + return false; + } +}; + +const isISOWeek = (doc, weekFieldName, yearFieldName) => { + if (!_.has(doc, weekFieldName) || (yearFieldName && !_.has(doc, yearFieldName))) { + logger.error('isISOWeek validation failed: input field(s) do not exist'); + return false; + } + + const year = yearFieldName ? doc[yearFieldName] : new Date().getFullYear(); + const week = doc[weekFieldName]; + return /^\d{1,2}$/.test(week) && + /^\d{4}$/.test(year) && + week >= 1 && + week <= moment().year(year).isoWeeksInYear(); +}; + +const validPhone = (value) => { + const appSettings = config.getAll(); + return phoneNumberParser.validate(appSettings, value); +}; + +const uniquePhone = async (value) => { + const results = await db.medic.query('medic-client/contacts_by_phone', { key: value }); + return !results?.rows?.length; +}; + +module.exports = { + init: (_db) => { + db = _db; + }, + exists, + compareDate, + isISOWeek, + validPhone, + uniquePhone +}; diff --git a/shared-libs/validation/src/validator.js b/shared-libs/validation/src/validator.js index fc18a84f514..9401ef55535 100644 --- a/shared-libs/validation/src/validator.js +++ b/shared-libs/validation/src/validator.js @@ -1,7 +1,7 @@ const Entity = require('./entities.js'); const ValidatorFunctions = require('./validator_functions.js'); -const validate = (entities, values, valueKey) => { +const validate = async (entities, values, valueKey) => { if (entities === null || typeof entities === 'undefined') { return true; } @@ -44,19 +44,19 @@ const validate = (entities, values, valueKey) => { funcArgs.unshift(values); if (ValidatorFunctions[funcName]) { - tempResult = ValidatorFunctions[funcName].apply(this, funcArgs); + tempResult = await ValidatorFunctions[funcName].apply(this, funcArgs); } useTempResult = true; } else if (thisEntity.type === Entity.Block) { - tempResult = validate(thisEntity.sub, values, valueKey); + tempResult = await validate(thisEntity.sub, values, valueKey); useTempResult = true; } else if (thisEntity.type === Entity.Ternary) { - const ternaryCondition = validate(thisEntity.conditions, values, valueKey); + const ternaryCondition = await validate(thisEntity.conditions, values, valueKey); if (ternaryCondition) { - tempResult = validate(thisEntity.ifThen, values, valueKey); + tempResult = await validate(thisEntity.ifThen, values, valueKey); } else { - tempResult = validate(thisEntity.ifElse, values, valueKey); + tempResult = await validate(thisEntity.ifElse, values, valueKey); } useTempResult = true; diff --git a/shared-libs/validation/src/validator_functions.js b/shared-libs/validation/src/validator_functions.js index 2dba97dcdce..d23e0a62f87 100644 --- a/shared-libs/validation/src/validator_functions.js +++ b/shared-libs/validation/src/validator_functions.js @@ -1,3 +1,6 @@ +const logger = require('@medic/logger'); +const validationUtils = require('./validation_utils'); + const re = { alpha: /^[a-zA-Z]+$/, alphanumeric: /^[a-zA-Z0-9]+$/, @@ -66,7 +69,55 @@ const ValidatorFunctions = { integer: (allValues, value) => parseInt(value, 10) === value, - equalsto: (allValues, value, equalsToKey) => value === allValues[equalsToKey] + equalsto: (allValues, value, equalsToKey) => value === allValues[equalsToKey], + + exists: async (allValues, value, formName, fieldName) => { + try { + return await validationUtils.exists(allValues, [fieldName], { additionalFilter: `form:${formName}` }); + } catch (e) { + logger.error('Error running "exists" validation: %o', e); + } + }, + + unique: async (allValues, value, ...fieldNames) => { + try { + const exists = await validationUtils.exists(allValues, fieldNames); + return !exists; + } catch (e) { + logger.error('Error running "unique" validation: %o', e); + } + }, + + uniquewithin: async (allValues, value, ...fields) => { + const duration = fields.pop(); + try { + const exists = await validationUtils.exists(allValues, fields, { duration }); + return !exists; + } catch (e) { + logger.error('Error running "uniqueWithin" validation: %o', e); + } + }, + + isafter: (allValues, value, duration) => { + return validationUtils.compareDate(allValues, value, duration, true); + }, + + isbefore: (allValues, value, duration) => { + return validationUtils.compareDate(allValues, value, duration, false); + }, + + isisoweek: (allValues, value, weekFieldName, yearFieldName) => { + return validationUtils.isISOWeek(allValues, weekFieldName, yearFieldName); + }, + + validphone: (allValues, value, phoneFieldName) => { + return validationUtils.validPhone(allValues[phoneFieldName]); + }, + + uniquephone: async (allValues, value, phoneFieldName) => { + return await validationUtils.uniquePhone(allValues[phoneFieldName]); + } + }; module.exports = ValidatorFunctions; diff --git a/shared-libs/validation/test/validations.js b/shared-libs/validation/test/validations.js index 44b5acf748c..4f8380378ba 100644 --- a/shared-libs/validation/test/validations.js +++ b/shared-libs/validation/test/validations.js @@ -1,8 +1,7 @@ const moment = require('moment'); -const rewire = require('rewire'); const sinon = require('sinon'); const assert = require('chai').assert; -const validation = rewire('../src/validation'); +const validation = require('../src/validation'); const logger = require('@medic/logger'); let clock; @@ -19,6 +18,7 @@ const stubMe = (functionName) => { }; describe('validations', () => { + beforeEach(() => { db = { medic: { query: () => stubMe('query'), allDocs: () => stubMe('allDocs') } }; config = {}; @@ -28,6 +28,7 @@ describe('validations', () => { validation.init({ db, config, translate }); }); + afterEach(() => { if (clock) { clock.restore(); @@ -35,11 +36,6 @@ describe('validations', () => { sinon.restore(); }); - it('should throw an error when validate is called without initialization', () => { - validation.__set__('inited', false); - assert.throws(validation.validate, ''); - }); - it('validate handles pupil parse errors', () => { const doc = { phone: '123', @@ -197,7 +193,7 @@ describe('validations', () => { return validation.validate(doc, validations).then(errors => { assert.deepEqual(errors, [ { - code: 'invalid_xyz_unique', + code: 'invalid_xyz', message: 'Duplicate: {{xyz}}.', }, ]); @@ -243,7 +239,7 @@ describe('validations', () => { assert.deepEqual(allDocs.args[0][0], { keys: ['different'], include_docs: true }); assert.deepEqual(errors, [ { - code: 'invalid_xyz_unique', + code: 'invalid_xyz', message: 'Duplicate xyz {{xyz}} and abc {{abc}}.', }, ]); @@ -527,7 +523,7 @@ describe('validations', () => { return validation.validate(doc, validations).then(errors => { assert.deepEqual(errors, [ { - code: 'invalid_xyz_uniqueWithin', + code: 'invalid_xyz', message: 'Duplicate xyz {{xyz}}.', }, ]); @@ -661,7 +657,7 @@ describe('validations', () => { return validation.validate(doc, validations).then(errors => { assert.deepEqual(errors, [ { - code: 'invalid_parent_id_exists', + code: 'invalid_parent_id', message: 'Unknown patient {{parent_id}}.', }, ]); @@ -696,7 +692,7 @@ describe('validations', () => { return validation.validate(doc, validations).then(errors => { assert.deepEqual(errors, [ { - code: 'invalid_parent_id_exists', + code: 'invalid_parent_id', message: 'Unknown patient {{parent_id}}.', }, ]); @@ -777,7 +773,7 @@ describe('validations', () => { return validation.validate(doc, validations).then(errors => { assert.deepEqual(errors, [ { - code: 'invalid_lmp_date_isBefore', + code: 'invalid_lmp_date', message: 'Invalid date.', }, ]); @@ -805,7 +801,7 @@ describe('validations', () => { return validation.validate(doc, validations).then(errors => { assert.deepEqual(errors, [ { - code: 'invalid_lmp_date_isBefore', + code: 'invalid_lmp_date', message: 'Invalid date.', }, ]); @@ -833,7 +829,7 @@ describe('validations', () => { return validation.validate(doc, validations).then(errors => { assert.deepEqual(errors, [ { - code: 'invalid_lmp_date_isBefore', + code: 'invalid_lmp_date', message: 'Invalid date.', }, ]); @@ -895,7 +891,7 @@ describe('validations', () => { return validation.validate(doc, validations).then(errors => { assert.deepEqual(errors, [ { - code: 'invalid_lmp_date_isAfter', + code: 'invalid_lmp_date', message: 'Invalid date.', }, ]); @@ -950,5 +946,215 @@ describe('validations', () => { }); }); + + describe('combining validations - #8806', () => { + + describe('rule L or G exists', () => { + + const validations = [{ + property: 'patient_id', + rule: 'exists("L","patient_id") || exists("G","patient_id")', + message: [{ + content: 'Pregnancy not registered already', + locale: 'en', + }] + }]; + + it('should fail when neither L nor G exists', () => { + sinon.stub(db.medic, 'query').resolves({ rows: [] }); + const doc = { + _id: 'same', + patient_id: '444', + }; + return validation.validate(doc, validations).then(errors => { + assert.equal(errors.length, 1); + }); + }); + + it('should pass when L exists', () => { + sinon.stub(db.medic, 'query') + .onCall(0).resolves({ rows: [{ id: 'different1' }] }) // once for patient_id=444 + .onCall(1).resolves({ rows: [{ id: 'different1' }] }) // once for form=L + .onCall(2).resolves({ rows: [{ id: 'different2' }] }) // once for patient_id=444 + .onCall(3).resolves({ rows: [] }); // once for form=G + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [{ + id: 'different1', + doc: { _id: 'different1', errors: [] }, + }], + }); + const doc = { + _id: 'same', + patient_id: '444', + }; + return validation.validate(doc, validations).then(errors => { + assert.equal(db.medic.query.callCount, 4); // TODO maybe should be 2, bcause we can short-circuit + assert.equal(db.medic.allDocs.callCount, 1); + assert.equal(errors.length, 0); + }); + }); + + it('should pass when G exists', () => { + sinon.stub(db.medic, 'query') + .onCall(0).resolves({ rows: [{ id: 'different1' }] }) // once for patient_id=444 + .onCall(1).resolves({ rows: [] }) // once for form=L + .onCall(2).resolves({ rows: [{ id: 'different2' }] }) // once for patient_id=444 + .onCall(3).resolves({ rows: [{ id: 'different2' }] }); // once for form=G + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [{ + id: 'different2', + doc: { _id: 'different2', errors: [] }, + }], + }); + const doc = { + _id: 'same', + patient_id: '444', + }; + return validation.validate(doc, validations).then(errors => { + assert.equal(db.medic.query.callCount, 4); + assert.equal(db.medic.allDocs.callCount, 1); + assert.equal(errors.length, 0); + }); + }); + + it('should pass when L and G exist', () => { + sinon.stub(db.medic, 'query') + .onCall(0).resolves({ rows: [{ id: 'different1' }] }) // once for patient_id=444 + .onCall(1).resolves({ rows: [{ id: 'different1' }] }) // once for form=L + .onCall(2).resolves({ rows: [{ id: 'different2' }] }) // once for patient_id=444 + .onCall(3).resolves({ rows: [{ id: 'different2' }] }); // once for form=G + sinon.stub(db.medic, 'allDocs') + .onCall(0).resolves({ + rows: [{ + id: 'different1', + doc: { _id: 'different1', errors: [] }, + }], + }) + .onCall(1).resolves({ + rows: [{ + id: 'different2', + doc: { _id: 'different2', errors: [] }, + }], + }); + const doc = { + _id: 'same', + patient_id: '444', + }; + return validation.validate(doc, validations).then(errors => { + assert.equal(db.medic.query.callCount, 4); + assert.equal(db.medic.allDocs.callCount, 2); + assert.equal(errors.length, 0); + }); + }); + }); + + describe('rule length and exists', () => { + + const validations = [{ + property: 'patient_id', + rule: 'lenMin(2) && exists("G","patient_id")', + message: [{ + content: 'Pregnancy not registered already', + locale: 'en', + }] + }]; + + it('should fail when patient id too short', () => { + sinon.stub(db.medic, 'query').resolves({ rows: [] }); + const doc = { + _id: 'same', + patient_id: '1', + }; + return validation.validate(doc, validations).then(errors => { + assert.equal(errors.length, 1); + }); + }); + + it('should fail when not exists', () => { + sinon.stub(db.medic, 'query').resolves({ rows: [] }); + sinon.stub(db.medic, 'allDocs') + .onCall(0).resolves({ + rows: [{ + id: 'different1', + doc: { _id: 'different1', errors: [] }, + }], + }); + const doc = { + _id: 'same', + patient_id: '123', + }; + return validation.validate(doc, validations).then(errors => { + assert.equal(errors.length, 1); + }); + }); + + + it('should pass when long enough and exists', () => { + sinon.stub(db.medic, 'query') + .onCall(0).resolves({ rows: [{ id: 'different1' }] }) // once for patient_id=444 + .onCall(1).resolves({ rows: [{ id: 'different1' }] }); // once for form=G + sinon.stub(db.medic, 'allDocs') + .onCall(0).resolves({ + rows: [{ + id: 'different1', + doc: { _id: 'different1', errors: [] }, + }], + }); + const doc = { + _id: 'same', + patient_id: '123', + }; + return validation.validate(doc, validations).then(errors => { + assert.equal(errors.length, 0); + }); + }); + + }); + + }); + describe('multiple rules on a property', () => { + + // these rules are impossible to satisfy but it means we can test handling of multiple failures + const validations = [ + { + property: 'lmp_date', + rule: 'isAfter("40 weeks")', + message: [{ + content: 'Date should be later than 40 weeks from now.', + locale: 'en' + }] + }, + { + property: 'lmp_date', + rule: 'isBefore("8 weeks")', + message: [{ + content: 'Date should be older than 8 weeks ago.', + locale: 'en' + }] + } + ]; + + it('should pass when long enough and exists', () => { + const doc = { + _id: 'same', + lmp_date: moment().valueOf(), + reported_date: moment().valueOf() + }; + return validation.validate(doc, validations).then(errors => { + assert.deepEqual(errors, [ + { + code: 'invalid_lmp_date', + message: 'Date should be later than 40 weeks from now.', + }, + { + code: 'invalid_lmp_date', + message: 'Date should be older than 8 weeks ago.', + }, + ]); + }); + }); + + }); + });