diff --git a/admin/tests/unit/services/lineage-model-generator.spec.js b/admin/tests/unit/services/lineage-model-generator.spec.js index dd308a15294..fd1178822a6 100644 --- a/admin/tests/unit/services/lineage-model-generator.spec.js +++ b/admin/tests/unit/services/lineage-model-generator.spec.js @@ -5,14 +5,16 @@ describe('LineageModelGenerator service', () => { let service; let dbQuery; let dbAllDocs; + let dbGet; beforeEach(() => { module('adminApp'); module($provide => { dbQuery = sinon.stub(); dbAllDocs = sinon.stub(); + dbGet = sinon.stub(); $provide.value('$q', Q); // bypass $q so we don't have to digest - $provide.factory('DB', KarmaUtils.mockDB({ query: dbQuery, allDocs: dbAllDocs })); + $provide.factory('DB', KarmaUtils.mockDB({ query: dbQuery, allDocs: dbAllDocs, get: dbGet })); }); inject(_LineageModelGenerator_ => service = _LineageModelGenerator_); }); @@ -20,7 +22,7 @@ describe('LineageModelGenerator service', () => { describe('contact', () => { it('handles not found', done => { - dbQuery.returns(Promise.resolve({ rows: [] })); + dbGet.returns(Promise.reject({ status: 404 })); service.contact('a') .then(() => { done(new Error('expected error to be thrown')); @@ -34,9 +36,7 @@ describe('LineageModelGenerator service', () => { it('handles no lineage', () => { const contact = { _id: 'a', _rev: '1' }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact } - ] })); + dbGet.returns(Promise.resolve(contact)); return service.contact('a').then(model => { chai.expect(model._id).to.equal('a'); chai.expect(model.doc).to.deep.equal(contact); @@ -44,22 +44,21 @@ describe('LineageModelGenerator service', () => { }); it('binds lineage', () => { - const contact = { _id: 'a', _rev: '1' }; - const parent = { _id: 'b', _rev: '1' }; + const contact = { _id: 'a', _rev: '1', parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1' }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + dbGet.withArgs('a').returns(Promise.resolve(contact)); + dbAllDocs.withArgs(sinon.match({ + keys: sinon.match.array.deepEquals(['b', 'c']), + include_docs: true + })).returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); return service.contact('a').then(model => { - chai.expect(dbQuery.callCount).to.equal(1); - chai.expect(dbQuery.args[0][0]).to.equal('medic-client/docs_by_id_lineage'); - chai.expect(dbQuery.args[0][1]).to.deep.equal({ - startkey: [ 'a' ], - endkey: [ 'a', {} ], - include_docs: true - }); + chai.expect(dbGet.callCount).to.equal(1); + chai.expect(dbAllDocs.callCount).to.equal(1); + chai.expect(dbAllDocs.args[0][0].keys).to.deep.equal(['b', 'c']); chai.expect(model._id).to.equal('a'); chai.expect(model.doc).to.deep.equal(contact); chai.expect(model.lineage).to.deep.equal([ parent, grandparent ]); @@ -67,17 +66,23 @@ describe('LineageModelGenerator service', () => { }); it('binds contacts', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'b', parent: { _id: 'c' } } }; const contactsContact = { _id: 'd', name: 'dave' }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'e' } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'e' }, parent: { _id: 'c' } }; const parentsContact = { _id: 'e', name: 'eliza' }; const grandparent = { _id: 'c', _rev: '1' }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + dbGet.returns(Promise.resolve(contact)); + dbAllDocs.withArgs(sinon.match({ + keys: sinon.match.array.deepEquals(['b', 'c']), + include_docs: true + })).returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); - dbAllDocs.returns(Promise.resolve({ rows: [ + dbAllDocs.withArgs({ + keys: sinon.match.array.deepEquals(['d', 'e']), + include_docs: true + }).returns(Promise.resolve({ rows: [ { doc: contactsContact }, { doc: parentsContact } ] })); @@ -89,23 +94,35 @@ describe('LineageModelGenerator service', () => { }); it('hydrates lineage contacts - #3812', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' } }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' }, parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1', contact: { _id: 'e' } }; const parentContact = { _id: 'd', name: 'donny' }; const grandparentContact = { _id: 'e', name: 'erica' }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + const xContact = { _id: 'x', name: 'xavier' }; + dbGet.returns(Promise.resolve(contact)); + dbAllDocs.withArgs(sinon.match({ + keys: sinon.match.array.deepEquals(['b', 'c']), + include_docs: true + })).returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); - dbAllDocs.returns(Promise.resolve({ rows: [ + dbAllDocs.withArgs({ + keys: sinon.match.array.deepEquals(['x', 'd', 'e']), + include_docs: true + }).returns(Promise.resolve({ rows: [ + { doc: xContact }, { doc: parentContact }, { doc: grandparentContact } ] })); return service.contact('a').then(model => { - chai.expect(dbAllDocs.callCount).to.equal(1); + chai.expect(dbAllDocs.callCount).to.equal(2); chai.expect(dbAllDocs.args[0][0]).to.deep.equal({ + keys: [ 'b', 'c' ], + include_docs: true + }); + chai.expect(dbAllDocs.args[1][0]).to.deep.equal({ keys: [ 'x', 'd', 'e' ], include_docs: true }); @@ -116,7 +133,7 @@ describe('LineageModelGenerator service', () => { it('merges lineage when merge passed', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c' } } }; - const parent = { _id: 'b', name: '2' }; + const parent = { _id: 'b', name: '2', parent: { _id: 'c' } }; const grandparent = { _id: 'c', name: '3' }; const expected = { _id: 'a', @@ -147,8 +164,11 @@ describe('LineageModelGenerator service', () => { } ] }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + dbGet.returns(Promise.resolve(contact)); + dbAllDocs.withArgs(sinon.match({ + keys: sinon.match.array.deepEquals(['b', 'c']), + include_docs: true + })).returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); @@ -160,8 +180,9 @@ describe('LineageModelGenerator service', () => { it('should merge lineage with undefined members', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c', parent: { _id: 'd' } } } }; const parent = { _id: 'b', name: '2', parent: { _id: 'c', parent: { _id: 'd' } } }; - dbQuery.resolves({ rows: - [{ doc: contact, key: ['a', 0] }, { doc: parent, key: ['a', 1] }, { key: ['a', 2] }, { key: ['a', 3] }] + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: + [{ doc: parent, id: 'b' }, { id: 'c' }, { id: 'd' }] }); const expected = { _id: 'a', @@ -180,11 +201,11 @@ describe('LineageModelGenerator service', () => { it('should merge lineage with undefined members v2', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c', parent: { _id: 'd' } } } }; const parent = { _id: 'b', name: '2', parent: { _id: 'c', parent: { _id: 'd' } } }; - dbQuery.resolves({ rows: [ - { doc: contact, key: ['a', 0] }, - { doc: parent, key: ['a', 1] }, - { key: ['a', 2] }, - { key: ['a', 3], doc: { _id: 'd', name: '4' } } + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ + { doc: parent, id: 'b' }, + { id: 'c' }, + { id: 'd', doc: { _id: 'd', name: '4' } } ] }); const expected = { _id: 'a', @@ -229,8 +250,8 @@ describe('LineageModelGenerator service', () => { } ] }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + dbGet.returns(Promise.resolve(contact)); + dbAllDocs.returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); diff --git a/ddocs/medic-db/medic-client/views/docs_by_id_lineage/map.js b/ddocs/medic-db/medic-client/views/docs_by_id_lineage/map.js deleted file mode 100644 index c87b7d182f8..00000000000 --- a/ddocs/medic-db/medic-client/views/docs_by_id_lineage/map.js +++ /dev/null @@ -1,20 +0,0 @@ -function(doc) { - - var emitLineage = function(contact, depth) { - while (contact && contact._id) { - emit([ doc._id, depth++ ], { _id: contact._id }); - contact = contact.parent; - } - }; - - var types = [ 'contact', 'district_hospital', 'health_center', 'clinic', 'person' ]; - - if (types.indexOf(doc.type) !== -1) { - // contact - emitLineage(doc, 0); - } else if (doc.type === 'data_record' && doc.form) { - // report - emit([ doc._id, 0 ]); - emitLineage(doc.contact, 1); - } -} diff --git a/shared-libs/cht-datasource/src/local/libs/lineage.ts b/shared-libs/cht-datasource/src/local/libs/lineage.ts index 5c53ab7e326..c32f2ed9e5b 100644 --- a/shared-libs/cht-datasource/src/local/libs/lineage.ts +++ b/shared-libs/cht-datasource/src/local/libs/lineage.ts @@ -15,7 +15,7 @@ import { Nullable } from '../../libs/core'; import { Doc } from '../../libs/doc'; -import { getDocsByIds, queryDocsByRange } from './doc'; +import { getDocsByIds } from './doc'; import logger from '@medic/logger'; import lineageFactory from '@medic/lineage'; import * as Report from '../../report'; @@ -27,14 +27,41 @@ import { InvalidArgumentError } from '../../libs/error'; import contactTypeUtils from '@medic/contact-types-utils'; import { isEqual } from 'lodash'; +const getParentIds = (doc: Doc): string[] => { + const parentIds: string[] = []; + let current: unknown = doc.type === 'data_record' ? doc.contact : doc.parent; + while (isRecord(current)) { + if (typeof current._id === 'string') { + parentIds.push(current._id); + } + current = current.parent; + } + return parentIds; +}; + /** * Returns the identified document along with the parent documents recorded for its lineage. The returned array is * sorted such that the identified document is the first element and the parent documents are in order of lineage. * @internal */ export const getLineageDocsById = (medicDb: PouchDB.Database): (id: string) => Promise[]> => { - const fn = queryDocsByRange(medicDb, 'medic-client/docs_by_id_lineage'); - return (id: string) => fn([id], [id, {}]); + const getMedicDocsById = getDocsByIds(medicDb); + return async (id: string) => { + try { + const doc = await medicDb.get(id); + const parentIds = getParentIds(doc); + if (parentIds.length === 0) { + return [doc]; + } + const ancestors = await getMedicDocsById(parentIds); + return [doc, ...ancestors]; + } catch (err: unknown) { + if ((err as PouchDB.Core.Error).status === 404) { + return []; + } + throw err; + } + }; }; /** @internal */ diff --git a/shared-libs/cht-datasource/test/local/libs/doc.spec.ts b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts index 44fb2385280..db7e37f0919 100644 --- a/shared-libs/cht-datasource/test/local/libs/doc.spec.ts +++ b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts @@ -245,11 +245,11 @@ describe('local doc lib', () => { }); isDoc.returns(true); - const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc1._id); + const result = await queryDocsByRange(db, 'medic-client/contacts_by_type')(doc0._id, doc1._id); expect(result).to.deep.equal([doc0, doc1, doc2]); - expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { + expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_type', { include_docs: true, startkey: doc0._id, endkey: doc1._id, @@ -271,10 +271,10 @@ describe('local doc lib', () => { }); isDoc.returns(true); - const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc2._id, limit, skip); + const result = await queryDocsByRange(db, 'medic-client/contacts_by_type')(doc0._id, doc2._id, limit, skip); expect(result).to.deep.equal([doc0, null, doc2]); - expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { + expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_type', { startkey: doc0._id, endkey: doc2._id, include_docs: true, @@ -291,10 +291,10 @@ describe('local doc lib', () => { }); isDoc.returns(false); - const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc0._id, limit, skip); + const result = await queryDocsByRange(db, 'medic-client/contacts_by_type')(doc0._id, doc0._id, limit, skip); expect(result).to.deep.equal([null]); - expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { + expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_type', { startkey: doc0._id, endkey: doc0._id, include_docs: true, diff --git a/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts b/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts index 9312ef26632..895aee76bd9 100644 --- a/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts +++ b/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts @@ -30,18 +30,26 @@ describe('local lineage lib', () => { it('getLineageDocsById', async () => { const uuid = '123'; - const queryFn = sinon.stub().resolves([]); - const queryDocsByRange = sinon - .stub(LocalDoc, 'queryDocsByRange') - .returns(queryFn); - const medicDb = { hello: 'world' } as unknown as PouchDB.Database; + const doc = { _id: uuid, parent: { _id: 'parent1' } }; + const parentDoc = { _id: 'parent1' }; + medicGet.resolves(doc); + const getDocsByIdsInner = sinon.stub().resolves([parentDoc]); + const getDocsByIdsOuter = sinon.stub(LocalDoc, 'getDocsByIds').returns(getDocsByIdsInner); const fn = Lineage.getLineageDocsById(medicDb); const result = await fn(uuid); + expect(result).to.deep.equal([doc, parentDoc]); + expect(medicGet.calledOnceWithExactly(uuid)).to.be.true; + expect(getDocsByIdsOuter.calledOnceWithExactly(medicDb)).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly(['parent1'])).to.be.true; + }); + + it('getLineageDocsById handles 404', async () => { + medicGet.rejects({ status: 404 }); + const fn = Lineage.getLineageDocsById(medicDb); + const result = await fn('missing'); expect(result).to.deep.equal([]); - expect(queryDocsByRange.calledOnceWithExactly(medicDb, 'medic-client/docs_by_id_lineage')).to.be.true; - expect(queryFn.calledOnceWithExactly([uuid], [uuid, {}])).to.be.true; }); describe('getPrimaryContactIds', () => { diff --git a/shared-libs/lineage/src/hydration.js b/shared-libs/lineage/src/hydration.js index 33ee9c0547f..b17cdd8c06e 100644 --- a/shared-libs/lineage/src/hydration.js +++ b/shared-libs/lineage/src/hydration.js @@ -22,6 +22,13 @@ const extractParentIds = current => selfAndParents(current) .map(parent => parent._id) .filter(id => id); +// One entry per id, in the given order. Ids with no matching doc yield undefined so that positions in the +// lineage - and therefore ancestor depth - are preserved. +const orderDocsByIds = (ids, docs) => { + const docsById = new Map(docs.map(doc => [ doc._id, doc ])); + return ids.map(id => docsById.get(id)); +}; + const getContactById = (contacts, id) => id && contacts.find(contact => contact && contact._id === id); const getContactIds = (contacts) => { @@ -229,16 +236,23 @@ module.exports = function(Promise, DB) { }; const fetchLineageById = function(id) { - const options = { - startkey: [id], - endkey: [id, {}], - include_docs: true - }; - return DB.query('medic-client/docs_by_id_lineage', options) - .then(function(result) { - return result.rows.map(function(row) { - return row.doc; - }); + // The lineage of a document is recorded on the document itself: the parent chain for a contact, or the contact's + // parent chain for a report. Fetch the document, then fetch its ancestors by id, preserving lineage order. + return DB.get(id) + .then(function(doc) { + const startParent = utils.isReport(doc) ? doc.contact : doc.parent; + const parentIds = extractParentIds(startParent); + if (!parentIds.length) { + return [doc]; + } + return fetchDocs(parentIds) + .then(ancestors => [ doc, ...orderDocsByIds(parentIds, ancestors) ]); + }) + .catch(function(err) { + if (err.status === 404) { + return []; + } + throw err; }); }; diff --git a/shared-libs/lineage/test/hydration.spec.js b/shared-libs/lineage/test/hydration.spec.js index 6da39b4a5ef..f75e383a27a 100644 --- a/shared-libs/lineage/test/hydration.spec.js +++ b/shared-libs/lineage/test/hydration.spec.js @@ -24,15 +24,15 @@ describe('Lineage', function() { describe('fetchLineageById', function() { it('queries db with correct parameters', function() { - query.resolves({ rows: [] }); + get.resolves({ _id: 'banana', parent: { _id: 'apple' } }); + allDocs.resolves({ rows: [{ doc: { _id: 'apple' } }] }); const id = 'banana'; return lineage.fetchLineageById(id).then(() => { - chai.expect(query.callCount).to.equal(1); - chai.expect(query.getCall(0).args[0]).to.equal('medic-client/docs_by_id_lineage'); - chai.expect(query.getCall(0).args[1].startkey).to.deep.equal([ id ]); - chai.expect(query.getCall(0).args[1].endkey).to.deep.equal([ id, {} ]); - chai.expect(query.getCall(0).args[1].include_docs).to.deep.equal(true); + chai.expect(get.callCount).to.equal(1); + chai.expect(get.getCall(0).args[0]).to.equal('banana'); + chai.expect(allDocs.callCount).to.equal(1); + chai.expect(allDocs.getCall(0).args[0]).to.deep.equal({ keys: ['apple'], include_docs: true }); }); }); }); @@ -164,7 +164,6 @@ describe('Lineage', function() { describe('fetchHydratedDoc', function() { it('supports callback as second argument', function(done) { - query.resolves({ rows: [] }); get.resolves({ _id: 'a', type: 'person' }); lineage.fetchHydratedDoc('a', function(err, result) { @@ -175,7 +174,7 @@ describe('Lineage', function() { }); it('passes error to callback', function(done) { - query.rejects(new Error('db fail')); + get.rejects(new Error('db fail')); lineage.fetchHydratedDoc('a', function(err) { chai.expect(err.message).to.equal('db fail'); @@ -184,7 +183,7 @@ describe('Lineage', function() { }); it('throws when lineage is empty and throwWhenMissingLineage is true', function() { - query.resolves({ rows: [] }); + get.rejects({ status: 404 }); return lineage.fetchHydratedDoc('a', { throwWhenMissingLineage: true }) .then(() => chai.expect.fail('should have thrown')) @@ -205,7 +204,7 @@ describe('Lineage', function() { it('throws non-404 errors for single doc', function() { const err = new Error('server error'); err.status = 500; - query.rejects(err); + get.rejects(err); return lineage.fetchHydratedDocs(['a']) .then(() => chai.expect.fail('should have thrown')) diff --git a/tests/integration/api/server.spec.js b/tests/integration/api/server.spec.js index 1f8b2ee87f8..690303ca085 100644 --- a/tests/integration/api/server.spec.js +++ b/tests/integration/api/server.spec.js @@ -276,9 +276,14 @@ describe('server', () => { const reqID = getReqId(apiLogs[0]); const haproxyRequests = haproxyLogs.filter(entry => getReqId(entry) === reqID); - expect(haproxyRequests.length).to.equal(2); + // Request count depends on whether the doc has ancestors: + // _session + DB.get (2) OR _session + DB.get + _all_docs (3) + expect(haproxyRequests.length).to.be.at.least(2); expect(haproxyRequests[0]).to.include('_session'); - expect(haproxyRequests[1]).to.include('_design/medic-client/_view/docs_by_id_lineage'); + const hasDbGetOrPost = haproxyRequests.some(r => { + return r.includes(constants.USER_CONTACT_ID) || r.includes('_all_docs'); + }); + expect(hasDbGetOrPost).to.be.true; }); it('should propagate ID via couch-request', async () => { diff --git a/webapp/tests/karma/ts/services/lineage-model-generator.service.spec.ts b/webapp/tests/karma/ts/services/lineage-model-generator.service.spec.ts index 302a7697d40..ed0b5af9176 100644 --- a/webapp/tests/karma/ts/services/lineage-model-generator.service.spec.ts +++ b/webapp/tests/karma/ts/services/lineage-model-generator.service.spec.ts @@ -10,14 +10,16 @@ describe('LineageModelGenerator service', () => { let service; let dbQuery; let dbAllDocs; + let dbGet; beforeEach(() => { dbQuery = sinon.stub(); dbAllDocs = sinon.stub(); + dbGet = sinon.stub(); TestBed.configureTestingModule({ providers: [ - { provide: DbService, useValue: { get: () => ({ query: dbQuery, allDocs: dbAllDocs }) }}, + { provide: DbService, useValue: { get: () => ({ query: dbQuery, allDocs: dbAllDocs, get: dbGet }) }}, ], }); @@ -31,7 +33,7 @@ describe('LineageModelGenerator service', () => { describe('contact', () => { it('handles not found', done => { - dbQuery.resolves({ rows: [] }); + dbGet.rejects({ status: 404 }); service.contact('a') .then(() => { done(new Error('expected error to be thrown')); @@ -45,10 +47,7 @@ describe('LineageModelGenerator service', () => { it('handles no lineage', () => { const contact = { _id: 'a', _rev: '1' }; - dbQuery.resolves({ - rows: [ - { doc: contact } - ] }); + dbGet.resolves(contact); return service.contact('a').then(model => { expect(model._id).to.equal('a'); expect(model.doc).to.deep.equal(contact); @@ -56,23 +55,19 @@ describe('LineageModelGenerator service', () => { }); it('binds lineage', () => { - const contact = { _id: 'a', _rev: '1' }; - const parent = { _id: 'b', _rev: '1' }; + const contact = { _id: 'a', _rev: '1', parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1' }; - dbQuery.resolves({ + dbGet.withArgs('a').resolves(contact); + dbAllDocs.withArgs({ keys: ['b', 'c'], include_docs: true }).resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); return service.contact('a').then(model => { - expect(dbQuery.callCount).to.equal(1); - expect(dbQuery.args[0][0]).to.equal('medic-client/docs_by_id_lineage'); - expect(dbQuery.args[0][1]).to.deep.equal({ - startkey: [ 'a' ], - endkey: [ 'a', {} ], - include_docs: true - }); + expect(dbGet.callCount).to.equal(1); + expect(dbAllDocs.callCount).to.equal(1); + expect(dbAllDocs.args[0][0].keys).to.deep.equal(['b', 'c']); expect(model._id).to.equal('a'); expect(model.doc).to.deep.equal(contact); expect(model.lineage).to.deep.equal([ parent, grandparent ]); @@ -80,18 +75,18 @@ describe('LineageModelGenerator service', () => { }); it('binds contacts', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'b', parent: { _id: 'c' } } }; const contactsContact = { _id: 'd', name: 'dave' }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'e' } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'e' }, parent: { _id: 'c' } }; const parentsContact = { _id: 'e', name: 'eliza' }; const grandparent = { _id: 'c', _rev: '1' }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.withArgs({ keys: ['b', 'c'], include_docs: true }).resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); - dbAllDocs.resolves({ + dbAllDocs.withArgs({ keys: sinon.match.array.deepEquals(['d', 'e']), include_docs: true }).resolves({ rows: [ { doc: contactsContact }, { doc: parentsContact } @@ -104,25 +99,27 @@ describe('LineageModelGenerator service', () => { }); it('hydrates lineage contacts - #3812', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' } }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' }, parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1', contact: { _id: 'e' } }; const parentContact = { _id: 'd', name: 'donny' }; const grandparentContact = { _id: 'e', name: 'erica' }; - dbQuery.resolves({ + const xContact = { _id: 'x', name: 'xavier' }; + dbGet.resolves(contact); + dbAllDocs.withArgs({ keys: ['b', 'c'], include_docs: true }).resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); - dbAllDocs.resolves({ + dbAllDocs.withArgs({ keys: sinon.match.array.deepEquals(['x', 'd', 'e']), include_docs: true }).resolves({ rows: [ + { doc: xContact }, { doc: parentContact }, { doc: grandparentContact } ] }); return service.contact('a').then(model => { - expect(dbAllDocs.callCount).to.equal(1); - expect(dbAllDocs.args[0][0]).to.deep.equal({ + expect(dbAllDocs.callCount).to.equal(2); + expect(dbAllDocs.args[1][0]).to.deep.equal({ keys: [ 'x', 'd', 'e' ], include_docs: true }); @@ -132,18 +129,18 @@ describe('LineageModelGenerator service', () => { }); it('should skip lineage contact hydration if requested', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' } }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' }, parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1', contact: { _id: 'e' } }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); return service.contact('a', { hydrate: false }).then(model => { - expect(dbAllDocs.callCount).to.equal(0); + expect(dbAllDocs.callCount).to.equal(1); // One for lineage, zero for contacts expect(model.doc.contact).to.deep.equal({ _id: 'x' }); expect(model.lineage[0].contact).to.deep.equal({ _id: 'd' }); expect(model.lineage[1].contact).to.deep.equal({ _id: 'e' }); @@ -152,7 +149,7 @@ describe('LineageModelGenerator service', () => { it('merges lineage when merge passed', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c' } } }; - const parent = { _id: 'b', name: '2' }; + const parent = { _id: 'b', name: '2', parent: { _id: 'c' } }; const grandparent = { _id: 'c', name: '3' }; const expected = { _id: 'a', @@ -183,9 +180,9 @@ describe('LineageModelGenerator service', () => { } ] }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); @@ -197,8 +194,9 @@ describe('LineageModelGenerator service', () => { it('should merge lineage with undefined members', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c', parent: { _id: 'd' } } } }; const parent = { _id: 'b', name: '2', parent: { _id: 'c', parent: { _id: 'd' } } }; - dbQuery.resolves({ rows: - [{ doc: contact, key: ['a', 0] }, { doc: parent, key: ['a', 1] }, { key: ['a', 2] }, { key: ['a', 3] }] + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: + [{ doc: parent, id: 'b' }, { id: 'c' }, { id: 'd' }] }); const expected = { _id: 'a', @@ -217,12 +215,12 @@ describe('LineageModelGenerator service', () => { it('should merge lineage with undefined members v2', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c', parent: { _id: 'd' } } } }; const parent = { _id: 'b', name: '2', parent: { _id: 'c', parent: { _id: 'd' } } }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ - { doc: contact, key: ['a', 0] }, - { doc: parent, key: ['a', 1] }, - { key: ['a', 2] }, - { key: ['a', 3], doc: { _id: 'd', name: '4' } } + { doc: parent, id: 'b' }, + { id: 'c' }, + { id: 'd', doc: { _id: 'd', name: '4' } } ] }); const expected = { _id: 'a', @@ -267,9 +265,9 @@ describe('LineageModelGenerator service', () => { } ] }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); @@ -282,7 +280,7 @@ describe('LineageModelGenerator service', () => { describe('report', () => { it('handles not found', done => { - dbQuery.resolves({ rows: [] }); + dbGet.rejects({ status: 404 }); service.report('a') .then(() => { done(new Error('expected error to be thrown')); @@ -296,10 +294,7 @@ describe('LineageModelGenerator service', () => { it('handles no lineage', () => { const report = { _id: 'a', _rev: '1' }; - dbQuery.resolves({ - rows: [ - { doc: report } - ] }); + dbGet.resolves(report); return service.report('a').then(model => { expect(model._id).to.equal('a'); expect(model.doc).to.deep.equal(report); @@ -311,9 +306,9 @@ describe('LineageModelGenerator service', () => { const contact = { _id: 'b', _rev: '1' }; const parent = { _id: 'c', _rev: '1' }; const grandparent = { _id: 'd', _rev: '1' }; - dbQuery.resolves({ + dbGet.withArgs('a').resolves(report); + dbAllDocs.resolves({ rows: [ - { doc: report }, { doc: contact }, { doc: parent }, { doc: grandparent } @@ -326,28 +321,33 @@ describe('LineageModelGenerator service', () => { }); it('hydrates lineage contacts - #3812', () => { - const report = { _id: 'a', _rev: '1', type: DOC_TYPES.DATA_RECORD, form: 'a', contact: { _id: 'x' } }; - const contact = { _id: 'b', _rev: '1', contact: { _id: 'y' } }; - const parent = { _id: 'c', _rev: '1', contact: { _id: 'e' } }; + const reportContact = { _id: 'x', parent: { _id: 'c', parent: { _id: 'd' } } }; + const report = { _id: 'a', _rev: '1', type: DOC_TYPES.DATA_RECORD, form: 'a', contact: reportContact }; + const contact = { _id: 'x', _rev: '1', contact: { _id: 'y' }, parent: { _id: 'c' } }; + const parent = { _id: 'c', _rev: '1', contact: { _id: 'e' }, parent: { _id: 'd' } }; const grandparent = { _id: 'd', _rev: '1', contact: { _id: 'f' } }; const parentContact = { _id: 'e', name: 'erica' }; const grandparentContact = { _id: 'f', name: 'frank' }; - dbQuery.resolves({ + const xContact = { _id: 'x', name: 'xavier' }; + const yContact = { _id: 'y', name: 'yvonne' }; + dbGet.resolves(report); + dbAllDocs.withArgs(sinon.match({ keys: ['x', 'c', 'd'], include_docs: true })).resolves({ rows: [ - { doc: report }, { doc: contact }, { doc: parent }, { doc: grandparent } ] }); - dbAllDocs.resolves({ + dbAllDocs.withArgs(sinon.match({ keys: ['y', 'e', 'f'], include_docs: true })).resolves({ rows: [ + { doc: xContact }, + { doc: yContact }, { doc: parentContact }, { doc: grandparentContact } ] }); return service.report('a').then(model => { - expect(dbAllDocs.callCount).to.equal(1); - expect(dbAllDocs.args[0][0]).to.deep.equal({ - keys: [ 'x', 'y', 'e', 'f' ], + expect(dbAllDocs.callCount).to.equal(2); + expect(dbAllDocs.args[1][0]).to.deep.equal({ + keys: [ 'y', 'e', 'f' ], include_docs: true }); expect(model.doc.contact.parent.contact).to.deep.equal(parentContact); diff --git a/webapp/tests/mocha/unit/views/docs_by_id_lineage.spec.js b/webapp/tests/mocha/unit/views/docs_by_id_lineage.spec.js deleted file mode 100644 index cf88040d14e..00000000000 --- a/webapp/tests/mocha/unit/views/docs_by_id_lineage.spec.js +++ /dev/null @@ -1,198 +0,0 @@ -const expect = require('chai').expect; -const utils = require('./utils'); -const map = utils.loadView('medic-db', 'medic-client', 'docs_by_id_lineage'); -const { DOC_TYPES, CONTACT_TYPES } = require('@medic/constants'); - -describe('docs_by_id_lineage view', () => { - beforeEach(() => { - map.reset(); - }); - describe('data_record lineage', () => { - it('does not emit if doc is not a report', () => { - const doc = { - _id: 'messsage', - type: DOC_TYPES.DATA_RECORD, - sms_message: { } - }; - - const result = map(doc, true); - expect(result.length).to.equal(0); - }); - it('emits report document for depth 0', () => { - const doc = { - _id: 'report', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - }; - - const result = map(doc, true); - expect(result.length).to.equal(1); - expect(result[0]).to.deep.equal({ key: [ 'report', 0 ], value: undefined }); - }); - - it('emits contact lineage for depth 1+', () => { - const doc = { - _id: 'report', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - contact: { - _id: 'contact1', - parent: { - _id: 'contact2', - parent: { - _id: 'contact3' - } - } - } - }; - const result = map(doc, true); - expect(result.length).to.equal(4); - expect(result[0]).to.deep.equal({ key: [ 'report', 0 ], value: undefined }); - expect(result[1]).to.deep.equal({ key: [ 'report', 1 ], value: { _id: 'contact1' }}); - expect(result[2]).to.deep.equal({ key: [ 'report', 2 ], value: { _id: 'contact2' }}); - expect(result[3]).to.deep.equal({ key: [ 'report', 3 ], value: { _id: 'contact3' }}); - }); - - it('does not emit lineage for empty contact parents', () => { - const doc1 = { - _id: 'report1', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - contact: {} - }; - const result1 = map(doc1, true); - expect(result1.length).to.equal(1); - expect(result1[0]).to.deep.equal({ key: [ 'report1', 0 ], value: undefined }); - - const doc2 = { - _id: 'report2', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - contact: { - _id: 'contact1', - parent: {} - } - }; - - map.reset(); - const result2 = map(doc2, true); - expect(result2.length).to.equal(2); - expect(result2[0]).to.deep.equal({ key: [ 'report2', 0 ], value: undefined }); - expect(result2[1]).to.deep.equal({ key: [ 'report2', 1 ], value: { _id: 'contact1' }}); - - const doc3 = { - _id: 'report3', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - contact: { - _id: 'contact1', - parent: { - _id: 'contact2', - parent: {} - } - } - }; - map.reset(); - const result3 = map(doc3, true); - expect(result3.length).to.equal(3); - expect(result3[0]).to.deep.equal({ key: [ 'report3', 0 ], value: undefined }); - expect(result3[1]).to.deep.equal({ key: [ 'report3', 1 ], value: { _id: 'contact1' }}); - expect(result3[2]).to.deep.equal({ key: [ 'report3', 2 ], value: { _id: 'contact2' }}); - }); - }); - - describe('contacts lineage', () => { - it('emits lineage for type `person`, `clinic`, `health_center` and `district_hospital`', () => { - const person = { _id: 'person', type: 'person' }; - const result = map(person, true); - expect(result.length).to.equal(1); - expect(result[0]).to.deep.equal({ key: [ 'person', 0 ], value: { _id: 'person' }}); - - map.reset(); - const clinic = { _id: 'clinic', type: CONTACT_TYPES.CLINIC }; - const resultClinic = map(clinic, true); - expect(resultClinic.length).to.equal(1); - expect(resultClinic[0]).to.deep.equal({ key: [ CONTACT_TYPES.CLINIC, 0 ], value: { _id: 'clinic' }}); - - map.reset(); - const healthCenter = { _id: 'healthCenter', type: 'health_center' }; - const resultHealthCenter = map(healthCenter, true); - expect(resultHealthCenter.length).to.equal(1); - expect(resultHealthCenter[0]).to.deep.equal({ key: [ 'healthCenter', 0 ], value: { _id: 'healthCenter' }}); - - map.reset(); - const districtHospital = { _id: 'districtHospital', type: CONTACT_TYPES.DISTRICT_HOSPITAL }; - const resultdistrictHospital = map(districtHospital, true); - expect(resultdistrictHospital.length).to.equal(1); - expect(resultdistrictHospital[0]) - .to.deep.equal({ key: [ 'districtHospital', 0 ], value: { _id: 'districtHospital' }}); - }); - - it('emits full lineage', () => { - const checkLineage = (result, key) => { - if (key > 0) { - expect(result).to.deep.equal({ key: [ 'person', key ], value: { _id: `parent${key}` }}); - } else { - expect(result).to.deep.equal({ key: [ 'person', 0 ], value: { _id: 'person' }}); - } - }; - for (let depth = 1; depth < 10; depth++) { - const doc = { _id: 'person', type: 'person', parent: {} }; - let currentParent = doc.parent; - for (let i = 1; i <= depth; i++) { - currentParent._id = `parent${i}`; - currentParent.parent = {}; - currentParent = currentParent.parent; - } - - map.reset(); - const results = map(doc, true); - expect(results.length).to.equal(depth + 1); - results.forEach(checkLineage); - } - }); - - it('does not emit lineage for empty parents', () => { - const doc1 = { - _id: 'contact1', - type: 'person', - parent: {} - }; - const result1 = map(doc1, true); - expect(result1.length).to.equal(1); - expect(result1[0]).to.deep.equal({ key: [ 'contact1', 0 ], value: { _id: 'contact1'} }); - - const doc2 = { - _id: 'contact2', - type: 'person', - parent: { - _id: 'contact3', - parent: {} - } - }; - map.reset(); - const result2 = map(doc2, true); - expect(result2.length).to.equal(2); - expect(result2[0]).to.deep.equal({ key: [ 'contact2', 0 ], value: { _id: 'contact2' }}); - expect(result2[1]).to.deep.equal({ key: [ 'contact2', 1 ], value: { _id: 'contact3' }}); - - const doc3 = { - _id: 'contact3', - type: 'person', - parent: { - _id: 'contact4', - parent: { - _id: 'contact5', - parent: {} - } - } - }; - map.reset(); - const result3 = map(doc3, true); - expect(result3.length).to.equal(3); - expect(result3[0]).to.deep.equal({ key: [ 'contact3', 0 ], value: { _id: 'contact3' }}); - expect(result3[1]).to.deep.equal({ key: [ 'contact3', 1 ], value: { _id: 'contact4' }}); - expect(result3[2]).to.deep.equal({ key: [ 'contact3', 2 ], value: { _id: 'contact5' }}); - }); - }); -});