diff --git a/.gitignore b/.gitignore index 39c909fa0..e1a85a64d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ upload-docs.*.log.json /.vscode/ /.idea/ /.settings/ +/json_docs/ *.swp coverage .nyc_output diff --git a/package-lock.json b/package-lock.json index 60ff114c6..09d2cfac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "json-diff": "^1.0.6", "json-stringify-safe": "^5.0.1", "json2csv": "^4.5.4", + "lodash": "^4.17.21", "mime-types": "^2.1.35", "minimist": "^1.2.8", "mkdirp": "^3.0.1", diff --git a/package.json b/package.json index c06ae5d57..1f5a35ba2 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "json-diff": "^1.0.6", "json-stringify-safe": "^5.0.1", "json2csv": "^4.5.4", + "lodash": "^4.17.21", "mime-types": "^2.1.35", "minimist": "^1.2.8", "mkdirp": "^3.0.1", diff --git a/src/fn/merge-contacts.js b/src/fn/merge-contacts.js new file mode 100644 index 000000000..ef3f20211 --- /dev/null +++ b/src/fn/merge-contacts.js @@ -0,0 +1,68 @@ +const minimist = require('minimist'); +const path = require('path'); + +const environment = require('../lib/environment'); +const pouch = require('../lib/db'); +const { info } = require('../lib/log'); + +const HierarchyOperations = require('../lib/hierarchy-operations'); + +module.exports = { + requiresInstance: true, + execute: () => { + const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); + const db = pouch(); + const options = { + docDirectoryPath: args.docDirectoryPath, + force: args.force, + }; + return HierarchyOperations(db, options).merge(args.sourceIds, args.destinationId); + } +}; + +// Parses extraArgs and asserts if required parameters are not present +const parseExtraArgs = (projectDir, extraArgs = []) => { + const args = minimist(extraArgs, { boolean: true }); + + const sourceIds = (args.remove || '') + .split(',') + .filter(Boolean); + + if (!args.keep) { + usage(); + throw Error(`Action "merge-contacts" is missing required contact ID ${bold('--keep')}. Other contacts will be merged into this contact.`); + } + + if (sourceIds.length === 0) { + usage(); + throw Error(`Action "merge-contacts" is missing required contact ID(s) ${bold('--remove')}. These contacts will be merged into the contact specified by ${bold('--keep')}`); + } + + return { + destinationId: args.keep, + sourceIds, + docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), + force: !!args.force, + }; +}; + +const bold = text => `\x1b[1m${text}\x1b[0m`; +const usage = () => { + info(` +${bold('cht-conf\'s merge-contacts action')} +When combined with 'upload-docs' this action merges multiple contacts and all their associated data into one. + +${bold('USAGE')} +cht --local merge-contacts -- --keep= --remove=, + +${bold('OPTIONS')} +--keep= + Specifies the ID of the contact that should have all other contact data merged into it. + +--remove=, + A comma delimited list of IDs of contacts which will be deleted and all of their data will be merged into the keep contact. + +--docDirectoryPath= + Specifies the folder used to store the documents representing the changes in hierarchy. +`); +}; diff --git a/src/fn/move-contacts.js b/src/fn/move-contacts.js index 6b29e3b03..97dc8e142 100644 --- a/src/fn/move-contacts.js +++ b/src/fn/move-contacts.js @@ -1,105 +1,34 @@ const minimist = require('minimist'); const path = require('path'); -const userPrompt = require('../lib/user-prompt'); const environment = require('../lib/environment'); -const fs = require('../lib/sync-fs'); -const lineageManipulation = require('../lib/lineage-manipulation'); -const lineageConstraints = require('../lib/lineage-constraints'); const pouch = require('../lib/db'); -const { warn, trace, info } = require('../lib/log'); +const { info } = require('../lib/log'); -const HIERARCHY_ROOT = 'root'; -const BATCH_SIZE = 10000; +const HierarchyOperations = require('../lib/hierarchy-operations'); module.exports = { requiresInstance: true, execute: () => { const args = parseExtraArgs(environment.pathToProject, environment.extraArgs); const db = pouch(); - prepareDocumentDirectory(args); - return updateLineagesAndStage(args, db); + const options = { + docDirectoryPath: args.docDirectoryPath, + force: args.force, + }; + return HierarchyOperations(db, options).move(args.sourceIds, args.destinationId); } }; -const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; -const updateLineagesAndStage = async (options, db) => { - trace(`Fetching contact details for parent: ${options.parentId}`); - const parentDoc = await fetch.contact(db, options.parentId); - - const constraints = await lineageConstraints(db, parentDoc); - const contactDocs = await fetch.contactList(db, options.contactIds); - await validateContacts(contactDocs, constraints); - - let affectedContactCount = 0, affectedReportCount = 0; - const replacementLineage = lineageManipulation.createLineageFromDoc(parentDoc); - for (let contactId of options.contactIds) { - const contactDoc = contactDocs[contactId]; - const descendantsAndSelf = await fetch.descendantsOf(db, contactId); - - // Check that primary contact is not removed from areas where they are required - const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(contactDoc, descendantsAndSelf); - if (invalidPrimaryContactDoc) { - throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); - } - - trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(contactDoc)}.`); - const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, contactId); - - const ancestors = await fetch.ancestorsOf(db, contactDoc); - trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(contactDoc)}.`); - const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); - - minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options); - - const movedReportsCount = await moveReports(db, descendantsAndSelf, options, replacementLineage, contactId); - trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); - - affectedContactCount += updatedDescendants.length + updatedAncestors.length; - affectedReportCount += movedReportsCount; - - info(`Staged updates to ${prettyPrintDocument(contactDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); - } - - info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); -}; - -/* -Checks for any errors which this will create in the hierarchy (hierarchy schema, circular hierarchies) -Confirms the list of contacts are possible to move -*/ -const validateContacts = async (contactDocs, constraints) => { - Object.values(contactDocs).forEach(doc => { - const hierarchyError = constraints.getHierarchyErrors(doc); - if (hierarchyError) { - throw Error(`Hierarchy Constraints: ${hierarchyError}`); - } - }); - - /* - It is nice that the tool can move lists of contacts as one operation, but strange things happen when two contactIds are in the same lineage. - For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file. - */ - const contactIds = Object.keys(contactDocs); - Object.values(contactDocs) - .forEach(doc => { - const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || []; - const violatingParentId = parentIdsOfDoc.find(parentId => contactIds.includes(parentId)); - if (violatingParentId) { - throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); - } - }); -}; - // Parses extraArgs and asserts if required parameters are not present const parseExtraArgs = (projectDir, extraArgs = []) => { const args = minimist(extraArgs, { boolean: true }); - const contactIds = (args.contacts || args.contact || '') + const sourceIds = (args.contacts || args.contact || '') .split(',') .filter(id => id); - if (contactIds.length === 0) { + if (sourceIds.length === 0) { usage(); throw Error('Action "move-contacts" is missing required list of contacts to be moved'); } @@ -110,28 +39,15 @@ const parseExtraArgs = (projectDir, extraArgs = []) => { } return { - parentId: args.parent, - contactIds, + destinationId: args.parent, + sourceIds, docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'), force: !!args.force, }; }; -const prepareDocumentDirectory = ({ docDirectoryPath, force }) => { - if (!fs.exists(docDirectoryPath)) { - fs.mkdir(docDirectoryPath); - } else if (!force && fs.recurseFiles(docDirectoryPath).length > 0) { - warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`); - if(userPrompt.keyInYN()) { - fs.deleteFilesInFolder(docDirectoryPath); - } else { - throw new Error('User aborted execution.'); - } - } -}; - +const bold = text => `\x1b[1m${text}\x1b[0m`; const usage = () => { - const bold = text => `\x1b[1m${text}\x1b[0m`; info(` ${bold('cht-conf\'s move-contacts action')} When combined with 'upload-docs' this action effectively moves a contact from one place in the hierarchy to another. @@ -144,148 +60,9 @@ ${bold('OPTIONS')} A comma delimited list of ids of contacts to be moved. --parent= - Specifies the ID of the new parent. Use '${HIERARCHY_ROOT}' to identify the top of the hierarchy (no parent). + Specifies the ID of the new parent. Use '${HierarchyOperations.HIERARCHY_ROOT}' to identify the top of the hierarchy (no parent). --docDirectoryPath= Specifies the folder used to store the documents representing the changes in hierarchy. `); }; - -const moveReports = async (db, descendantsAndSelf, writeOptions, replacementLineage, contactId) => { - const contactIds = descendantsAndSelf.map(contact => contact._id); - - let skip = 0; - let reportDocsBatch; - do { - info(`Processing ${skip} to ${skip + BATCH_SIZE} report docs`); - reportDocsBatch = await fetch.reportsCreatedBy(db, contactIds, skip); - - const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, contactId); - minifyLineageAndWriteToDisk(updatedReports, writeOptions); - - skip += reportDocsBatch.length; - } while (reportDocsBatch.length >= BATCH_SIZE); - - return skip; -}; - -const minifyLineageAndWriteToDisk = (docs, parsedArgs) => { - docs.forEach(doc => { - lineageManipulation.minifyLineagesInDoc(doc); - writeDocumentToDisk(parsedArgs, doc); - }); -}; - -const writeDocumentToDisk = ({ docDirectoryPath }, doc) => { - const destinationPath = path.join(docDirectoryPath, `${doc._id}.doc.json`); - if (fs.exists(destinationPath)) { - warn(`File at ${destinationPath} already exists and is being overwritten.`); - } - - trace(`Writing updated document to ${destinationPath}`); - fs.writeJson(destinationPath, doc); -}; - -const fetch = { - /* - Fetches all of the documents associated with the "contactIds" and confirms they exist. - */ - contactList: async (db, ids) => { - const contactDocs = await db.allDocs({ - keys: ids, - include_docs: true, - }); - - const missingContactErrors = contactDocs.rows.filter(row => !row.doc).map(row => `Contact with id '${row.key}' could not be found.`); - if (missingContactErrors.length > 0) { - throw Error(missingContactErrors); - } - - return contactDocs.rows.reduce((agg, curr) => Object.assign(agg, { [curr.doc._id]: curr.doc }), {}); - }, - - contact: async (db, id) => { - try { - if (id === HIERARCHY_ROOT) { - return undefined; - } - - return await db.get(id); - } catch (err) { - if (err.name !== 'not_found') { - throw err; - } - - throw Error(`Contact with id '${id}' could not be found`); - } - }, - - /* - Given a contact's id, obtain the documents of all descendant contacts - */ - descendantsOf: async (db, contactId) => { - const descendantDocs = await db.query('medic/contacts_by_depth', { - key: [contactId], - include_docs: true, - }); - - return descendantDocs.rows - .map(row => row.doc) - /* We should not move or update tombstone documents */ - .filter(doc => doc && doc.type !== 'tombstone'); - }, - - reportsCreatedBy: async (db, contactIds, skip) => { - const reports = await db.query('medic-client/reports_by_freetext', { - keys: contactIds.map(id => [`contact:${id}`]), - include_docs: true, - limit: BATCH_SIZE, - skip: skip, - }); - - return reports.rows.map(row => row.doc); - }, - - ancestorsOf: async (db, contactDoc) => { - const ancestorIds = lineageManipulation.pluckIdsFromLineage(contactDoc.parent); - const ancestors = await db.allDocs({ - keys: ancestorIds, - include_docs: true, - }); - - const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key); - if (ancestorIdsNotFound.length > 0) { - throw Error(`Contact '${prettyPrintDocument(contactDoc)} has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`); - } - - return ancestors.rows.map(ancestor => ancestor.doc); - }, -}; - -const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => { - if (lineageManipulation.replaceLineage(doc, 'contact', replaceWith, startingFromIdInLineage)) { - agg.push(doc); - } - return agg; -}, []); - -const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, contactId) => descendantsAndSelf.reduce((agg, doc) => { - const startingFromIdInLineage = doc._id === contactId ? undefined : contactId; - const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage); - const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, contactId); - if (parentWasUpdated || contactWasUpdated) { - agg.push(doc); - } - return agg; -}, []); - -const replaceLineageInAncestors = (descendantsAndSelf, ancestors) => ancestors.reduce((agg, ancestor) => { - let result = agg; - const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); - if (primaryContact) { - ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); - result = [ancestor, ...result]; - } - - return result; -}, []); diff --git a/src/lib/hierarchy-operations/backend.js b/src/lib/hierarchy-operations/backend.js new file mode 100644 index 000000000..30990d8b3 --- /dev/null +++ b/src/lib/hierarchy-operations/backend.js @@ -0,0 +1,100 @@ +const _ = require('lodash'); +const lineageManipulation = require('./lineage-manipulation'); + +const HIERARCHY_ROOT = 'root'; +const BATCH_SIZE = 10000; + +/* +Fetches all of the documents associated with the "contactIds" and confirms they exist. +*/ +async function contactList(db, ids) { + const contactDocs = await db.allDocs({ + keys: ids, + include_docs: true, + }); + + const missingContactErrors = contactDocs.rows.filter(row => !row.doc).map(row => `Contact with id '${row.key}' could not be found.`); + if (missingContactErrors.length > 0) { + throw Error(missingContactErrors); + } + + return contactDocs.rows.reduce((agg, curr) => Object.assign(agg, { [curr.doc._id]: curr.doc }), {}); +} + +async function contact(db, id) { + try { + if (id === HIERARCHY_ROOT) { + return undefined; + } + + return await db.get(id); + } catch (err) { + if (err.name !== 'not_found') { + throw err; + } + + throw Error(`Contact with id '${id}' could not be found`); + } +} + +/* +Given a contact's id, obtain the documents of all descendant contacts +*/ +async function descendantsOf(db, contactId) { + const descendantDocs = await db.query('medic/contacts_by_depth', { + key: [contactId], + include_docs: true, + }); + + return descendantDocs.rows + .map(row => row.doc) + /* We should not move or update tombstone documents */ + .filter(doc => doc && doc.type !== 'tombstone'); +} + +async function reportsCreatedByOrAt(db, createdByIds, createdAtId, skip) { + const createdByKeys = createdByIds.map(id => [`contact:${id}`]); + const createdAtKeys = createdAtId ? [ + [`patient_id:${createdAtId}`], + [`patient_uuid:${createdAtId}`], + [`place_id:${createdAtId}`], + [`place_uuid:${createdAtId}`] + ] : []; + + const reports = await db.query('medic-client/reports_by_freetext', { + keys: [ + ...createdByKeys, + ...createdAtKeys, + ], + include_docs: true, + limit: BATCH_SIZE, + skip, + }); + + return _.uniqBy(reports.rows.map(row => row.doc), '_id'); +} + +async function ancestorsOf(db, contactDoc) { + const ancestorIds = lineageManipulation.pluckIdsFromLineage(contactDoc.parent); + const ancestors = await db.allDocs({ + keys: ancestorIds, + include_docs: true, + }); + + const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key); + if (ancestorIdsNotFound.length > 0) { + throw Error(`Contact '${contactDoc?.name}' (${contactDoc?._id}) has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`); + } + + return ancestors.rows.map(ancestor => ancestor.doc); +} + +module.exports = { + HIERARCHY_ROOT, + BATCH_SIZE, + ancestorsOf, + descendantsOf, + contact, + contactList, + reportsCreatedByOrAt, +}; diff --git a/src/lib/hierarchy-operations/index.js b/src/lib/hierarchy-operations/index.js new file mode 100644 index 000000000..19b6b861b --- /dev/null +++ b/src/lib/hierarchy-operations/index.js @@ -0,0 +1,193 @@ +const lineageManipulation = require('./lineage-manipulation'); +const LineageConstraints = require('./lineage-constraints'); +const { trace, info } = require('../log'); + +const JsDocs = require('./jsdocFolder'); +const Backend = require('./backend'); + +const HierarchyOperations = (db, options) => { + async function move(sourceIds, destinationId) { + JsDocs.prepareFolder(options); + trace(`Fetching contact details: ${destinationId}`); + const constraints = await LineageConstraints(db, options); + const destinationDoc = await Backend.contact(db, destinationId); + const sourceDocs = await Backend.contactList(db, sourceIds); + constraints.assertHierarchyErrors(Object.values(sourceDocs), destinationDoc); + + let affectedContactCount = 0, affectedReportCount = 0; + const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc); + for (let sourceId of sourceIds) { + const sourceDoc = sourceDocs[sourceId]; + const descendantsAndSelf = await Backend.descendantsOf(db, sourceId); + + if (options.merge) { + const self = descendantsAndSelf.find(d => d._id === sourceId); + JsDocs.writeDoc(options, { + _id: self._id, + _rev: self._rev, + _deleted: true, + }); + } + + const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`; + // Check that primary contact is not removed from areas where they are required + const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf); + if (invalidPrimaryContactDoc) { + throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`); + } + + trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`); + const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, sourceId); + + const ancestors = await Backend.ancestorsOf(db, sourceDoc); + trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`); + const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors); + + minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors]); + + const movedReportsCount = await moveReports(descendantsAndSelf, replacementLineage, sourceId, destinationId); + trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`); + + affectedContactCount += updatedDescendants.length + updatedAncestors.length; + affectedReportCount += movedReportsCount; + + info(`Staged updates to ${prettyPrintDocument(sourceDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`); + } + + info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`); + } + + async function moveReports(descendantsAndSelf, replacementLineage, sourceId, destinationId) { + const descendantIds = descendantsAndSelf.map(contact => contact._id); + + let skip = 0; + let reportDocsBatch; + do { + info(`Processing ${skip} to ${skip + Backend.BATCH_SIZE} report docs`); + const createdAtId = options.merge && sourceId; + reportDocsBatch = await Backend.reportsCreatedByOrAt(db, descendantIds, createdAtId, skip); + + const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, sourceId); + + if (options.merge) { + reassignReports(reportDocsBatch, sourceId, destinationId, updatedReports); + } + + minifyLineageAndWriteToDisk(updatedReports); + + skip += reportDocsBatch.length; + } while (reportDocsBatch.length >= Backend.BATCH_SIZE); + + return skip; + } + + function reassignReports(reports, sourceId, destinationId, updatedReports) { + function reassignReportWithSubject(report, subjectId) { + let updated = false; + if (report[subjectId] === sourceId) { + report[subjectId] = destinationId; + updated = true; + } + + if (report.fields[subjectId] === sourceId) { + report.fields[subjectId] = destinationId; + updated = true; + } + + if (updated) { + const isAlreadyUpdated = !!updatedReports.find(updated => updated._id === report._id); + if (!isAlreadyUpdated) { + updatedReports.push(report); + } + } + } + + for (const report of reports) { + const subjectIds = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid']; + for (const subjectId of subjectIds) { + reassignReportWithSubject(report, subjectId); + } + } + } + + function minifyLineageAndWriteToDisk(docs) { + docs.forEach(doc => { + lineageManipulation.minifyLineagesInDoc(doc); + JsDocs.writeDoc(options, doc); + }); + } + + function replaceLineageInReports(reportsCreatedByDescendants, replaceWith, startingFromId) { + return reportsCreatedByDescendants.reduce((agg, doc) => { + const replaceLineageOptions = { + lineageAttribute: 'contact', + replaceWith, + startingFromId, + merge: options.merge, + }; + + if (lineageManipulation.replaceLineage(doc, replaceLineageOptions)) { + agg.push(doc); + } + return agg; + }, []); + } + + function replaceLineageInAncestors(descendantsAndSelf, ancestors) { + return ancestors.reduce((agg, ancestor) => { + let result = agg; + const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id); + if (primaryContact) { + ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact); + result = [ancestor, ...result]; + } + + return result; + }, []); + } + + function replaceLineageInContacts(descendantsAndSelf, replaceWith, destinationId) { + function replaceForSingleContact(doc) { + const docIsDestination = doc._id === destinationId; + const startingFromId = options.merge || !docIsDestination ? destinationId : undefined; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith, + startingFromId, + merge: options.merge, + }; + const parentWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); + + replaceLineageOptions.lineageAttribute = 'contact'; + replaceLineageOptions.startingFromId = destinationId; + const contactWasUpdated = lineageManipulation.replaceLineage(doc, replaceLineageOptions); + const isUpdated = parentWasUpdated || contactWasUpdated; + if (isUpdated) { + result.push(doc); + } + } + + const result = []; + for (const doc of descendantsAndSelf) { + const docIsDestination = doc._id === destinationId; + + // skip top-level because it will be deleted + if (options.merge && docIsDestination) { + continue; + } + + replaceForSingleContact(doc); + } + + return result; + } + + return { move }; +}; + +module.exports = (db, options) => ({ + HIERARCHY_ROOT: Backend.HIERARCHY_ROOT, + move: HierarchyOperations(db, { ...options, merge: false }).move, + merge: HierarchyOperations(db, { ...options, merge: true }).move, +}); + diff --git a/src/lib/hierarchy-operations/jsdocFolder.js b/src/lib/hierarchy-operations/jsdocFolder.js new file mode 100644 index 000000000..a45836c4d --- /dev/null +++ b/src/lib/hierarchy-operations/jsdocFolder.js @@ -0,0 +1,37 @@ +const path = require('path'); +const userPrompt = require('../user-prompt'); +const fs = require('../sync-fs'); +const { warn, trace } = require('../log'); + +function prepareFolder({ docDirectoryPath, force }) { + if (!fs.exists(docDirectoryPath)) { + fs.mkdir(docDirectoryPath); + } else if (!force && fs.recurseFiles(docDirectoryPath).length > 0) { + deleteAfterConfirmation(docDirectoryPath); + } +} + +function writeDoc({ docDirectoryPath }, doc) { + const destinationPath = path.join(docDirectoryPath, `${doc._id}.doc.json`); + if (fs.exists(destinationPath)) { + warn(`File at ${destinationPath} already exists and is being overwritten.`); + } + + trace(`Writing updated document to ${destinationPath}`); + fs.writeJson(destinationPath, doc); +} + +function deleteAfterConfirmation(docDirectoryPath) { + warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`); + if (userPrompt.keyInYN()) { + fs.deleteFilesInFolder(docDirectoryPath); + } else { + throw new Error('User aborted execution.'); + } +} + +module.exports = { + prepareFolder, + writeDoc, +}; + diff --git a/src/lib/hierarchy-operations/lineage-constraints.js b/src/lib/hierarchy-operations/lineage-constraints.js new file mode 100644 index 000000000..5bfbae19f --- /dev/null +++ b/src/lib/hierarchy-operations/lineage-constraints.js @@ -0,0 +1,167 @@ +const log = require('../log'); +const { trace } = log; + +const lineageManipulation = require('./lineage-manipulation'); + +module.exports = async (db, options = {}) => { + const mapTypeToAllowedParents = await fetchAllowedParents(db); + + const getHierarchyErrors = (sourceDoc, destinationDoc) => { + if (options.merge) { + return getMergeViolations(sourceDoc, destinationDoc); + } + + return getMovingViolations(mapTypeToAllowedParents, sourceDoc, destinationDoc); + }; + + return { + getPrimaryContactViolations: (sourceDoc, destinationDoc, descendantDocs) => getPrimaryContactViolations(db, sourceDoc, destinationDoc, descendantDocs), + getHierarchyErrors, + assertHierarchyErrors: (sourceDocs, destinationDoc) => { + sourceDocs.forEach(sourceDoc => { + const hierarchyError = getHierarchyErrors(sourceDoc, destinationDoc); + if (hierarchyError) { + throw Error(`Hierarchy Constraints: ${hierarchyError}`); + } + }); + + /* + It is nice that the tool can move lists of contacts as one operation, but strange things happen when two contactIds are in the same lineage. + For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file. + */ + const contactIds = sourceDocs.map(doc => doc._id); + sourceDocs + .forEach(doc => { + const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || []; + const violatingParentId = parentIdsOfDoc.find(parentId => contactIds.includes(parentId)); + if (violatingParentId) { + throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`); + } + }); + } + }; +}; + +/* +Enforce the list of allowed parents for each contact type +Ensure we are not creating a circular hierarchy +*/ +const getMovingViolations = (mapTypeToAllowedParents, sourceDoc, destinationDoc) => { + function getContactTypeError() { + const sourceContactType = getContactType(sourceDoc); + const destinationType = getContactType(destinationDoc); + const rulesForContact = mapTypeToAllowedParents[sourceContactType]; + if (!rulesForContact) { + return `cannot move contact with unknown type '${sourceContactType}'`; + } + + const isPermittedMoveToRoot = !destinationDoc && rulesForContact.length === 0; + if (!isPermittedMoveToRoot && !rulesForContact.includes(destinationType)) { + return `contacts of type '${sourceContactType}' cannot have parent of type '${destinationType}'`; + } + } + + function findCircularHierarchyErrors() { + if (destinationDoc && sourceDoc._id) { + const parentAncestry = [destinationDoc._id, ...lineageManipulation.pluckIdsFromLineage(destinationDoc.parent)]; + if (parentAncestry.includes(sourceDoc._id)) { + return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' as it would create a circular hierarchy.`; + } + } + } + + if (!mapTypeToAllowedParents) { + return 'hierarchy constraints are undefined'; + } + + const commonViolations = getCommonViolations(sourceDoc, destinationDoc); + const contactTypeError = getContactTypeError(); + const circularHierarchyError = findCircularHierarchyErrors(); + return commonViolations || contactTypeError || circularHierarchyError; +}; + +const getCommonViolations = (sourceDoc, destinationDoc) => { + const sourceContactType = getContactType(sourceDoc); + const destinationContactType = getContactType(destinationDoc); + if (!sourceContactType) { + return `source contact "${sourceDoc._id}" required attribute "type" is undefined`; + } + + if (destinationDoc && !destinationContactType) { + return `destination contact "${destinationDoc._id}" required attribute "type" is undefined`; + } +}; + +const getMergeViolations = (sourceDoc, destinationDoc) => { + const commonViolations = getCommonViolations(sourceDoc, destinationDoc); + if (commonViolations) { + return commonViolations; + } + + const sourceContactType = getContactType(sourceDoc); + const destinationContactType = getContactType(destinationDoc); + if (sourceContactType !== destinationContactType) { + return `source and destinations must have the same type. Source is "${sourceContactType}" while destination is "${destinationContactType}".`; + } + + if (sourceDoc._id === destinationDoc._id) { + return `cannot move contact to destination that is itself`; + } +}; + +/* +A place's primary contact must be a descendant of that place. + +1. Check to see which part of the contact's lineage will be removed +2. For each removed part of the contact's lineage, confirm that place's primary contact isn't being removed. +*/ +const getPrimaryContactViolations = async (db, contactDoc, parentDoc, descendantDocs) => { + const safeGetLineageFromDoc = doc => doc ? lineageManipulation.pluckIdsFromLineage(doc.parent) : []; + const contactsLineageIds = safeGetLineageFromDoc(contactDoc); + const parentsLineageIds = safeGetLineageFromDoc(parentDoc); + + if (parentDoc) { + parentsLineageIds.push(parentDoc._id); + } + + const docIdsRemovedFromContactLineage = contactsLineageIds.filter(value => !parentsLineageIds.includes(value)); + const docsRemovedFromContactLineage = await db.allDocs({ + keys: docIdsRemovedFromContactLineage, + include_docs: true, + }); + + const primaryContactIds = docsRemovedFromContactLineage.rows + .map(row => row?.doc?.contact?._id) + .filter(Boolean); + + return descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId)); +}; + +const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type); + +async function fetchAllowedParents(db) { + try { + const { settings } = await db.get('settings'); + const { contact_types } = settings; + + if (Array.isArray(contact_types)) { + trace('Found app_settings.contact_types. Configurable hierarchy constraints will be enforced.'); + return contact_types + .filter(rule => rule) + .reduce((agg, curr) => Object.assign(agg, { [curr.id]: curr.parents }), {}); + } + } catch (err) { + if (err.name !== 'not_found') { + throw err; + } + } + + trace('Default hierarchy constraints will be enforced.'); + return { + district_hospital: [], + health_center: ['district_hospital'], + clinic: ['health_center'], + person: ['district_hospital', 'health_center', 'clinic'], + }; +} + diff --git a/src/lib/hierarchy-operations/lineage-manipulation.js b/src/lib/hierarchy-operations/lineage-manipulation.js new file mode 100644 index 000000000..f966e330c --- /dev/null +++ b/src/lib/hierarchy-operations/lineage-manipulation.js @@ -0,0 +1,135 @@ + +/** + * Given a doc, replace the lineage information therein with "replaceWith" + * + * @param {Object} doc A CouchDB document containing a hierarchy that needs replacing + * @param {Object} params SonarQube + * @param {string} params.lineageAttribute Name of the attribute which is a lineage in doc (contact or parent) + * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } + * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced + * @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is replaced + */ +function replaceLineage(doc, params) { + const { lineageAttribute, replaceWith, startingFromId, merge } = params; + + // Replace the full lineage + if (!startingFromId) { + return replaceWithinLineage(doc, lineageAttribute, replaceWith); + } + + function getInitialState() { + if (merge) { + return { + element: doc, + attributeName: lineageAttribute, + }; + } + + return { + element: doc[lineageAttribute], + attributeName: 'parent', + }; + } + + function traverseOne() { + const compare = merge ? state.element[state.attributeName] : state.element; + if (compare?._id === startingFromId) { + return replaceWithinLineage(state.element, state.attributeName, replaceWith); + } + + state.element = state.element[state.attributeName]; + state.attributeName = 'parent'; + } + + const state = getInitialState(); + while (state.element) { + const result = traverseOne(); + if (result) { + return result; + } + } + + return false; +} + +const replaceWithinLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { + if (!replaceWith) { + const lineageWasDeleted = !!replaceInDoc[lineageAttributeName]; + replaceInDoc[lineageAttributeName] = undefined; + return lineageWasDeleted; + } else if (replaceInDoc[lineageAttributeName]) { + replaceInDoc[lineageAttributeName]._id = replaceWith._id; + replaceInDoc[lineageAttributeName].parent = replaceWith.parent; + } else { + replaceInDoc[lineageAttributeName] = replaceWith; + } + + return true; +}; + +/* +Function borrowed from shared-lib/lineage +*/ +const minifyLineagesInDoc = doc => { + const minifyLineage = lineage => { + if (!lineage?._id) { + return undefined; + } + + const result = { + _id: lineage._id, + parent: minifyLineage(lineage.parent), + }; + + return result; + }; + + if (!doc) { + return undefined; + } + + if ('parent' in doc) { + doc.parent = minifyLineage(doc.parent); + } + + if ('contact' in doc) { + doc.contact = minifyLineage(doc.contact); + } + + if (doc.type === 'data_record') { + delete doc.patient; + } +}; + +const createLineageFromDoc = doc => { + if (!doc) { + return undefined; + } + + return { + _id: doc._id, + parent: doc.parent || undefined, + }; +}; + +/* +Given a lineage, return the ids therein +*/ +const pluckIdsFromLineage = lineage => { + const result = []; + + let current = lineage; + while (current) { + result.push(current._id); + current = current.parent; + } + + return result; +}; + +module.exports = { + createLineageFromDoc, + minifyLineagesInDoc, + pluckIdsFromLineage, + replaceLineage, +}; diff --git a/src/lib/lineage-constraints.js b/src/lib/lineage-constraints.js deleted file mode 100644 index c0eb59647..000000000 --- a/src/lib/lineage-constraints.js +++ /dev/null @@ -1,94 +0,0 @@ -const log = require('./log'); -const { trace } = log; - -const { pluckIdsFromLineage } = require('./lineage-manipulation'); - -const lineageConstraints = async (repository, parentDoc) => { - let mapTypeToAllowedParents; - try { - const { settings } = await repository.get('settings'); - const { contact_types } = settings; - - if (Array.isArray(contact_types)) { - trace('Found app_settings.contact_types. Configurable hierarchy constraints will be enforced.'); - mapTypeToAllowedParents = contact_types - .filter(rule => rule) - .reduce((agg, curr) => Object.assign(agg, { [curr.id]: curr.parents }), {}); - } - } catch (err) { - if (err.name !== 'not_found') { - throw err; - } - } - - if (!mapTypeToAllowedParents) { - trace('Default hierarchy constraints will be enforced.'); - mapTypeToAllowedParents = { - district_hospital: [], - health_center: ['district_hospital'], - clinic: ['health_center'], - person: ['district_hospital', 'health_center', 'clinic'], - }; - } - - return { - getHierarchyErrors: contactDoc => getHierarchyViolations(mapTypeToAllowedParents, contactDoc, parentDoc), - getPrimaryContactViolations: (contactDoc, descendantDocs) => getPrimaryContactViolations(repository, contactDoc, parentDoc, descendantDocs), - }; -}; - -/* -Enforce the list of allowed parents for each contact type -Ensure we are not creating a circular hierarchy -*/ -const getHierarchyViolations = (mapTypeToAllowedParents, contactDoc, parentDoc) => { - const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type); - const contactType = getContactType(contactDoc); - const parentType = getContactType(parentDoc); - if (!contactType) return 'contact required attribute "type" is undefined'; - if (parentDoc && !parentType) return `parent contact "${parentDoc._id}" required attribute "type" is undefined`; - if (!mapTypeToAllowedParents) return 'hierarchy constraints are undefined'; - - const rulesForContact = mapTypeToAllowedParents[contactType]; - if (!rulesForContact) return `cannot move contact with unknown type '${contactType}'`; - - const isPermittedMoveToRoot = !parentDoc && rulesForContact.length === 0; - if (!isPermittedMoveToRoot && !rulesForContact.includes(parentType)) return `contacts of type '${contactType}' cannot have parent of type '${parentType}'`; - - if (parentDoc && contactDoc._id) { - const parentAncestry = [parentDoc._id, ...pluckIdsFromLineage(parentDoc.parent)]; - if (parentAncestry.includes(contactDoc._id)) { - return `Circular hierarchy: Cannot set parent of contact '${contactDoc._id}' as it would create a circular hierarchy.`; - } - } -}; - -/* -A place's primary contact must be a descendant of that place. - -1. Check to see which part of the contact's lineage will be removed -2. For each removed part of the contact's lineage, confirm that place's primary contact isn't being removed. -*/ -const getPrimaryContactViolations = async (repository, contactDoc, parentDoc, descendantDocs) => { - const safeGetLineageFromDoc = doc => doc ? pluckIdsFromLineage(doc.parent) : []; - const contactsLineageIds = safeGetLineageFromDoc(contactDoc); - const parentsLineageIds = safeGetLineageFromDoc(parentDoc); - - if (parentDoc) { - parentsLineageIds.push(parentDoc._id); - } - - const docIdsRemovedFromContactLineage = contactsLineageIds.filter(value => !parentsLineageIds.includes(value)); - const docsRemovedFromContactLineage = await repository.allDocs({ - keys: docIdsRemovedFromContactLineage, - include_docs: true, - }); - - const primaryContactIds = docsRemovedFromContactLineage.rows - .map(row => row.doc && row.doc.contact && row.doc.contact._id) - .filter(id => id); - - return descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId)); -}; - -module.exports = lineageConstraints; diff --git a/src/lib/lineage-manipulation.js b/src/lib/lineage-manipulation.js deleted file mode 100644 index e87eb7107..000000000 --- a/src/lib/lineage-manipulation.js +++ /dev/null @@ -1,107 +0,0 @@ - -/* -Given a doc, replace the lineage information therein with "replaceWith" - -startingFromIdInLineage (optional) - Will result in a partial replacement of the lineage. Only the part of the lineage "after" the parent -with _id=startingFromIdInLineage will be replaced by "replaceWith" -*/ -const replaceLineage = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage) => { - const handleReplacement = (replaceInDoc, docAttr, replaceWith) => { - if (!replaceWith) { - const lineageWasDeleted = !!replaceInDoc[docAttr]; - replaceInDoc[docAttr] = undefined; - return lineageWasDeleted; - } else if (replaceInDoc[docAttr]) { - replaceInDoc[docAttr]._id = replaceWith._id; - replaceInDoc[docAttr].parent = replaceWith.parent; - } else { - replaceInDoc[docAttr] = replaceWith; - } - - return true; - }; - - // Replace the full lineage - if (!startingFromIdInLineage) { - return handleReplacement(doc, lineageAttributeName, replaceWith); - } - - // Replace part of a lineage - let currentParent = doc[lineageAttributeName]; - while (currentParent) { - if (currentParent._id === startingFromIdInLineage) { - return handleReplacement(currentParent, 'parent', replaceWith); - } - currentParent = currentParent.parent; - } - - return false; -}; - -/* -Function borrowed from shared-lib/lineage -*/ -const minifyLineagesInDoc = doc => { - const minifyLineage = lineage => { - if (!lineage || !lineage._id) { - return undefined; - } - - const result = { - _id: lineage._id, - parent: minifyLineage(lineage.parent), - }; - - return result; - }; - - if (!doc) { - return undefined; - } - - if ('parent' in doc) { - doc.parent = minifyLineage(doc.parent); - } - - if ('contact' in doc) { - doc.contact = minifyLineage(doc.contact); - if (doc.contact && !doc.contact.parent) delete doc.contact.parent; // for unit test clarity - } - - if (doc.type === 'data_record') { - delete doc.patient; - } -}; - -const createLineageFromDoc = doc => { - if (!doc) { - return undefined; - } - - return { - _id: doc._id, - parent: doc.parent || undefined, - }; -}; - -/* -Given a lineage, return the ids therein -*/ -const pluckIdsFromLineage = lineage => { - const result = []; - - let current = lineage; - while (current) { - result.push(current._id); - current = current.parent; - } - - return result; -}; - -module.exports = { - createLineageFromDoc, - minifyLineagesInDoc, - pluckIdsFromLineage, - replaceLineage, -}; diff --git a/test/fn/merge-contacts.spec.js b/test/fn/merge-contacts.spec.js new file mode 100644 index 000000000..c4f519ad5 --- /dev/null +++ b/test/fn/merge-contacts.spec.js @@ -0,0 +1,26 @@ +const { expect } = require('chai'); +const rewire = require('rewire'); +const Mergeremove = rewire('../../src/fn/merge-contacts'); +const parseExtraArgs = Mergeremove.__get__('parseExtraArgs'); + +describe('merge-contacts', () => { + describe('parseExtraArgs', () => { + it('undefined arguments', () => { + expect(() => parseExtraArgs(__dirname, undefined)).to.throw('required contact'); + }); + + it('empty arguments', () => expect(() => parseExtraArgs(__dirname, [])).to.throw('required contact')); + + it('remove only', () => expect(() => parseExtraArgs(__dirname, ['--remove=a'])).to.throw('required contact')); + + it('remove and keeps', () => { + const args = ['--remove=food,is,tasty', '--keep=bar', '--docDirectoryPath=/', '--force=hi']; + expect(parseExtraArgs(__dirname, args)).to.deep.eq({ + sourceIds: ['food', 'is', 'tasty'], + destinationId: 'bar', + force: true, + docDirectoryPath: '/', + }); + }); + }); +}); diff --git a/test/fn/move-contacts.spec.js b/test/fn/move-contacts.spec.js index a7f471282..60068c13b 100644 --- a/test/fn/move-contacts.spec.js +++ b/test/fn/move-contacts.spec.js @@ -1,535 +1,11 @@ -const { assert, expect } = require('chai'); +const { expect } = require('chai'); const rewire = require('rewire'); -const sinon = require('sinon'); -const fs = require('../../src/lib/sync-fs'); -const environment = require('../../src/lib/environment'); - -const PouchDB = require('pouchdb-core'); -PouchDB.plugin(require('pouchdb-adapter-memory')); -PouchDB.plugin(require('pouchdb-mapreduce')); - -const moveContactsModule = rewire('../../src/fn/move-contacts'); -moveContactsModule.__set__('prepareDocumentDirectory', () => {}); -const updateLineagesAndStage = moveContactsModule.__get__('updateLineagesAndStage'); -const { mockReport, mockHierarchy, parentsToLineage } = require('../mock-hierarchies'); - -const contacts_by_depth = { - // eslint-disable-next-line quotes - map: "function(doc) {\n if (doc.type === 'tombstone' && doc.tombstone) {\n doc = doc.tombstone;\n }\n if (['contact', 'person', 'clinic', 'health_center', 'district_hospital'].indexOf(doc.type) !== -1) {\n var value = doc.patient_id || doc.place_id;\n var parent = doc;\n var depth = 0;\n while (parent) {\n if (parent._id) {\n emit([parent._id], value);\n emit([parent._id, depth], value);\n }\n depth++;\n parent = parent.parent;\n }\n }\n}", -}; - -const reports_by_freetext = { - // eslint-disable-next-line quotes - map: "function(doc) {\n var skip = [ '_id', '_rev', 'type', 'refid', 'content' ];\n\n var usedKeys = [];\n var emitMaybe = function(key, value) {\n if (usedKeys.indexOf(key) === -1 && // Not already used\n key.length > 2 // Not too short\n ) {\n usedKeys.push(key);\n emit([key], value);\n }\n };\n\n var emitField = function(key, value, reportedDate) {\n if (!key || !value) {\n return;\n }\n key = key.toLowerCase();\n if (skip.indexOf(key) !== -1 || /_date$/.test(key)) {\n return;\n }\n if (typeof value === 'string') {\n value = value.toLowerCase();\n value.split(/\\s+/).forEach(function(word) {\n emitMaybe(word, reportedDate);\n });\n }\n if (typeof value === 'number' || typeof value === 'string') {\n emitMaybe(key + ':' + value, reportedDate);\n }\n };\n\n if (doc.type === 'data_record' && doc.form) {\n Object.keys(doc).forEach(function(key) {\n emitField(key, doc[key], doc.reported_date);\n });\n if (doc.fields) {\n Object.keys(doc.fields).forEach(function(key) {\n emitField(key, doc.fields[key], doc.reported_date);\n });\n }\n if (doc.contact && doc.contact._id) {\n emitMaybe('contact:' + doc.contact._id.toLowerCase(), doc.reported_date);\n }\n }\n}" -}; +const MoveContacts = rewire('../../src/fn/move-contacts'); +const parseExtraArgs = MoveContacts.__get__('parseExtraArgs'); describe('move-contacts', () => { - - let pouchDb, scenarioCount = 0; - const writtenDocs = []; - const getWrittenDoc = docId => { - const matches = writtenDocs.filter(doc => doc && doc._id === docId); - if (matches.length === 0) { - return undefined; - } - - // Remove _rev because it makes expectations harder to write - const result = matches[matches.length - 1]; - delete result._rev; - return result; - }; - const expectWrittenDocs = expected => expect(writtenDocs.map(doc => doc._id)).to.deep.eq(expected); - - const upsert = async (id, content) => { - const { _rev } = await pouchDb.get(id); - await pouchDb.put(Object.assign({ - _id: id, - _rev, - }, content)); - }; - const updateHierarchyRules = contact_types => upsert('settings', { settings: { contact_types } }); - - beforeEach(async () => { - pouchDb = new PouchDB(`move-contacts-${scenarioCount++}`); - - await mockHierarchy(pouchDb, { - district_1: { - health_center_1: { - clinic_1: { - patient_1: {}, - }, - }, - }, - district_2: {}, - }); - - await pouchDb.put({ _id: 'settings', settings: {} }); - - await mockReport(pouchDb, { - id: 'report_1', - creatorId: 'health_center_1_contact', - }); - - await pouchDb.put({ - _id: '_design/medic-client', - views: { reports_by_freetext }, - }); - - await pouchDb.put({ - _id: '_design/medic', - views: { contacts_by_depth }, - }); - - moveContactsModule.__set__('writeDocumentToDisk', (docDirectoryPath, doc) => writtenDocs.push(doc)); - writtenDocs.length = 0; - }); - - afterEach(async () => pouchDb.destroy()); - - it('move health_center_1 to district_2', async () => { - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'district_2', - }, pouchDb); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_2'), - parent: parentsToLineage('health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - }); - }); - - it('move health_center_1 to root', async () => { - sinon.spy(pouchDb, 'query'); - - await updateHierarchyRules([{ id: 'health_center', parents: [] }]); - - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'root', - }, pouchDb); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1'), - parent: parentsToLineage(), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1'), - parent: parentsToLineage('health_center_1'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1'), - }); - - const contactIdsKeys = [ - ['contact:clinic_1'], - ['contact:clinic_1_contact'], - ['contact:health_center_1'], - ['contact:health_center_1_contact'], - ['contact:patient_1'] - ]; - expect(pouchDb.query.callCount).to.equal(2); - expect(pouchDb.query.args).to.deep.equal([ - ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 10000, skip: 0, group_level: undefined }], - ]); - }); - - it('move district_1 from root', async () => { - await updateHierarchyRules([{ id: 'district_hospital', parents: ['district_hospital'] }]); - - await updateLineagesAndStage({ - contactIds: ['district_1'], - parentId: 'district_2', - }, pouchDb); - - expect(getWrittenDoc('district_1')).to.deep.eq({ - _id: 'district_1', - type: 'district_hospital', - contact: parentsToLineage('district_1_contact', 'district_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), - parent: parentsToLineage('district_1', 'district_2'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'district_2'), - parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'district_2'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), - }); - }); - - it('move district_1 to flexible hierarchy parent', async () => { - await pouchDb.put({ - _id: `county_1`, - type: 'contact', - contact_type: 'county', - }); - - await updateHierarchyRules([ - { id: 'county', parents: [] }, - { id: 'district_hospital', parents: ['county'] }, - ]); - - await updateLineagesAndStage({ - contactIds: ['district_1'], - parentId: 'county_1', - }, pouchDb); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), - parent: parentsToLineage('district_1', 'county_1'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'county_1'), - parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'county_1'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), - }); - }); - - it('moves flexible hierarchy contact to flexible hierarchy parent', async () => { - await updateHierarchyRules([ - { id: 'county', parents: [] }, - { id: 'subcounty', parents: ['county'] }, - { id: 'focal', parents: ['county', 'subcounty'], person: true } - ]); - - await pouchDb.bulkDocs([ - { _id: `county`, type: 'contact', contact_type: 'county' }, - { _id: `subcounty`, type: 'contact', contact_type: 'subcounty', parent: { _id: 'county' } }, - { _id: `focal`, type: 'contact', contact_type: 'focal', parent: { _id: 'county' } }, - ]); - - await mockReport(pouchDb, { - id: 'report_focal', - creatorId: 'focal', - }); - - await updateLineagesAndStage({ - contactIds: ['focal'], - parentId: 'subcounty', - }, pouchDb); - - expect(getWrittenDoc('focal')).to.deep.eq({ - _id: 'focal', - type: 'contact', - contact_type: 'focal', - parent: parentsToLineage('subcounty', 'county'), - }); - - expect(getWrittenDoc('report_focal')).to.deep.eq({ - _id: 'report_focal', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('focal', 'subcounty', 'county'), - }); - }); - - it('moving primary contact updates parents', async () => { - await mockHierarchy(pouchDb, { - t_district_1: { - t_health_center_1: { - t_clinic_1: { - t_patient_1: {}, - }, - t_clinic_2: { - t_patient_2: {}, - } - }, - }, - }); - - const patient1Lineage = parentsToLineage('t_patient_1', 't_clinic_1', 't_health_center_1', 't_district_1'); - await upsert('t_health_center_1', { - type: 'health_center', - contact: patient1Lineage, - parent: parentsToLineage('t_district_1'), - }); - - await upsert('t_district_1', { - type: 'district_hospital', - contact: patient1Lineage, - parent: parentsToLineage(), - }); - - await updateLineagesAndStage({ - contactIds: ['t_patient_1'], - parentId: 't_clinic_2', - }, pouchDb); - - expect(getWrittenDoc('t_health_center_1')).to.deep.eq({ - _id: 't_health_center_1', - type: 'health_center', - contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), - parent: parentsToLineage('t_district_1'), - }); - - expect(getWrittenDoc('t_district_1')).to.deep.eq({ - _id: 't_district_1', - type: 'district_hospital', - contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), - }); - - expectWrittenDocs(['t_patient_1', 't_district_1', 't_health_center_1']); - }); - - // We don't want lineage { id, parent: '' } to result from district_hospitals which have parent: '' - it('district_hospital with empty string parent is not preserved', async () => { - await upsert('district_2', { parent: '', type: 'district_hospital' }); - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'district_2', - }, pouchDb); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); - }); - - it('documents should be minified', async () => { - await updateHierarchyRules([{ id: 'clinic', parents: ['district_hospital'] }]); - const patient = { - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), - type: 'person', - important: true, - }; - const clinic = { - parent: parentsToLineage('health_center_1', 'district_1'), - type: 'clinic', - important: true, - }; - patient.parent.important = false; - clinic.parent.parent.important = false; - - await upsert('clinic_1', clinic); - await upsert('patient_1', patient); - - await updateLineagesAndStage({ - contactIds: ['clinic_1'], - parentId: 'district_2', - }, pouchDb); - - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - important: true, - parent: parentsToLineage('district_2'), - }); - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - important: true, - parent: parentsToLineage('clinic_1', 'district_2'), - }); - }); - - it('cannot create circular hierarchy', async () => { - // even if the hierarchy rules allow it - await updateHierarchyRules([{ id: 'health_center', parents: ['clinic'] }]); - - try { - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'clinic_1', - }, pouchDb); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('circular'); - } - }); - - it('throw if parent does not exist', async () => { - try { - await updateLineagesAndStage({ - contactIds: ['clinic_1'], - parentId: 'dne_parent_id' - }, pouchDb); - assert.fail('should throw when parent is not defined'); - } catch (err) { - expect(err.message).to.include('could not be found'); - } - }); - - it('throw when altering same lineage', async () => { - try { - await updateLineagesAndStage({ - contactIds: ['patient_1', 'health_center_1'], - parentId: 'district_2', - }, pouchDb); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('same lineage'); - } - }); - - it('throw if contact_id does not exist', async () => { - try { - await updateLineagesAndStage({ - contactIds: ['dne'], - parentId: 'clinic_1' - }, pouchDb); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('could not be found'); - } - }); - - it('throw if contact_id is not a contact', async () => { - try { - await updateLineagesAndStage({ - contactIds: ['report_1'], - parentId: 'clinic_1' - }, pouchDb); - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('unknown type'); - } - }); - - it('throw if moving primary contact of parent', async () => { - try { - await updateLineagesAndStage({ - contactIds: ['clinic_1_contact'], - parentId: 'district_1' - }, pouchDb); - - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('primary contact'); - } - }); - - it('throw if setting parent to self', async () => { - await updateHierarchyRules([{ id: 'clinic', parents: ['clinic'] }]); - try { - await updateLineagesAndStage({ - contactIds: ['clinic_1'], - parentId: 'clinic_1' - }, pouchDb); - - assert.fail('should throw'); - } catch (err) { - expect(err.message).to.include('circular'); - } - }); - - it('throw when moving place to unconfigured parent', async () => { - await updateHierarchyRules([{ id: 'district_hospital', parents: [] }]); - - try { - await updateLineagesAndStage({ - contactIds: ['district_1'], - parentId: 'district_2', - }, pouchDb); - - assert.fail('Expected error'); - } catch (err) { - expect(err.message).to.include('parent of type'); - } - }); - describe('parseExtraArgs', () => { - const parseExtraArgs = moveContactsModule.__get__('parseExtraArgs'); + // const parseExtraArgs = MoveContactsLib.__get__('parseExtraArgs'); it('undefined arguments', () => { expect(() => parseExtraArgs(__dirname, undefined)).to.throw('required list of contacts'); }); @@ -541,227 +17,11 @@ describe('move-contacts', () => { it('contacts and parents', () => { const args = ['--contacts=food,is,tasty', '--parent=bar', '--docDirectoryPath=/', '--force=hi']; expect(parseExtraArgs(__dirname, args)).to.deep.eq({ - contactIds: ['food', 'is', 'tasty'], - parentId: 'bar', + sourceIds: ['food', 'is', 'tasty'], + destinationId: 'bar', force: true, docDirectoryPath: '/', }); }); }); - - let readline; - describe('prepareDocumentDirectory', () => { - const moveContacts = rewire('../../src/fn/move-contacts'); - const userPrompt = rewire('../../src/lib/user-prompt'); - const prepareDocDir = moveContacts.__get__('prepareDocumentDirectory'); - let docOnj = { docDirectoryPath: '/test/path/for/testing ', force: false }; - beforeEach(() => { - readline = { keyInYN: sinon.stub() }; - userPrompt.__set__('readline', readline); - moveContacts.__set__('userPrompt', userPrompt); - sinon.stub(fs, 'exists').returns(true); - sinon.stub(fs, 'recurseFiles').returns(Array(20)); - sinon.stub(fs, 'deleteFilesInFolder').returns(true); - }); - afterEach(() => { - sinon.restore(); - }); - - it('does not delete files in directory when user presses n', () => { - readline.keyInYN.returns(false); - sinon.stub(environment, 'force').get(() => false); - try { - prepareDocDir(docOnj); - assert.fail('Expected error to be thrown'); - } catch(e) { - assert.equal(fs.deleteFilesInFolder.callCount, 0); - } - }); - - it('deletes files in directory when user presses y', () => { - readline.keyInYN.returns(true); - sinon.stub(environment, 'force').get(() => false); - prepareDocDir(docOnj); - assert.equal(fs.deleteFilesInFolder.callCount, 1); - }); - - it('deletes files in directory when force is set', () => { - sinon.stub(environment, 'force').get(() => true); - prepareDocDir(docOnj); - assert.equal(fs.deleteFilesInFolder.callCount, 1); - }); - }); - - describe('batching works as expected', () => { - let defaultBatchSize; - beforeEach(async () => { - defaultBatchSize = moveContactsModule.__get__('BATCH_SIZE'); - await mockReport(pouchDb, { - id: 'report_2', - creatorId: 'health_center_1_contact', - }); - - await mockReport(pouchDb, { - id: 'report_3', - creatorId: 'health_center_1_contact', - }); - - await mockReport(pouchDb, { - id: 'report_4', - creatorId: 'health_center_1_contact', - }); - }); - - afterEach(() => { - moveContactsModule.__set__('BATCH_SIZE', defaultBatchSize); - }); - - it('move health_center_1 to district_2 in batches of 1', async () => { - moveContactsModule.__set__('BATCH_SIZE', 1); - sinon.spy(pouchDb, 'query'); - - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'district_2', - }, pouchDb); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - parent: parentsToLineage('district_2'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_2'), - parent: parentsToLineage('health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_2')).to.deep.eq({ - _id: 'report_2', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - }); - - expect(getWrittenDoc('report_3')).to.deep.eq({ - _id: 'report_3', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), - }); - - expect(pouchDb.query.callCount).to.deep.equal(6); - - const contactIdsKeys = [ - ['contact:clinic_1'], - ['contact:clinic_1_contact'], - ['contact:health_center_1'], - ['contact:health_center_1_contact'], - ['contact:patient_1'] - ]; - expect(pouchDb.query.args).to.deep.equal([ - ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 0, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 1, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 2, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 3, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 4, group_level: undefined }], - ]); - }); - - it('should health_center_1 to district_1 in batches of 2', async () => { - moveContactsModule.__set__('BATCH_SIZE', 2); - sinon.spy(pouchDb, 'query'); - - await updateLineagesAndStage({ - contactIds: ['health_center_1'], - parentId: 'district_1', - }, pouchDb); - - expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ - _id: 'health_center_1_contact', - type: 'person', - parent: parentsToLineage('health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('health_center_1')).to.deep.eq({ - _id: 'health_center_1', - type: 'health_center', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - parent: parentsToLineage('district_1'), - }); - - expect(getWrittenDoc('clinic_1')).to.deep.eq({ - _id: 'clinic_1', - type: 'clinic', - contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1'), - parent: parentsToLineage('health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('patient_1')).to.deep.eq({ - _id: 'patient_1', - type: 'person', - parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('report_1')).to.deep.eq({ - _id: 'report_1', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('report_2')).to.deep.eq({ - _id: 'report_2', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - }); - - expect(getWrittenDoc('report_3')).to.deep.eq({ - _id: 'report_3', - form: 'foo', - type: 'data_record', - contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), - }); - - expect(pouchDb.query.callCount).to.deep.equal(4); - - const contactIdsKeys = [ - ['contact:clinic_1'], - ['contact:clinic_1_contact'], - ['contact:health_center_1'], - ['contact:health_center_1_contact'], - ['contact:patient_1'] - ]; - expect(pouchDb.query.args).to.deep.equal([ - ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 0, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 2, group_level: undefined }], - ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 4, group_level: undefined }] - ]); - }); - }); }); diff --git a/test/lib/hierarchy-operations/hierarchy-operations.spec.js b/test/lib/hierarchy-operations/hierarchy-operations.spec.js new file mode 100644 index 000000000..3d6eee0ab --- /dev/null +++ b/test/lib/hierarchy-operations/hierarchy-operations.spec.js @@ -0,0 +1,764 @@ +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const rewire = require('rewire'); +const sinon = require('sinon'); + +const { mockReport, mockHierarchy, parentsToLineage } = require('../../mock-hierarchies'); +const JsDocs = rewire('../../../src/lib/hierarchy-operations/jsdocFolder.js'); +const Backend = rewire('../../../src/lib/hierarchy-operations/backend.js'); + +const PouchDB = require('pouchdb-core'); + +chai.use(chaiAsPromised); +PouchDB.plugin(require('pouchdb-adapter-memory')); +PouchDB.plugin(require('pouchdb-mapreduce')); + +const { assert, expect } = chai; + +const HierarchyOperations = rewire('../../../src/lib/hierarchy-operations/index.js'); +HierarchyOperations.__set__('JsDocs', JsDocs); +HierarchyOperations.__set__('Backend', Backend); + +const contacts_by_depth = { + // eslint-disable-next-line quotes + map: "function(doc) {\n if (doc.type === 'tombstone' && doc.tombstone) {\n doc = doc.tombstone;\n }\n if (['contact', 'person', 'clinic', 'health_center', 'district_hospital'].indexOf(doc.type) !== -1) {\n var value = doc.patient_id || doc.place_id;\n var parent = doc;\n var depth = 0;\n while (parent) {\n if (parent._id) {\n emit([parent._id], value);\n emit([parent._id, depth], value);\n }\n depth++;\n parent = parent.parent;\n }\n }\n}", +}; + +const reports_by_freetext = { + // eslint-disable-next-line quotes + map: "function(doc) {\n var skip = [ '_id', '_rev', 'type', 'refid', 'content' ];\n\n var usedKeys = [];\n var emitMaybe = function(key, value) {\n if (usedKeys.indexOf(key) === -1 && // Not already used\n key.length > 2 // Not too short\n ) {\n usedKeys.push(key);\n emit([key], value);\n }\n };\n\n var emitField = function(key, value, reportedDate) {\n if (!key || !value) {\n return;\n }\n key = key.toLowerCase();\n if (skip.indexOf(key) !== -1 || /_date$/.test(key)) {\n return;\n }\n if (typeof value === 'string') {\n value = value.toLowerCase();\n value.split(/\\s+/).forEach(function(word) {\n emitMaybe(word, reportedDate);\n });\n }\n if (typeof value === 'number' || typeof value === 'string') {\n emitMaybe(key + ':' + value, reportedDate);\n }\n };\n\n if (doc.type === 'data_record' && doc.form) {\n Object.keys(doc).forEach(function(key) {\n emitField(key, doc[key], doc.reported_date);\n });\n if (doc.fields) {\n Object.keys(doc.fields).forEach(function(key) {\n emitField(key, doc.fields[key], doc.reported_date);\n });\n }\n if (doc.contact && doc.contact._id) {\n emitMaybe('contact:' + doc.contact._id.toLowerCase(), doc.reported_date);\n }\n }\n}" +}; + +describe('move-contacts', () => { + let pouchDb, scenarioCount = 0; + const writtenDocs = []; + const getWrittenDoc = docId => { + const matches = writtenDocs.filter(doc => doc && doc._id === docId); + if (matches.length === 0) { + return undefined; + } + + // Remove _rev because it makes expectations harder to write + const result = matches[matches.length - 1]; + delete result._rev; + return result; + }; + const expectWrittenDocs = expected => expect(writtenDocs.map(doc => doc._id)).to.have.members(expected); + + const upsert = async (id, content) => { + const { _rev } = await pouchDb.get(id); + await pouchDb.put(Object.assign({ + _id: id, + _rev, + }, content)); + }; + const updateHierarchyRules = contact_types => upsert('settings', { settings: { contact_types } }); + + beforeEach(async () => { + pouchDb = new PouchDB(`move-contacts-${scenarioCount++}`); + + await mockHierarchy(pouchDb, { + district_1: { + health_center_1: { + clinic_1: { + patient_1: {}, + }, + }, + }, + district_2: { + health_center_2: { + clinic_2: { + patient_2: {}, + } + } + }, + }); + + await pouchDb.put({ _id: 'settings', settings: {} }); + + await mockReport(pouchDb, { + id: 'report_1', + creatorId: 'health_center_1_contact', + }); + + await pouchDb.put({ + _id: '_design/medic-client', + views: { reports_by_freetext }, + }); + + await pouchDb.put({ + _id: '_design/medic', + views: { contacts_by_depth }, + }); + + JsDocs.writeDoc = (docDirectoryPath, doc) => writtenDocs.push(doc); + JsDocs.prepareFolder = () => {}; + writtenDocs.length = 0; + }); + + afterEach(async () => pouchDb.destroy()); + + it('move health_center_1 to district_2', async () => { + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); + + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + parent: parentsToLineage('district_2'), + }); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_2'), + parent: parentsToLineage('health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + }); + }); + + it('move health_center_1 to root', async () => { + sinon.spy(pouchDb, 'query'); + + await updateHierarchyRules([{ id: 'health_center', parents: [] }]); + + await HierarchyOperations(pouchDb).move(['health_center_1'], 'root'); + + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1'), + }); + + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1'), + parent: parentsToLineage(), + }); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1'), + parent: parentsToLineage('health_center_1'), + }); + + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1'), + }); + + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1'), + }); + + const contactIdsKeys = [ + ['contact:clinic_1'], + ['contact:clinic_1_contact'], + ['contact:health_center_1'], + ['contact:health_center_1_contact'], + ['contact:patient_1'] + ]; + expect(pouchDb.query.callCount).to.equal(2); + expect(pouchDb.query.args).to.deep.equal([ + ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 10000, skip: 0, group_level: undefined }], + ]); + }); + + it('move district_1 from root', async () => { + await updateHierarchyRules([{ id: 'district_hospital', parents: ['district_hospital'] }]); + + await HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); + + expect(getWrittenDoc('district_1')).to.deep.eq({ + _id: 'district_1', + type: 'district_hospital', + contact: parentsToLineage('district_1_contact', 'district_1', 'district_2'), + parent: parentsToLineage('district_2'), + }); + + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), + }); + + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), + parent: parentsToLineage('district_1', 'district_2'), + }); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'district_2'), + parent: parentsToLineage('health_center_1', 'district_1', 'district_2'), + }); + + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'district_2'), + }); + + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'district_2'), + }); + }); + + it('move district_1 to flexible hierarchy parent', async () => { + await pouchDb.put({ + _id: `county_1`, + type: 'contact', + contact_type: 'county', + }); + + await updateHierarchyRules([ + { id: 'county', parents: [] }, + { id: 'district_hospital', parents: ['county'] }, + ]); + + await HierarchyOperations(pouchDb).move(['district_1'], 'county_1'); + + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), + }); + + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), + parent: parentsToLineage('district_1', 'county_1'), + }); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1', 'county_1'), + parent: parentsToLineage('health_center_1', 'district_1', 'county_1'), + }); + + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1', 'county_1'), + }); + + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1', 'county_1'), + }); + }); + + it('moves flexible hierarchy contact to flexible hierarchy parent', async () => { + await updateHierarchyRules([ + { id: 'county', parents: [] }, + { id: 'subcounty', parents: ['county'] }, + { id: 'focal', parents: ['county', 'subcounty'], person: true } + ]); + + await pouchDb.bulkDocs([ + { _id: `county`, type: 'contact', contact_type: 'county' }, + { _id: `subcounty`, type: 'contact', contact_type: 'subcounty', parent: { _id: 'county' } }, + { _id: `focal`, type: 'contact', contact_type: 'focal', parent: { _id: 'county' } }, + ]); + + await mockReport(pouchDb, { + id: 'report_focal', + creatorId: 'focal', + }); + + await HierarchyOperations(pouchDb).move(['focal'], 'subcounty'); + + expect(getWrittenDoc('focal')).to.deep.eq({ + _id: 'focal', + type: 'contact', + contact_type: 'focal', + parent: parentsToLineage('subcounty', 'county'), + }); + + expect(getWrittenDoc('report_focal')).to.deep.eq({ + _id: 'report_focal', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('focal', 'subcounty', 'county'), + }); + }); + + it('moving primary contact updates parents', async () => { + await mockHierarchy(pouchDb, { + t_district_1: { + t_health_center_1: { + t_clinic_1: { + t_patient_1: {}, + }, + t_clinic_2: { + t_patient_2: {}, + } + }, + }, + }); + + const patient1Lineage = parentsToLineage('t_patient_1', 't_clinic_1', 't_health_center_1', 't_district_1'); + await upsert('t_health_center_1', { + type: 'health_center', + contact: patient1Lineage, + parent: parentsToLineage('t_district_1'), + }); + + await upsert('t_district_1', { + type: 'district_hospital', + contact: patient1Lineage, + parent: parentsToLineage(), + }); + + await HierarchyOperations(pouchDb).move(['t_patient_1'], 't_clinic_2'); + + expect(getWrittenDoc('t_health_center_1')).to.deep.eq({ + _id: 't_health_center_1', + type: 'health_center', + contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), + parent: parentsToLineage('t_district_1'), + }); + + expect(getWrittenDoc('t_district_1')).to.deep.eq({ + _id: 't_district_1', + type: 'district_hospital', + contact: parentsToLineage('t_patient_1', 't_clinic_2', 't_health_center_1', 't_district_1'), + }); + + expectWrittenDocs(['t_patient_1', 't_district_1', 't_health_center_1']); + }); + + // We don't want lineage { id, parent: '' } to result from district_hospitals which have parent: '' + it('district_hospital with empty string parent is not preserved', async () => { + await upsert('district_2', { parent: '', type: 'district_hospital' }); + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); + + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + parent: parentsToLineage('district_2'), + }); + }); + + describe('merging', () => { + it('merge district_2 into district_1', async () => { + // setup + await mockReport(pouchDb, { + id: 'changing_subject_and_contact', + creatorId: 'health_center_2_contact', + patientId: 'district_2' + }); + + await mockReport(pouchDb, { + id: 'changing_contact', + creatorId: 'health_center_2_contact', + patientId: 'patient_2' + }); + + await mockReport(pouchDb, { + id: 'changing_subject', + patientId: 'district_2' + }); + + // action + await HierarchyOperations(pouchDb).merge(['district_2'], 'district_1'); + + // assert + expectWrittenDocs([ + 'district_2', 'district_2_contact', + 'health_center_2', 'health_center_2_contact', + 'clinic_2', 'clinic_2_contact', + 'patient_2', + 'changing_subject_and_contact', 'changing_contact', 'changing_subject' + ]); + + expect(getWrittenDoc('district_2')).to.deep.eq({ + _id: 'district_2', + _deleted: true, + }); + + expect(getWrittenDoc('health_center_2')).to.deep.eq({ + _id: 'health_center_2', + type: 'health_center', + contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), + parent: parentsToLineage('district_1'), + }); + + expect(getWrittenDoc('clinic_2')).to.deep.eq({ + _id: 'clinic_2', + type: 'clinic', + contact: parentsToLineage('clinic_2_contact', 'clinic_2', 'health_center_2', 'district_1'), + parent: parentsToLineage('health_center_2', 'district_1'), + }); + + expect(getWrittenDoc('patient_2')).to.deep.eq({ + _id: 'patient_2', + type: 'person', + parent: parentsToLineage('clinic_2', 'health_center_2', 'district_1'), + }); + + expect(getWrittenDoc('changing_subject_and_contact')).to.deep.eq({ + _id: 'changing_subject_and_contact', + form: 'foo', + type: 'data_record', + contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), + fields: { + patient_uuid: 'district_1' + } + }); + + expect(getWrittenDoc('changing_contact')).to.deep.eq({ + _id: 'changing_contact', + form: 'foo', + type: 'data_record', + contact: parentsToLineage('health_center_2_contact', 'health_center_2', 'district_1'), + fields: { + patient_uuid: 'patient_2' + } + }); + + expect(getWrittenDoc('changing_subject')).to.deep.eq({ + _id: 'changing_subject', + form: 'foo', + type: 'data_record', + contact: { + _id: 'dne', + parent: undefined, + }, + fields: { + patient_uuid: 'district_1' + } + }); + }); + + it('merge two patients', async () => { + // setup + await mockReport(pouchDb, { + id: 'pat1', + creatorId: 'clinic_1_contact', + patientId: 'patient_1' + }); + + await mockReport(pouchDb, { + id: 'pat2', + creatorId: 'clinic_2_contact', + patientId: 'patient_2' + }); + + // action + await HierarchyOperations(pouchDb).merge(['patient_2'], 'patient_1'); + + await expectWrittenDocs(['patient_2', 'pat2']); + + expect(getWrittenDoc('patient_2')).to.deep.eq({ + _id: 'patient_2', + _deleted: true, + }); + + expect(getWrittenDoc('pat2')).to.deep.eq({ + _id: 'pat2', + form: 'foo', + type: 'data_record', + // still created by the user in district-2 + contact: parentsToLineage('clinic_2_contact', 'clinic_2', 'health_center_2', 'district_2'), + fields: { + patient_uuid: 'patient_1' + } + }); + }); + }); + + it('documents should be minified', async () => { + await updateHierarchyRules([{ id: 'clinic', parents: ['district_hospital'] }]); + const patient = { + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), + type: 'person', + important: true, + }; + const clinic = { + parent: parentsToLineage('health_center_1', 'district_1'), + type: 'clinic', + important: true, + }; + patient.parent.important = false; + clinic.parent.parent.important = false; + + await upsert('clinic_1', clinic); + await upsert('patient_1', patient); + + await HierarchyOperations(pouchDb).move(['clinic_1'], 'district_2'); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + important: true, + parent: parentsToLineage('district_2'), + }); + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + important: true, + parent: parentsToLineage('clinic_1', 'district_2'), + }); + }); + + it('cannot create circular hierarchy', async () => { + // even if the hierarchy rules allow it + await updateHierarchyRules([{ id: 'health_center', parents: ['clinic'] }]); + + try { + await HierarchyOperations(pouchDb).move(['health_center_1'], 'clinic_1'); + assert.fail('should throw'); + } catch (err) { + expect(err.message).to.include('circular'); + } + }); + + it('throw if parent does not exist', async () => { + const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'dne_parent_id'); + await expect(actual).to.eventually.rejectedWith('could not be found'); + }); + + it('throw when altering same lineage', async () => { + const actual = HierarchyOperations(pouchDb).move(['patient_1', 'health_center_1'], 'district_2'); + await expect(actual).to.eventually.rejectedWith('same lineage'); + }); + + it('throw if contact_id is not a contact', async () => { + const actual = HierarchyOperations(pouchDb).move(['report_1'], 'clinic_1'); + await expect(actual).to.eventually.rejectedWith('unknown type'); + }); + + it('throw if moving primary contact of parent', async () => { + const actual = HierarchyOperations(pouchDb).move(['clinic_1_contact'], 'district_1'); + await expect(actual).to.eventually.rejectedWith('primary contact'); + }); + + it('throw if setting parent to self', async () => { + await updateHierarchyRules([{ id: 'clinic', parents: ['clinic'] }]); + const actual = HierarchyOperations(pouchDb).move(['clinic_1'], 'clinic_1'); + await expect(actual).to.eventually.rejectedWith('circular'); + }); + + it('throw when moving place to unconfigured parent', async () => { + await updateHierarchyRules([{ id: 'district_hospital', parents: [] }]); + const actual = HierarchyOperations(pouchDb).move(['district_1'], 'district_2'); + await expect(actual).to.eventually.rejectedWith('parent of type'); + }); + + describe('batching works as expected', () => { + const initialBatchSize = Backend.BATCH_SIZE; + beforeEach(async () => { + await mockReport(pouchDb, { + id: 'report_2', + creatorId: 'health_center_1_contact', + }); + + await mockReport(pouchDb, { + id: 'report_3', + creatorId: 'health_center_1_contact', + }); + + await mockReport(pouchDb, { + id: 'report_4', + creatorId: 'health_center_1_contact', + }); + }); + + afterEach(() => { + Backend.BATCH_SIZE = initialBatchSize; + Backend.__set__('BATCH_SIZE', initialBatchSize); + }); + + it('move health_center_1 to district_2 in batches of 1', async () => { + Backend.__set__('BATCH_SIZE', 1); + Backend.BATCH_SIZE = 1; + sinon.spy(pouchDb, 'query'); + + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_2'); + + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + parent: parentsToLineage('district_2'), + }); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_2'), + parent: parentsToLineage('health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('report_2')).to.deep.eq({ + _id: 'report_2', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + }); + + expect(getWrittenDoc('report_3')).to.deep.eq({ + _id: 'report_3', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_2'), + }); + + expect(pouchDb.query.callCount).to.deep.equal(6); + + const contactIdsKeys = [ + ['contact:clinic_1'], + ['contact:clinic_1_contact'], + ['contact:health_center_1'], + ['contact:health_center_1_contact'], + ['contact:patient_1'] + ]; + expect(pouchDb.query.args).to.deep.equal([ + ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 0, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 1, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 2, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 3, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 1, skip: 4, group_level: undefined }], + ]); + }); + + it('should health_center_1 to district_1 in batches of 2', async () => { + Backend.__set__('BATCH_SIZE', 2); + Backend.BATCH_SIZE = 2; + sinon.spy(pouchDb, 'query'); + + await HierarchyOperations(pouchDb).move(['health_center_1'], 'district_1'); + + expect(getWrittenDoc('health_center_1_contact')).to.deep.eq({ + _id: 'health_center_1_contact', + type: 'person', + parent: parentsToLineage('health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('health_center_1')).to.deep.eq({ + _id: 'health_center_1', + type: 'health_center', + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), + parent: parentsToLineage('district_1'), + }); + + expect(getWrittenDoc('clinic_1')).to.deep.eq({ + _id: 'clinic_1', + type: 'clinic', + contact: parentsToLineage('clinic_1_contact', 'clinic_1', 'health_center_1', 'district_1'), + parent: parentsToLineage('health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('patient_1')).to.deep.eq({ + _id: 'patient_1', + type: 'person', + parent: parentsToLineage('clinic_1', 'health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('report_1')).to.deep.eq({ + _id: 'report_1', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('report_2')).to.deep.eq({ + _id: 'report_2', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), + }); + + expect(getWrittenDoc('report_3')).to.deep.eq({ + _id: 'report_3', + form: 'foo', + type: 'data_record', + fields: {}, + contact: parentsToLineage('health_center_1_contact', 'health_center_1', 'district_1'), + }); + + expect(pouchDb.query.callCount).to.deep.equal(4); + + const contactIdsKeys = [ + ['contact:clinic_1'], + ['contact:clinic_1_contact'], + ['contact:health_center_1'], + ['contact:health_center_1_contact'], + ['contact:patient_1'] + ]; + expect(pouchDb.query.args).to.deep.equal([ + ['medic/contacts_by_depth', { key: ['health_center_1'], include_docs: true, group_level: undefined, skip: undefined, limit: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 0, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 2, group_level: undefined }], + ['medic-client/reports_by_freetext', { keys: contactIdsKeys, include_docs: true, limit: 2, skip: 4, group_level: undefined }] + ]); + }); + }); +}); diff --git a/test/lib/hierarchy-operations/jsdocs.spec.js b/test/lib/hierarchy-operations/jsdocs.spec.js new file mode 100644 index 000000000..23353673a --- /dev/null +++ b/test/lib/hierarchy-operations/jsdocs.spec.js @@ -0,0 +1,46 @@ +const { assert, expect } = require('chai'); +const rewire = require('rewire'); +const sinon = require('sinon'); + +const environment = require('../../../src/lib/environment'); +const fs = require('../../../src/lib/sync-fs'); +const JsDocs = rewire('../../../src/lib/hierarchy-operations/jsdocFolder'); +const userPrompt = rewire('../../../src/lib/user-prompt'); + +describe('JsDocs', () => { + let readline; + + let docOnj = { docDirectoryPath: '/test/path/for/testing ', force: false }; + beforeEach(() => { + readline = { keyInYN: sinon.stub() }; + userPrompt.__set__('readline', readline); + JsDocs.__set__('userPrompt', userPrompt); + sinon.stub(fs, 'exists').returns(true); + sinon.stub(fs, 'recurseFiles').returns(Array(20)); + sinon.stub(fs, 'deleteFilesInFolder').returns(true); + }); + afterEach(() => { + sinon.restore(); + }); + + it('does not delete files in directory when user presses n', () => { + readline.keyInYN.returns(false); + sinon.stub(environment, 'force').get(() => false); + const actual = () => JsDocs.prepareFolder(docOnj); + expect(actual).to.throw('aborted execution'); + assert.equal(fs.deleteFilesInFolder.callCount, 0); + }); + + it('deletes files in directory when user presses y', () => { + readline.keyInYN.returns(true); + sinon.stub(environment, 'force').get(() => false); + JsDocs.prepareFolder(docOnj); + assert.equal(fs.deleteFilesInFolder.callCount, 1); + }); + + it('deletes files in directory when force is set', () => { + sinon.stub(environment, 'force').get(() => true); + JsDocs.prepareFolder(docOnj); + assert.equal(fs.deleteFilesInFolder.callCount, 1); + }); +}); diff --git a/test/lib/lineage-constraints.spec.js b/test/lib/hierarchy-operations/lineage-constraints.spec.js similarity index 77% rename from test/lib/lineage-constraints.spec.js rename to test/lib/hierarchy-operations/lineage-constraints.spec.js index 66c6134d3..d4812d115 100644 --- a/test/lib/lineage-constraints.spec.js +++ b/test/lib/hierarchy-operations/lineage-constraints.spec.js @@ -4,26 +4,20 @@ const PouchDB = require('pouchdb-core'); PouchDB.plugin(require('pouchdb-adapter-memory')); PouchDB.plugin(require('pouchdb-mapreduce')); -const { mockHierarchy } = require('../mock-hierarchies'); +const { mockHierarchy } = require('../../mock-hierarchies'); -const lineageConstraints = rewire('../../src/lib/lineage-constraints'); -const log = require('../../src/lib/log'); +const lineageConstraints = rewire('../../../src/lib/hierarchy-operations/lineage-constraints'); +const log = require('../../../src/lib/log'); log.level = log.LEVEL_INFO; describe('lineage constriants', () => { describe('getHierarchyErrors', () => { - const scenario = async (contact_types, contactType, parentType) => { - const mockDb = { get: () => ({ settings: { contact_types } }) }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { type: parentType }); - return getHierarchyErrors({ type: contactType }); - }; + it('empty rules yields error', async () => expect(await runScenario([], 'person', 'health_center')).to.include('unknown type')); - it('empty rules yields error', async () => expect(await scenario([], 'person', 'health_center')).to.include('unknown type')); - - it('no valid parent yields error', async () => expect(await scenario([undefined], 'person', 'health_center')).to.include('unknown type')); + it('no valid parent yields error', async () => expect(await runScenario([undefined], 'person', 'health_center')).to.include('unknown type')); it('valid parent yields no error', async () => { - const actual = await scenario([{ + const actual = await runScenario([{ id: 'person', parents: ['health_center'], }], 'person', 'health_center'); @@ -31,45 +25,49 @@ describe('lineage constriants', () => { expect(actual).to.be.undefined; }); - it('no contact type yields undefined error', async () => expect(await scenario([])).to.include('undefined')); + it('no contact type yields undefined error', async () => expect(await runScenario([])).to.include('undefined')); - it('no parent type yields undefined error', async () => expect(await scenario([], 'person')).to.include('undefined')); + it('no parent type yields undefined error', async () => expect(await runScenario([], 'person')).to.include('undefined')); - it('no valid parents yields not defined', async () => expect(await scenario([{ + it('no valid parents yields not defined', async () => expect(await runScenario([{ id: 'person', parents: ['district_hospital'], }], 'person', 'health_center')).to.include('cannot have parent of type')); it('no settings doc requires valid parent type', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { type: 'dne' }); - const actual = getHierarchyErrors({ type: 'person' }); + const { getHierarchyErrors } = await lineageConstraints(mockDb); + const actual = getHierarchyErrors({ type: 'person' }, { type: 'dne' }); expect(actual).to.include('cannot have parent of type'); }); it('no settings doc requires valid contact type', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { type: 'clinic' }); - const actual = getHierarchyErrors({ type: 'dne' }); + const { getHierarchyErrors } = await lineageConstraints(mockDb); + const actual = getHierarchyErrors({ type: 'dne' }, { type: 'clinic' }); expect(actual).to.include('unknown type'); }); it('no settings doc yields not defined', async () => { const mockDb = { get: () => { throw { name: 'not_found' }; } }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, { type: 'clinic' }); - const actual = getHierarchyErrors({ type: 'person' }); + const { getHierarchyErrors } = await lineageConstraints(mockDb); + const actual = getHierarchyErrors({ type: 'person' }, { type: 'clinic' }); expect(actual).to.be.undefined; }); + it('cannot merge with self', async () => { + expect(await runScenario([], 'a', 'a', true)).to.include('self'); + }); + describe('default schema', () => { - it('no defined rules enforces defaults schema', async () => expect(await scenario(undefined, 'district_hospital', 'health_center')).to.include('cannot have parent')); + it('no defined rules enforces defaults schema', async () => expect(await runScenario(undefined, 'district_hospital', 'health_center')).to.include('cannot have parent')); - it('nominal case', async () => expect(await scenario(undefined, 'person', 'health_center')).to.be.undefined); + it('nominal case', async () => expect(await runScenario(undefined, 'person', 'health_center')).to.be.undefined); it('can move district_hospital to root', async () => { const mockDb = { get: () => ({ settings: { } }) }; - const { getHierarchyErrors } = await lineageConstraints(mockDb, undefined); - const actual = getHierarchyErrors({ type: 'district_hospital' }); + const { getHierarchyErrors } = await lineageConstraints(mockDb); + const actual = getHierarchyErrors({ type: 'district_hospital' }, undefined); expect(actual).to.be.undefined; }); }); @@ -159,3 +157,9 @@ describe('lineage constriants', () => { }); }); }); + +const runScenario = async (contact_types, sourceType, destinationType, merge = false) => { + const mockDb = { get: () => ({ settings: { contact_types } }) }; + const { getHierarchyErrors } = await lineageConstraints(mockDb, { merge }); + return getHierarchyErrors({ type: sourceType }, { type: destinationType }); +}; diff --git a/test/lib/lineage-manipulation.spec.js b/test/lib/hierarchy-operations/lineage-manipulation.spec.js similarity index 55% rename from test/lib/lineage-manipulation.spec.js rename to test/lib/hierarchy-operations/lineage-manipulation.spec.js index 7ad0d6e09..80077aa9f 100644 --- a/test/lib/lineage-manipulation.spec.js +++ b/test/lib/hierarchy-operations/lineage-manipulation.spec.js @@ -1,9 +1,9 @@ const { expect } = require('chai'); -const { replaceLineage, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../src/lib/lineage-manipulation'); -const log = require('../../src/lib/log'); +const { replaceLineage, pluckIdsFromLineage, minifyLineagesInDoc } = require('../../../src/lib/hierarchy-operations/lineage-manipulation'); +const log = require('../../../src/lib/log'); log.level = log.LEVEL_TRACE; -const { parentsToLineage } = require('../mock-hierarchies'); +const { parentsToLineage } = require('../../mock-hierarchies'); describe('lineage manipulation', () => { describe('replaceLineage', () => { @@ -12,7 +12,11 @@ describe('lineage manipulation', () => { it('replace with empty lineage', () => { const mock = mockReport(); - expect(replaceLineage(mock, 'contact', undefined)).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'contact', + replaceWith: undefined, + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'r', type: 'data_record', @@ -22,7 +26,11 @@ describe('lineage manipulation', () => { it('replace full lineage', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('new_parent'), + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -34,7 +42,11 @@ describe('lineage manipulation', () => { const mock = mockContact(); delete mock.parent; - expect(replaceLineage(mock, 'parent', parentsToLineage('new_parent'))).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('new_parent'), + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -45,12 +57,23 @@ describe('lineage manipulation', () => { it('replace empty with empty', () => { const mock = mockContact(); delete mock.parent; - expect(replaceLineage(mock, 'parent', undefined)).to.be.false; + + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: undefined, + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.false; }); it('replace lineage starting at contact', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('new_grandparent'), 'parent')).to.be.true; + + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('new_grandparent'), + startingFromId: 'parent', + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -58,9 +81,46 @@ describe('lineage manipulation', () => { }); }); + it('merge new parent', () => { + const mock = mockContact(); + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('new_parent', 'new_grandparent'), + startingFromId: 'parent', + merge: true, + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; + expect(mock).to.deep.eq({ + _id: 'c', + type: 'person', + parent: parentsToLineage('new_parent', 'new_grandparent'), + }); + }); + + it('merge grandparent of contact', () => { + const mock = mockReport(); + const replaceLineageOptions = { + lineageAttribute: 'contact', + replaceWith: parentsToLineage('new_grandparent'), + startingFromId: 'grandparent', + merge: true, + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; + expect(mock).to.deep.eq({ + _id: 'r', + type: 'data_record', + contact: parentsToLineage('parent', 'new_grandparent'), + }); + }); + it('replace empty starting at contact', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', undefined, 'parent')).to.be.true; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: undefined, + startingFromId: 'parent', + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.true; expect(mock).to.deep.eq({ _id: 'c', type: 'person', @@ -70,7 +130,12 @@ describe('lineage manipulation', () => { it('replace starting at non-existant contact', () => { const mock = mockContact(); - expect(replaceLineage(mock, 'parent', parentsToLineage('irrelevant'), 'dne')).to.be.false; + const replaceLineageOptions = { + lineageAttribute: 'parent', + replaceWith: parentsToLineage('irrelevant'), + startingFromId: 'dne', + }; + expect(replaceLineage(mock, replaceLineageOptions)).to.be.false; }); }); diff --git a/test/mock-hierarchies.js b/test/mock-hierarchies.js index d8a2436b3..6d99d8332 100644 --- a/test/mock-hierarchies.js +++ b/test/mock-hierarchies.js @@ -35,13 +35,16 @@ const mockHierarchy = async (db, hierarchy, existingLineage, depth = 0) => { }; const mockReport = async (db, report) => { - const creatorDoc = await db.get(report.creatorId); + const creatorDoc = report.creatorId && await db.get(report.creatorId); await db.put({ _id: report.id, form: 'foo', type: 'data_record', - contact: buildLineage(report.creatorId, creatorDoc.parent), + contact: buildLineage(report.creatorId || 'dne', creatorDoc?.parent), + fields: { + patient_uuid: report.patientId, + } }); }; diff --git a/test/mock-hierarchies.spec.js b/test/mock-hierarchies.spec.js index c8a21933a..3177a7172 100644 --- a/test/mock-hierarchies.spec.js +++ b/test/mock-hierarchies.spec.js @@ -84,6 +84,7 @@ describe('mocks', () => { _id: 'report_1', type: 'data_record', form: 'foo', + fields: {}, contact: { _id: 'health_center_1_contact', parent: {