diff --git a/api/src/controllers/contact.js b/api/src/controllers/contact.js new file mode 100644 index 00000000000..58d03e9a879 --- /dev/null +++ b/api/src/controllers/contact.js @@ -0,0 +1,50 @@ +const auth = require('../auth'); +const { Contact, Qualifier } = require('@medic/cht-datasource'); +const ctx = require('../services/data-context'); +const serverUtils = require('../server-utils'); + +const getContact = ({ with_lineage }) => ctx.bind(with_lineage === 'true' ? Contact.v1.getWithLineage : Contact.v1.get); +const getContactIds = () => ctx.bind(Contact.v1.getUuidsPage); + +const checkUserPermissions = async (req) => { + const userCtx = await auth.getUserCtx(req); + if (!auth.isOnlineOnly(userCtx) || !auth.hasAllPermissions(userCtx, 'can_view_contacts')) { + return Promise.reject({ code: 403, message: 'Insufficient privileges' }); + } +}; + +module.exports = { + v1: { + get: serverUtils.doOrError(async (req, res) => { + await checkUserPermissions(req); + const { uuid } = req.params; + const contact = await getContact(req.query)(Qualifier.byUuid(uuid)); + + if (!contact) { + return serverUtils.error({ status: 404, message: 'Contact not found' }, req, res); + } + + return res.json(contact); + }), + getUuids: serverUtils.doOrError(async (req, res) => { + await checkUserPermissions(req); + + if (!req.query.freetext && !req.query.type) { + return serverUtils.error({ status: 400, message: 'Either query param freetext or type is required' }, req, res); + } + const qualifier = {}; + + if (req.query.freetext) { + Object.assign(qualifier, Qualifier.byFreetext(req.query.freetext)); + } + + if (req.query.type) { + Object.assign(qualifier, Qualifier.byContactType(req.query.type)); + } + + const docs = await getContactIds()(qualifier, req.query.cursor, req.query.limit); + + return res.json(docs); + }), + }, +}; diff --git a/api/src/controllers/person.js b/api/src/controllers/person.js index 55ea939fcd2..dd66f3fbfd7 100644 --- a/api/src/controllers/person.js +++ b/api/src/controllers/person.js @@ -32,9 +32,8 @@ module.exports = { await checkUserPermissions(req); const personType = Qualifier.byContactType(req.query.type); - const limit = req.query.limit ? Number(req.query.limit) : req.query.limit; - const docs = await getPageByType()( personType, req.query.cursor, limit ); + const docs = await getPageByType()( personType, req.query.cursor, req.query.limit ); return res.json(docs); }), diff --git a/api/src/controllers/place.js b/api/src/controllers/place.js index ff184786561..9a30038067c 100644 --- a/api/src/controllers/place.js +++ b/api/src/controllers/place.js @@ -33,9 +33,8 @@ module.exports = { await checkUserPermissions(req); const placeType = Qualifier.byContactType(req.query.type); - const limit = req.query.limit ? Number(req.query.limit) : req.query.limit; - const docs = await getPageByType()( placeType, req.query.cursor, limit ); + const docs = await getPageByType()( placeType, req.query.cursor, req.query.limit ); return res.json(docs); }) diff --git a/api/src/controllers/report.js b/api/src/controllers/report.js new file mode 100644 index 00000000000..6ec94813230 --- /dev/null +++ b/api/src/controllers/report.js @@ -0,0 +1,39 @@ +const auth = require('../auth'); +const ctx = require('../services/data-context'); +const serverUtils = require('../server-utils'); +const { Report, Qualifier } = require('@medic/cht-datasource'); + +const getReport = () => ctx.bind(Report.v1.get); +const getReportIds = () => ctx.bind(Report.v1.getUuidsPage); + +const checkUserPermissions = async (req) => { + const userCtx = await auth.getUserCtx(req); + if (!auth.isOnlineOnly(userCtx) || !auth.hasAllPermissions(userCtx, 'can_view_reports')) { + return Promise.reject({ code: 403, message: 'Insufficient privileges' }); + } +}; + +module.exports = { + v1: { + get: serverUtils.doOrError(async (req, res) => { + await checkUserPermissions(req); + const { uuid } = req.params; + const report = await getReport()(Qualifier.byUuid(uuid)); + + if (!report) { + return serverUtils.error({ status: 404, message: 'Report not found' }, req, res); + } + + return res.json(report); + }), + getUuids: serverUtils.doOrError(async (req, res) => { + await checkUserPermissions(req); + + const qualifier = Qualifier.byFreetext(req.query.freetext); + + const docs = await getReportIds()(qualifier, req.query.cursor, req.query.limit); + + return res.json(docs); + }) + } +}; diff --git a/api/src/routing.js b/api/src/routing.js index 6f1ce353a56..2009234b369 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -37,8 +37,10 @@ const exportData = require('./controllers/export-data'); const records = require('./controllers/records'); const forms = require('./controllers/forms'); const users = require('./controllers/users'); +const contact = require('./controllers/contact'); const person = require('./controllers/person'); const place = require('./controllers/place'); +const report = require('./controllers/report'); const { people, places } = require('@medic/contacts')(config, db, dataContext); const upgrade = require('./controllers/upgrade'); const settings = require('./controllers/settings'); @@ -492,6 +494,12 @@ app.postJson('/api/v1/people', function(req, res) { app.get('/api/v1/person', person.v1.getAll); app.get('/api/v1/person/:uuid', person.v1.get); +app.get('/api/v1/contact/uuid', contact.v1.getUuids); +app.get('/api/v1/contact/:uuid', contact.v1.get); + +app.get('/api/v1/report/uuid', report.v1.getUuids); +app.get('/api/v1/report/:uuid', report.v1.get); + app.postJson('/api/v1/bulk-delete', bulkDocs.bulkDelete); // offline users are not allowed to hydrate documents via the hydrate API diff --git a/api/tests/mocha/controllers/contact.spec.js b/api/tests/mocha/controllers/contact.spec.js new file mode 100644 index 00000000000..d9a5ee9586a --- /dev/null +++ b/api/tests/mocha/controllers/contact.spec.js @@ -0,0 +1,474 @@ +const sinon = require('sinon'); +const auth = require('../../../src/auth'); +const dataContext = require('../../../src/services/data-context'); +const serverUtils = require('../../../src/server-utils'); +const { Contact, Qualifier, InvalidArgumentError} = require('@medic/cht-datasource'); +const {expect} = require('chai'); +const controller = require('../../../src/controllers/contact'); + +describe('Contact Controller', () => { + const userCtx = { hello: 'world' }; + let getUserCtx; + let isOnlineOnly; + let hasAllPermissions; + let dataContextBind; + let serverUtilsError; + let req; + let res; + + beforeEach(() => { + getUserCtx = sinon + .stub(auth, 'getUserCtx') + .resolves(userCtx); + isOnlineOnly = sinon.stub(auth, 'isOnlineOnly'); + hasAllPermissions = sinon.stub(auth, 'hasAllPermissions'); + dataContextBind = sinon.stub(dataContext, 'bind'); + serverUtilsError = sinon.stub(serverUtils, 'error'); + res = { + json: sinon.stub(), + }; + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + let contactGet; + let contactGetWithLineage; + + beforeEach(() => { + req = { + params: { uuid: 'uuid' }, + query: { } + }; + contactGet = sinon.stub(); + contactGetWithLineage = sinon.stub(); + dataContextBind + .withArgs(Contact.v1.get) + .returns(contactGet); + dataContextBind + .withArgs(Contact.v1.getWithLineage) + .returns(contactGetWithLineage); + }); + + it('returns a contact', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const contact = { name: 'John Doe', type: 'person' }; + contactGet.resolves(contact); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.get)).to.be.true; + expect(contactGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(contactGetWithLineage.notCalled).to.be.true; + expect(res.json.calledOnceWithExactly(contact)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a contact with lineage when the query parameter is set to "true"', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const contact = { name: 'John Doe', type: 'person' }; + contactGetWithLineage.resolves(contact); + req.query.with_lineage = 'true'; + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + expect(contactGet.notCalled).to.be.true; + expect(contactGetWithLineage.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(res.json.calledOnceWithExactly(contact)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a contact without lineage when the query parameter is set something else', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const contact = { name: 'John Doe', type: 'person' }; + contactGet.resolves(contact); + req.query.with_lineage = '1'; + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.get)).to.be.true; + expect(contactGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(contactGetWithLineage.notCalled).to.be.true; + expect(res.json.calledOnceWithExactly(contact)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a 404 error if contact is not found', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGet.resolves(null); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.get)).to.be.true; + expect(contactGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(contactGetWithLineage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly( + { status: 404, message: 'Contact not found' }, + req, + res + )).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error if user does not have can_view_contacts permission', async () => { + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(false); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(contactGet.notCalled).to.be.true; + expect(contactGetWithLineage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error if not an online user', async () => { + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(false); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.notCalled).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(contactGet.notCalled).to.be.true; + expect(contactGetWithLineage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + }); + + describe('getUuids', () => { + let contactGetUuidsPage; + let qualifierByContactType; + let qualifierByFreetext; + const contactType = 'person'; + const invalidContactType = 'invalidContact'; + const freetext = 'John'; + const invalidFreetext = 'invalidFreetext'; + const contactTypeOnlyQualifier = { contactType }; + const freetextOnlyQualifier = { freetext }; + const bothQualifier = { contactType, freetext }; + const contact = { name: 'John Doe', type: contactType }; + const limit = 100; + const cursor = null; + const contacts = Array.from({ length: 3 }, () => ({ ...contact })); + + beforeEach(() => { + contactGetUuidsPage = sinon.stub(); + qualifierByContactType = sinon.stub(Qualifier, 'byContactType'); + qualifierByFreetext = sinon.stub(Qualifier, 'byFreetext'); + dataContextBind.withArgs(Contact.v1.getUuidsPage).returns(contactGetUuidsPage); + qualifierByContactType.returns(contactTypeOnlyQualifier); + qualifierByFreetext.returns(freetextOnlyQualifier); + }); + + it('returns a page of contact ids with contact type param only', async () => { + req = { + query: { + type: contactType, + cursor, + limit, + } + }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGetUuidsPage.resolves(contacts); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.type)).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(contactTypeOnlyQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(contacts)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('returns a page of contact ids with freetext param only', async () => { + req = { + query: { + freetext, + cursor, + limit, + } + }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGetUuidsPage.resolves(contacts); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(freetextOnlyQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(contacts)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a page of contact ids with both contactType and freetext param', async () => { + req = { + query: { + type: contactType, + freetext, + cursor, + limit, + } + }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGetUuidsPage.resolves(contacts); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.type)).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(bothQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(contacts)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a page of contact ids with both contactType and freetext param and undefined limit', async () => { + req = { + query: { + type: contactType, + freetext, + cursor, + } + }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGetUuidsPage.resolves(contacts); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.type)).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(bothQualifier, cursor, undefined)).to.be.true; + expect(res.json.calledOnceWithExactly(contacts)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error in case of null limit', async () => { + req = { + query: { + type: contactType, + freetext, + cursor, + limit: null + } + }; + const err = new InvalidArgumentError(`The limit must be a positive number: [NaN].`); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGetUuidsPage.throws(err); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.type)).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(bothQualifier, cursor, null)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error if user does not have can_view_contacts permission', async () => { + req = { + query: { + type: contactType, + freetext, + cursor, + limit, + } + }; + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(false); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(contactGetUuidsPage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error if not an online user', async () => { + req = { + query: { + type: contactType, + freetext, + cursor, + limit, + } + }; + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(false); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.notCalled).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(contactGetUuidsPage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns 400 error when contactType is invalid', async () => { + req = { + query: { + type: invalidContactType, + cursor, + limit, + } + }; + const err = new InvalidArgumentError(`Invalid contact type: [${invalidContactType}]`); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGetUuidsPage.throws(err); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.type)).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(contactTypeOnlyQualifier, cursor, limit)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns 400 error when freetext is invalid', async () => { + req = { + query: { + freetext: invalidFreetext, + cursor, + limit, + } + }; + const err = new InvalidArgumentError(`Invalid freetext: [${invalidFreetext}]`); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGetUuidsPage.throws(err); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(freetextOnlyQualifier, cursor, limit)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns 400 error when contactType AND freetext is not present', async () => { + req = { + query: { + cursor, + limit, + } + }; + const err = { status: 400, message: 'Either query param freetext or type is required' }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGetUuidsPage.throws(err); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(contactGetUuidsPage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('rethrows error in case of other errors', async () => { + req = { + query: { + freetext: freetext, + type: contactType, + cursor, + limit, + } + }; + const err = new Error('error'); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + contactGetUuidsPage.throws(err); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.type)).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(bothQualifier, cursor, limit)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + }); + }); +}); diff --git a/api/tests/mocha/controllers/person.spec.js b/api/tests/mocha/controllers/person.spec.js index fe9ab0ee348..f3907e23961 100644 --- a/api/tests/mocha/controllers/person.spec.js +++ b/api/tests/mocha/controllers/person.spec.js @@ -51,11 +51,6 @@ describe('Person Controller', () => { .returns(personGetWithLineage); }); - afterEach(() => { - expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; - expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; - }); - it('returns a person', async () => { isOnlineOnly.returns(true); hasAllPermissions.returns(true); @@ -70,6 +65,8 @@ describe('Person Controller', () => { expect(personGetWithLineage.notCalled).to.be.true; expect(res.json.calledOnceWithExactly(person)).to.be.true; expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns a person with lineage when the query parameter is set to "true"', async () => { @@ -87,6 +84,8 @@ describe('Person Controller', () => { expect(personGetWithLineage.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; expect(res.json.calledOnceWithExactly(person)).to.be.true; expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns a person without lineage when the query parameter is set something else', async () => { @@ -104,6 +103,8 @@ describe('Person Controller', () => { expect(personGetWithLineage.notCalled).to.be.true; expect(res.json.calledOnceWithExactly(person)).to.be.true; expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns a 404 error if person is not found', async () => { @@ -123,6 +124,8 @@ describe('Person Controller', () => { req, res )).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns error if user does not have can_view_contacts permission', async () => { @@ -138,6 +141,8 @@ describe('Person Controller', () => { expect(personGetWithLineage.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns error if not an online user', async () => { @@ -152,6 +157,8 @@ describe('Person Controller', () => { expect(personGetWithLineage.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); }); @@ -180,11 +187,6 @@ describe('Person Controller', () => { qualifierByContactType.returns(personTypeQualifier); }); - afterEach(() => { - expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; - expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; - }); - it('returns a page of people with correct query params', async () => { isOnlineOnly.returns(true); hasAllPermissions.returns(true); @@ -198,6 +200,8 @@ describe('Person Controller', () => { expect(personGetPageByType.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true; expect(res.json.calledOnceWithExactly(people)).to.be.true; expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns error if user does not have can_view_contacts permission', async () => { @@ -213,6 +217,8 @@ describe('Person Controller', () => { expect(personGetPageByType.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns error if not an online user', async () => { @@ -227,6 +233,8 @@ describe('Person Controller', () => { expect(personGetPageByType.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns 400 error when personType is invalid', async () => { @@ -243,6 +251,8 @@ describe('Person Controller', () => { expect(personGetPageByType.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('rethrows error in case of other errors', async () => { @@ -259,6 +269,8 @@ describe('Person Controller', () => { expect(personGetPageByType.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); }); }); diff --git a/api/tests/mocha/controllers/place.spec.js b/api/tests/mocha/controllers/place.spec.js index a4328e110c8..05396ee23b4 100644 --- a/api/tests/mocha/controllers/place.spec.js +++ b/api/tests/mocha/controllers/place.spec.js @@ -51,11 +51,6 @@ describe('Place Controller', () => { .returns(placeGetWithLineage); }); - afterEach(() => { - expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; - expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; - }); - it('returns a place', async () => { isOnlineOnly.returns(true); hasAllPermissions.returns(true); @@ -70,6 +65,8 @@ describe('Place Controller', () => { expect(placeGetWithLineage.notCalled).to.be.true; expect(res.json.calledOnceWithExactly(place)).to.be.true; expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns a place with lineage when the query parameter is set to "true"', async () => { @@ -87,6 +84,8 @@ describe('Place Controller', () => { expect(placeGetWithLineage.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; expect(res.json.calledOnceWithExactly(place)).to.be.true; expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns a place without lineage when the query parameter is set something else', async () => { @@ -104,6 +103,8 @@ describe('Place Controller', () => { expect(placeGetWithLineage.notCalled).to.be.true; expect(res.json.calledOnceWithExactly(place)).to.be.true; expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns a 404 error if place is not found', async () => { @@ -123,6 +124,8 @@ describe('Place Controller', () => { req, res )).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns error if user does not have can_view_contacts permission', async () => { @@ -138,6 +141,8 @@ describe('Place Controller', () => { expect(placeGetWithLineage.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns error if not an online user', async () => { @@ -152,6 +157,8 @@ describe('Place Controller', () => { expect(placeGetWithLineage.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); }); @@ -180,11 +187,6 @@ describe('Place Controller', () => { qualifierByContactType.returns(placeTypeQualifier); }); - afterEach(() => { - expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; - expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; - }); - it('returns a page of places with correct query params', async () => { isOnlineOnly.returns(true); hasAllPermissions.returns(true); @@ -198,6 +200,8 @@ describe('Place Controller', () => { expect(placeGetPageByType.calledOnceWithExactly(placeTypeQualifier, cursor, limit)).to.be.true; expect(res.json.calledOnceWithExactly(places)).to.be.true; expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns error if user does not have can_view_contacts permission', async () => { @@ -213,6 +217,8 @@ describe('Place Controller', () => { expect(placeGetPageByType.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns error if not an online user', async () => { @@ -227,6 +233,8 @@ describe('Place Controller', () => { expect(placeGetPageByType.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('returns 400 error when placeType is invalid', async () => { @@ -243,6 +251,8 @@ describe('Place Controller', () => { expect(placeGetPageByType.calledOnceWithExactly(placeTypeQualifier, cursor, limit)).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); it('rethrows error in case of other errors', async () => { @@ -259,6 +269,8 @@ describe('Place Controller', () => { expect(placeGetPageByType.calledOnceWithExactly(placeTypeQualifier, cursor, limit)).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; }); }); }); diff --git a/api/tests/mocha/controllers/report.spec.js b/api/tests/mocha/controllers/report.spec.js new file mode 100644 index 00000000000..6e521387a00 --- /dev/null +++ b/api/tests/mocha/controllers/report.spec.js @@ -0,0 +1,312 @@ +const sinon = require('sinon'); +const auth = require('../../../src/auth'); +const dataContext = require('../../../src/services/data-context'); +const serverUtils = require('../../../src/server-utils'); +const controller = require('../../../src/controllers/report'); +const { Report, Qualifier, InvalidArgumentError} = require('@medic/cht-datasource'); +const {expect} = require('chai'); + +describe('Report Controller Tests', () => { + const userCtx = { hello: 'world' }; + let getUserCtx; + let isOnlineOnly; + let hasAllPermissions; + let dataContextBind; + let serverUtilsError; + let req; + let res; + + beforeEach(() => { + getUserCtx = sinon + .stub(auth, 'getUserCtx') + .resolves(userCtx); + isOnlineOnly = sinon.stub(auth, 'isOnlineOnly'); + hasAllPermissions = sinon.stub(auth, 'hasAllPermissions'); + dataContextBind = sinon.stub(dataContext, 'bind'); + serverUtilsError = sinon.stub(serverUtils, 'error'); + res = { + json: sinon.stub(), + }; + }); + + + describe('v1', () => { + describe('get', () => { + let reportGet; + + beforeEach(() => { + req = { + params: { uuid: 'uuid' }, + query: { } + }; + reportGet = sinon.stub(); + dataContextBind + .withArgs(Report.v1.get) + .returns(reportGet); + }); + + it('returns a report', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const report = { name: 'John Doe\'s Report', type: 'data_record', form: 'yes' }; + reportGet.resolves(report); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_reports')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Report.v1.get)).to.be.true; + expect(reportGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(res.json.calledOnceWithExactly(report)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a 404 error if report is not found', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + reportGet.resolves(null); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_reports')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Report.v1.get)).to.be.true; + expect(reportGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly( + { status: 404, message: 'Report not found' }, + req, + res + )).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error if user does not have can_view_reports permission', async () => { + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(false); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_reports')).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(reportGet.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error if not an online user', async () => { + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(false); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.notCalled).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(reportGet.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + }); + + describe('getUuids', () => { + let reportGetIdsPage; + let qualifierByFreetext; + const freetext = 'report'; + const invalidFreetext = 'invalidFreetext'; + const freetextOnlyQualifier = { freetext }; + const report = { name: 'Nice report', type: 'data_record', form: 'yes' }; + const limit = 100; + const cursor = null; + const reports = Array.from({ length: 3 }, () => ({ ...report })); + + beforeEach(() => { + req = { + query: { + freetext, + cursor, + limit, + } + }; + reportGetIdsPage = sinon.stub(); + qualifierByFreetext = sinon.stub(Qualifier, 'byFreetext'); + dataContextBind.withArgs(Report.v1.getUuidsPage).returns(reportGetIdsPage); + qualifierByFreetext.returns(freetextOnlyQualifier); + }); + + it('returns a page of report ids', async () => { + req = { + query: { + freetext, + cursor, + limit, + } + }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + reportGetIdsPage.resolves(reports); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_reports')).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Report.v1.getUuidsPage)).to.be.true; + expect(reportGetIdsPage.calledOnceWithExactly(freetextOnlyQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(reports)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a page of report ids for undefined limit', async () => { + req = { + query: { + freetext, + cursor, + } + }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + reportGetIdsPage.resolves(reports); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_reports')).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Report.v1.getUuidsPage)).to.be.true; + expect(reportGetIdsPage.calledOnceWithExactly(freetextOnlyQualifier, cursor, undefined)).to.be.true; + expect(res.json.calledOnceWithExactly(reports)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error for null limit', async () => { + req = { + query: { + freetext, + cursor, + limit: null + } + }; + const err = new InvalidArgumentError(`The limit must be a positive number: [NaN].`); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + reportGetIdsPage.throws(err); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_reports')).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Report.v1.getUuidsPage)).to.be.true; + expect(reportGetIdsPage.calledOnceWithExactly(freetextOnlyQualifier, cursor, null)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error if user does not have can_view_reports permission', async () => { + req = { + query: { + freetext, + cursor, + limit, + } + }; + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(false); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_reports')).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(reportGetIdsPage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns error if not an online user', async () => { + req = { + query: { + freetext, + cursor, + limit, + } + }; + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(false); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.notCalled).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(reportGetIdsPage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns 400 error when freetext is invalid', async () => { + req = { + query: { + freetext: invalidFreetext, + cursor, + limit, + } + }; + const err = new InvalidArgumentError(`Invalid freetext: [${invalidFreetext}]`); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + reportGetIdsPage.throws(err); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_reports')).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Report.v1.getUuidsPage)).to.be.true; + expect(reportGetIdsPage.calledOnceWithExactly(freetextOnlyQualifier, cursor, limit)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('rethrows error in case of other errors', async () => { + req = { + query: { + freetext: freetext, + cursor, + limit, + } + }; + const err = new Error('error'); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + reportGetIdsPage.throws(err); + + await controller.v1.getUuids(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_reports')).to.be.true; + expect(qualifierByFreetext.calledOnceWithExactly(req.query.freetext)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Report.v1.getUuidsPage)).to.be.true; + expect(reportGetIdsPage.calledOnceWithExactly(freetextOnlyQualifier, cursor, limit)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/.eslintrc.js b/shared-libs/cht-datasource/.eslintrc.js index 7edcec0373d..50217fc8a4b 100644 --- a/shared-libs/cht-datasource/.eslintrc.js +++ b/shared-libs/cht-datasource/.eslintrc.js @@ -30,7 +30,10 @@ module.exports = { jsdoc: { contexts: [ ] - } + }, + polyfills: [ + 'Report' + ] }, rules: { ['@typescript-eslint/explicit-module-boundary-types']: ['error', { allowedNames: ['getDatasource'] }], diff --git a/shared-libs/cht-datasource/src/contact-types.ts b/shared-libs/cht-datasource/src/contact-types.ts new file mode 100644 index 00000000000..a8f3d2903d8 --- /dev/null +++ b/shared-libs/cht-datasource/src/contact-types.ts @@ -0,0 +1,28 @@ +import { DataObject, Identifiable, isDataObject, isIdentifiable } from './libs/core'; +import { Doc } from './libs/doc'; + +/** @ignore */ +export namespace v1 { + /** @internal */ + export interface NormalizedParent extends DataObject, Identifiable { + readonly parent?: NormalizedParent; + } + + /** @internal */ + export interface Contact extends Doc, NormalizedParent { + readonly contact_type?: string; + readonly name?: string; + readonly reported_date?: Date; + readonly type: string; + } + + /** @internal */ + export interface ContactWithLineage extends Contact { + readonly parent?: ContactWithLineage | NormalizedParent; + } + + /** @ignore */ + export const isNormalizedParent = (value: unknown): value is NormalizedParent => { + return isDataObject(value) && isIdentifiable(value) && (!value.parent || isNormalizedParent(value.parent)); + }; +} diff --git a/shared-libs/cht-datasource/src/contact.ts b/shared-libs/cht-datasource/src/contact.ts new file mode 100644 index 00000000000..71c2afc3bbd --- /dev/null +++ b/shared-libs/cht-datasource/src/contact.ts @@ -0,0 +1,143 @@ +import { + getPagedGenerator, + Nullable, + Page, +} from './libs/core'; +import { + ContactTypeQualifier, + FreetextQualifier, + UuidQualifier +} from './qualifier'; +import { adapt, assertDataContext, DataContext } from './libs/data-context'; +import { LocalDataContext } from './local/libs/data-context'; +import { RemoteDataContext } from './remote/libs/data-context'; +import * as Local from './local'; +import * as Remote from './remote'; +import * as ContactTypes from './contact-types'; +import { DEFAULT_DOCS_PAGE_LIMIT } from './libs/constants'; +import { + assertContactTypeFreetextQualifier, + assertCursor, + assertFreetextQualifier, + assertLimit, + assertTypeQualifier, + assertUuidQualifier, + isContactType, + isFreetextType, +} from './libs/parameter-validators'; + +/** */ +export namespace v1 { + /** + * Immutable data about a Contact. + */ + export type Contact = ContactTypes.v1.Contact; + /** + * Immutable data about a contact, including the full records of the parent's lineage. + */ + export type ContactWithLineage = ContactTypes.v1.ContactWithLineage; + + const getContact = + ( + localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, + remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise + ) => (context: DataContext) => { + assertDataContext(context); + const fn = adapt(context, localFn, remoteFn); + return async (qualifier: UuidQualifier): Promise => { + assertUuidQualifier(qualifier); + return fn(qualifier); + }; + }; + + /** + * Returns a function for retrieving a contact from the given data context. + * @param context the current data context + * @returns a function for retrieving a contact + * @throws Error if a data context is not provided + */ + export const get = getContact(Local.Contact.v1.get, Remote.Contact.v1.get); + + /** + * Returns a function for retrieving a contact from the given data context with the contact's parent lineage. + * @param context the current data context + * @returns a function for retrieving a contact with the contact's parent lineage + * @throws Error if a data context is not provided + */ + export const getWithLineage = getContact(Local.Contact.v1.getWithLineage, Remote.Contact.v1.getWithLineage); + + /** + * Returns a function for retrieving a paged array of contact identifiers from the given data context. + * @param context the current data context + * @returns a function for retrieving a paged array of contact identifiers + * @throws Error if a data context is not provided + * @see {@link getUuids} which provides the same data, but without having to manually account for paging + */ + export const getUuidsPage = (context: DataContext): typeof curriedFn => { + assertDataContext(context); + const fn = adapt(context, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage); + + /** + * Returns an array of contact identifiers for the provided page specifications. + * @param qualifier the limiter defining which identifiers to return + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of identifiers to return. Default is 10000. + * @returns a page of contact identifiers for the provided specification + * @throws InvalidArrgumentError if no qualifier is provided or if the qualifier is invalid + * @throws InvalidArgumentError if the provided `limit` value is `<=0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + const curriedFn = async ( + qualifier: ContactTypeQualifier | FreetextQualifier, + cursor: Nullable = null, + limit: number | `${number}` = DEFAULT_DOCS_PAGE_LIMIT + ): Promise> => { + assertCursor(cursor); + assertLimit(limit); + + if (isContactType(qualifier) && isFreetextType(qualifier)) { + assertContactTypeFreetextQualifier(qualifier); + } else if (isContactType(qualifier)) { + assertTypeQualifier(qualifier); + } else if (isFreetextType(qualifier)) { + assertFreetextQualifier(qualifier); + } + + return fn(qualifier, cursor, Number(limit)); + }; + return curriedFn; + }; + + /** + * Returns a function for getting a generator that fetches contact identifiers from the given data context. + * @param context the current data context + * @returns a function for getting a generator that fetches contact identifiers + * @throws Error if a data context is not provided + */ + export const getUuids = (context: DataContext): typeof curriedGen => { + assertDataContext(context); + const getPage = context.bind(v1.getUuidsPage); + + /** + * Returns a generator for fetching all contact identifiers that match the given qualifier + * @param qualifier the limiter defining which identifiers to return + * @returns a generator for fetching all contact identifiers that match the given qualifier + * @throws InvalidArgumentError if no qualifier is provided or if the qualifier is invalid + */ + const curriedGen = ( + qualifier: ContactTypeQualifier | FreetextQualifier + ): AsyncGenerator => { + if (isContactType(qualifier) && isFreetextType(qualifier)) { + assertContactTypeFreetextQualifier(qualifier); + } else if (isContactType(qualifier)) { + assertTypeQualifier(qualifier); + } else if (isFreetextType(qualifier)) { + assertFreetextQualifier(qualifier); + } + + return getPagedGenerator(getPage, qualifier); + }; + return curriedGen; + }; +} diff --git a/shared-libs/cht-datasource/src/index.ts b/shared-libs/cht-datasource/src/index.ts index 394b23e9bae..9c3d7c93273 100644 --- a/shared-libs/cht-datasource/src/index.ts +++ b/shared-libs/cht-datasource/src/index.ts @@ -29,18 +29,26 @@ import { hasAnyPermission, hasPermissions } from './auth'; import { Nullable } from './libs/core'; import { assertDataContext, DataContext } from './libs/data-context'; +import * as Contact from './contact'; import * as Person from './person'; import * as Place from './place'; import * as Qualifier from './qualifier'; +import * as Report from './report'; +import { + DEFAULT_DOCS_PAGE_LIMIT, + DEFAULT_IDS_PAGE_LIMIT, +} from './libs/constants'; export { Nullable, NonEmptyArray } from './libs/core'; export { DataContext } from './libs/data-context'; export { getLocalDataContext } from './local'; export { getRemoteDataContext } from './remote'; export { InvalidArgumentError } from './libs/error'; +export * as Contact from './contact'; export * as Person from './person'; export * as Place from './place'; export * as Qualifier from './qualifier'; +export * as Report from './report'; /** * Returns the source for CHT data. @@ -54,12 +62,67 @@ export const getDatasource = (ctx: DataContext) => { v1: { hasPermissions, hasAnyPermission, + contact: { + /** + * Returns a contact by their UUID. + * @param uuid the UUID of the contact to retrieve + * @returns the contact or `null` if no contact is found for the UUID + * @throws InvalidArgumentError if no UUID is provided + */ + getByUuid: (uuid: string) => ctx.bind(Contact.v1.get)(Qualifier.byUuid(uuid)), + + /** + * Returns a contact by their UUID along with the contact's parent lineage. + * @param uuid the UUID of the contact to retrieve + * @returns the contact or `null` if no contact is found the UUID + * @throws InvalidArgumentError if no UUID is provided + */ + getByUuidWithLineage: (uuid: string) => ctx.bind(Contact.v1.getWithLineage)(Qualifier.byUuid(uuid)), + + /** + * Returns an array of contact identifiers for the provided page specifications. + * @param freetext the search keyword(s) + * @param type the type of contact to search for + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of identifiers to return. Default is 10000. + * @returns a page of contact identifiers for the provided specifications + * @throws InvalidArgumentError if either `freetext` or `type` is not provided + * @throws InvalidArgumentError if the `freetext` is empty or if the `type is invalid for a contact + * @throws InvalidArgumentError if the provided limit is `<= 0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + getUuidsPageByTypeFreetext: ( + freetext: string, + type: string, + cursor: Nullable = null, + limit: number | `${number}` = DEFAULT_IDS_PAGE_LIMIT + ) => ctx.bind(Contact.v1.getUuidsPage)( + Qualifier.and(Qualifier.byFreetext(freetext), Qualifier.byContactType(type)), cursor, limit + ), + + /** + * Returns a generator for fetching all the contact identifiers for given + * `freetext` and `type`. + * @param freetext the search keyword(s) + * @param type the type of contact identifiers to return + * @returns a generator for fetching all the contact identifiers matching the given `freetext` and `type`. + * @throws InvalidArgumentError if either `freetext` or `type` is not provided + * @throws InvalidArgumentError if the `freetext` is empty or if the `type is invalid for a contact + */ + getUuids: ( + freetext: string, + type: string + ) => ctx.bind(Contact.v1.getUuids)( + Qualifier.and(Qualifier.byFreetext(freetext), Qualifier.byContactType(type)) + ), + }, place: { /** * Returns a place by its UUID. * @param uuid the UUID of the place to retrieve * @returns the place or `null` if no place is found for the UUID - * @throws Error if no UUID is provided + * @throws InvalidArgumentError if no UUID is provided */ getByUuid: (uuid: string) => ctx.bind(Place.v1.get)(Qualifier.byUuid(uuid)), @@ -67,7 +130,7 @@ export const getDatasource = (ctx: DataContext) => { * Returns a place by its UUID along with the place's parent lineage. * @param uuid the UUID of the place to retrieve * @returns the place or `null` if no place is found for the UUID - * @throws Error if no UUID is provided + * @throws InvalidArgumentError if no UUID is provided */ getByUuidWithLineage: (uuid: string) => ctx.bind(Place.v1.getWithLineage)(Qualifier.byUuid(uuid)), @@ -86,7 +149,7 @@ export const getDatasource = (ctx: DataContext) => { getPageByType: ( placeType: string, cursor: Nullable = null, - limit = 100 + limit: number | `${number}` = DEFAULT_DOCS_PAGE_LIMIT ) => ctx.bind(Place.v1.getPage)( Qualifier.byContactType(placeType), cursor, limit ), @@ -104,7 +167,7 @@ export const getDatasource = (ctx: DataContext) => { * Returns a person by their UUID. * @param uuid the UUID of the person to retrieve * @returns the person or `null` if no person is found for the UUID - * @throws Error if no UUID is provided + * @throws InvalidArgumentError if no UUID is provided */ getByUuid: (uuid: string) => ctx.bind(Person.v1.get)(Qualifier.byUuid(uuid)), @@ -112,7 +175,7 @@ export const getDatasource = (ctx: DataContext) => { * Returns a person by their UUID along with the person's parent lineage. * @param uuid the UUID of the person to retrieve * @returns the person or `null` if no person is found for the UUID - * @throws Error if no UUID is provided + * @throws InvalidArgumentError if no UUID is provided */ getByUuidWithLineage: (uuid: string) => ctx.bind(Person.v1.getWithLineage)(Qualifier.byUuid(uuid)), @@ -131,7 +194,7 @@ export const getDatasource = (ctx: DataContext) => { getPageByType: ( personType: string, cursor: Nullable = null, - limit = 100 + limit: number | `${number}` = DEFAULT_DOCS_PAGE_LIMIT ) => ctx.bind(Person.v1.getPage)( Qualifier.byContactType(personType), cursor, limit ), @@ -143,7 +206,45 @@ export const getDatasource = (ctx: DataContext) => { * @throws InvalidArgumentError if no type is provided or if the type is not for a person */ getByType: (personType: string) => ctx.bind(Person.v1.getAll)(Qualifier.byContactType(personType)), - } - } + }, + report: { + /** + * Returns a report by their UUID. + * @param uuid the UUID of the report to retrieve + * @returns the report or `null` if no report is found for the UUID + * @throws InvalidArgumentError if no UUID is provided + */ + getByUuid: (uuid: string) => ctx.bind(Report.v1.get)(Qualifier.byUuid(uuid)), + + /** + * Returns a paged array of report identifiers from the given data context. + * @param qualifier the limiter defining which identifiers to return + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of identifiers to return. Default is 10000. + * @returns a page of report identifiers for the provided specification + * @throws InvalidArgumentError if no qualifier is provided or if the qualifier is invalid + * @throws InvalidArgumentError if the provided `limit` value is `<=0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + getUuidsPage: ( + qualifier: string, + cursor: Nullable = null, + limit: number | `${number}` = DEFAULT_IDS_PAGE_LIMIT + ) => ctx.bind(Report.v1.getUuidsPage)( + Qualifier.byFreetext(qualifier), cursor, limit + ), + + /** + * Returns a generator for fetching all the contact identifiers for given qualifier + * @param qualifier the limiter defining which identifiers to return + * @returns a generator for fetching all report identifiers that match the given qualifier + * @throws InvalidArgumentError if no qualifier is provided or if the qualifier is invalid + */ + getUuids: ( + qualifier: string, + ) => ctx.bind(Report.v1.getUuids)(Qualifier.byFreetext(qualifier)), + }, + }, }; }; diff --git a/shared-libs/cht-datasource/src/libs/constants.ts b/shared-libs/cht-datasource/src/libs/constants.ts new file mode 100644 index 00000000000..7c5d49aa961 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/constants.ts @@ -0,0 +1,8 @@ +/** @ignore */ +export const DEFAULT_DOCS_PAGE_LIMIT = 100; + +/** @ignore */ +export const DEFAULT_IDS_PAGE_LIMIT = 10000; + +/** @ignore */ +export const END_OF_ALPHABET_MARKER = '\ufff0'; diff --git a/shared-libs/cht-datasource/src/libs/contact.ts b/shared-libs/cht-datasource/src/libs/contact.ts deleted file mode 100644 index 63c0f505b93..00000000000 --- a/shared-libs/cht-datasource/src/libs/contact.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Doc } from './doc'; -import { DataObject, Identifiable, isDataObject, isIdentifiable } from './core'; - -/** @internal */ -export interface NormalizedParent extends DataObject, Identifiable { - readonly parent?: NormalizedParent; -} - -/** @internal */ -export const isNormalizedParent = (value: unknown): value is NormalizedParent => { - return isDataObject(value) - && isIdentifiable(value) - && (!value.parent || isNormalizedParent(value.parent)); -}; - -/** @internal */ -export interface Contact extends Doc, NormalizedParent { - readonly contact_type?: string; - readonly name?: string; - readonly reported_date?: Date; - readonly type: string; -} diff --git a/shared-libs/cht-datasource/src/libs/core.ts b/shared-libs/cht-datasource/src/libs/core.ts index 732f6d1bef7..d1c87becbbc 100644 --- a/shared-libs/cht-datasource/src/libs/core.ts +++ b/shared-libs/cht-datasource/src/libs/core.ts @@ -1,6 +1,4 @@ import { DataContext } from './data-context'; -import { ContactTypeQualifier, isContactTypeQualifier } from '../qualifier'; -import { InvalidArgumentError } from './error'; /** * A value that could be `null`. @@ -146,26 +144,3 @@ export const getPagedGenerator = async function* ( return null; }; - -/** @internal */ -export const assertTypeQualifier: (qualifier: unknown) => asserts qualifier is ContactTypeQualifier = ( - qualifier: unknown -) => { - if (!isContactTypeQualifier(qualifier)) { - throw new InvalidArgumentError(`Invalid contact type [${JSON.stringify(qualifier)}].`); - } -}; - -/** @internal */ -export const assertLimit: (limit: unknown) => asserts limit is number = (limit: unknown) => { - if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) { - throw new InvalidArgumentError(`The limit must be a positive number: [${String(limit)}].`); - } -}; - -/** @internal */ -export const assertCursor: (cursor: unknown) => asserts cursor is Nullable = (cursor: unknown) => { - if (cursor !== null && (typeof cursor !== 'string' || !cursor.length)) { - throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}].`); - } -}; diff --git a/shared-libs/cht-datasource/src/libs/data-context.ts b/shared-libs/cht-datasource/src/libs/data-context.ts index d58c017c780..51fa0d65293 100644 --- a/shared-libs/cht-datasource/src/libs/data-context.ts +++ b/shared-libs/cht-datasource/src/libs/data-context.ts @@ -13,7 +13,7 @@ export interface DataContext { * @param fn the function to execute * @returns the result of the function */ - bind: (fn: (ctx: DataContext) => T) => T + bind: (fn: (ctx: DataContext) => T) => T; } const isDataContext = (context: unknown): context is DataContext => { diff --git a/shared-libs/cht-datasource/src/libs/parameter-validators.ts b/shared-libs/cht-datasource/src/libs/parameter-validators.ts new file mode 100644 index 00000000000..8f0fcf3dc7a --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/parameter-validators.ts @@ -0,0 +1,80 @@ +import { InvalidArgumentError } from './error'; +import { + ContactTypeQualifier, + FreetextQualifier, + UuidQualifier, + isContactTypeQualifier, + isFreetextQualifier, + isUuidQualifier, +} from '../qualifier'; +import { Nullable } from './core'; + +/** @internal */ +export const assertTypeQualifier: (qualifier: unknown) => asserts qualifier is ContactTypeQualifier = ( + qualifier: unknown +) => { + if (!isContactTypeQualifier(qualifier)) { + throw new InvalidArgumentError(`Invalid contact type [${JSON.stringify(qualifier)}].`); + } +}; + +/** @internal */ +export const assertLimit: (limit: unknown) => asserts limit is number | `${number}` = (limit: unknown) => { + const numberLimit = Number(limit); + if (!Number.isInteger(numberLimit) || numberLimit <= 0) { + throw new InvalidArgumentError(`The limit must be a positive number: [${String(limit)}].`); + } +}; + +/** @internal */ +export const assertCursor: (cursor: unknown) => asserts cursor is Nullable = (cursor: unknown) => { + if (cursor !== null && (typeof cursor !== 'string' || !cursor.length)) { + throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}].`); + } +}; + +/** @internal */ +export const assertFreetextQualifier: (qualifier: unknown) => asserts qualifier is FreetextQualifier = ( + qualifier: unknown +) => { + if (!isFreetextQualifier(qualifier)) { + throw new InvalidArgumentError(`Invalid freetext [${JSON.stringify(qualifier)}].`); + } +}; + +/** @internal */ +export const assertContactTypeFreetextQualifier: ( + qualifier: unknown +) => asserts qualifier is ContactTypeQualifier | FreetextQualifier = ( + qualifier: unknown +) => { + if (!(isContactTypeQualifier(qualifier) || isFreetextQualifier(qualifier))) { + throw new InvalidArgumentError( + `Invalid qualifier [${JSON.stringify(qualifier)}]. Must be a contact type and/or freetext qualifier.` + ); + } +}; + +/** @internal */ +export const assertUuidQualifier: (qualifier: unknown) => asserts qualifier is UuidQualifier = (qualifier: unknown) => { + if (!isUuidQualifier(qualifier)) { + throw new InvalidArgumentError(`Invalid identifier [${JSON.stringify(qualifier)}].`); + } +}; + +/** @ignore */ +export const isContactType = (value: ContactTypeQualifier | FreetextQualifier): value is ContactTypeQualifier => { + return 'contactType' in value; +}; + +/** @ignore */ +export const isFreetextType = (value: ContactTypeQualifier | FreetextQualifier): value is FreetextQualifier => { + return 'freetext' in value; +}; + +/** @ignore */ +export const isContactTypeAndFreetextType = ( + qualifier: ContactTypeQualifier | FreetextQualifier +): qualifier is ContactTypeQualifier & FreetextQualifier => { + return isContactType(qualifier) && isFreetextType(qualifier); +}; diff --git a/shared-libs/cht-datasource/src/local/contact.ts b/shared-libs/cht-datasource/src/local/contact.ts new file mode 100644 index 00000000000..d33d1427f9d --- /dev/null +++ b/shared-libs/cht-datasource/src/local/contact.ts @@ -0,0 +1,165 @@ +import { LocalDataContext, SettingsService } from './libs/data-context'; +import { + getDocById, + getPaginatedDocs, + queryDocUuidsByKey, + queryDocUuidsByRange +} from './libs/doc'; +import { ContactTypeQualifier, FreetextQualifier, isKeyedFreetextQualifier, UuidQualifier } from '../qualifier'; +import * as ContactType from '../contact-types'; +import { isNonEmptyArray, NonEmptyArray, Nullable, Page } from '../libs/core'; +import { Doc } from '../libs/doc'; +import logger from '@medic/logger'; +import contactTypeUtils from '@medic/contact-types-utils'; +import { getContactLineage, getLineageDocsById } from './libs/lineage'; +import { InvalidArgumentError } from '../libs/error'; +import { normalizeFreetext, validateCursor } from './libs/core'; +import { END_OF_ALPHABET_MARKER } from '../libs/constants'; +import { isContactType, isContactTypeAndFreetextType } from '../libs/parameter-validators'; + +/** @internal */ +export namespace v1 { + const getContactTypes = (settings: SettingsService): string[] => { + const contactTypesObjects = contactTypeUtils.getContactTypes(settings.getAll()); + return contactTypesObjects.map((item) => item.id) as string[]; + }; + + const isContact = + (settings: SettingsService) => (doc: Nullable, uuid?: string): doc is ContactType.v1.Contact => { + if (!doc) { + if (uuid) { + logger.warn(`No contact found for identifier [${uuid}].`); + } + return false; + } + + if (!contactTypeUtils.isContact(settings.getAll(), doc)) { + logger.warn(`Document [${doc._id}] is not a valid contact.`); + return false; + } + return true; + }; + + /** @internal */ + export const get = ({ medicDb, settings }: LocalDataContext) => { + const getMedicDocById = getDocById(medicDb); + return async (identifier: UuidQualifier): Promise> => { + const doc = await getMedicDocById(identifier.uuid); + if (!isContact(settings)(doc, identifier.uuid)) { + return null; + } + + return doc; + }; + }; + + /** @internal */ + export const getWithLineage = ({ medicDb, settings }: LocalDataContext) => { + const getLineageDocs = getLineageDocsById(medicDb); + + return async (identifier: UuidQualifier): Promise> => { + const [contact, ...lineageContacts] = await getLineageDocs(identifier.uuid); + if (!isContact(settings)(contact, identifier.uuid)) { + return null; + } + + if (!isNonEmptyArray(lineageContacts)) { + logger.debug(`No lineage contacts found for ${contact.type} [${identifier.uuid}].`); + return contact; + } + + const combinedContacts: NonEmptyArray> = [contact, ...lineageContacts]; + + if (contactTypeUtils.isPerson(settings.getAll(), contact)) { + return await getContactLineage(medicDb)(lineageContacts, contact, true); + } + + return await getContactLineage(medicDb)(combinedContacts); + }; + }; + + /** @internal */ + export const getUuidsPage = ({ medicDb, settings }: LocalDataContext) => { + // Define query functions + const getByTypeExactMatchFreetext = queryDocUuidsByKey(medicDb, 'medic-client/contacts_by_type_freetext'); + const getByExactMatchFreetext = queryDocUuidsByKey(medicDb, 'medic-client/contacts_by_freetext'); + const getByType = queryDocUuidsByKey(medicDb, 'medic-client/contacts_by_type'); + const getByTypeStartsWithFreetext = queryDocUuidsByRange(medicDb, 'medic-client/contacts_by_type_freetext'); + const getByStartsWithFreetext = queryDocUuidsByRange(medicDb, 'medic-client/contacts_by_freetext'); + + const determineGetDocsFn = ( + qualifier: ContactTypeQualifier | FreetextQualifier + ): ((limit: number, skip: number) => Promise[]>) => { + if (isContactTypeAndFreetextType(qualifier)) { + return getDocsFnForContactTypeAndFreetext(qualifier); + } + + if (isContactType(qualifier)) { + return getDocsFnForContactType(qualifier); + } + + // if the qualifier is not a ContactType then, it's a FreetextType + return getDocsFnForFreetextType(qualifier); + }; + + const getDocsFnForContactTypeAndFreetext = ( + qualifier: ContactTypeQualifier & FreetextQualifier + ): (limit: number, skip: number) => Promise[]> => { + // this is for an exact match search + if (isKeyedFreetextQualifier(qualifier)) { + return (limit, skip) => getByTypeExactMatchFreetext( + [qualifier.contactType, normalizeFreetext(qualifier.freetext)], + limit, + skip + ); + } + + // this is for a begins with search + return (limit, skip) => getByTypeStartsWithFreetext( + [qualifier.contactType, normalizeFreetext(qualifier.freetext)], + [qualifier.contactType, normalizeFreetext(qualifier.freetext) + END_OF_ALPHABET_MARKER], + limit, + skip + ); + }; + + const getDocsFnForContactType = ( + qualifier: ContactTypeQualifier + ): (limit: number, skip: number) => Promise[]> => ( + limit, + skip + ) => getByType([qualifier.contactType], limit, skip); + + const getDocsFnForFreetextType = ( + qualifier: FreetextQualifier + ): (limit: number, skip: number) => Promise[]> => { + if (isKeyedFreetextQualifier(qualifier)) { + return (limit, skip) => getByExactMatchFreetext([normalizeFreetext(qualifier.freetext)], limit, skip); + } + return (limit, skip) => getByStartsWithFreetext( + [normalizeFreetext(qualifier.freetext)], + [normalizeFreetext(qualifier.freetext) + END_OF_ALPHABET_MARKER], + limit, + skip + ); + }; + + return async ( + qualifier: ContactTypeQualifier | FreetextQualifier, + cursor: Nullable, + limit: number + ): Promise> => { + if (isContactType(qualifier)) { + const contactTypesIds = getContactTypes(settings); + if (!contactTypesIds.includes(qualifier.contactType)) { + throw new InvalidArgumentError(`Invalid contact type [${qualifier.contactType}].`); + } + } + + const skip = validateCursor(cursor); + const getDocsFn = determineGetDocsFn(qualifier); + + return await getPaginatedDocs(getDocsFn, limit, skip); + }; + }; +} diff --git a/shared-libs/cht-datasource/src/local/index.ts b/shared-libs/cht-datasource/src/local/index.ts index f6fc2ffe2f6..5b2fd8f5302 100644 --- a/shared-libs/cht-datasource/src/local/index.ts +++ b/shared-libs/cht-datasource/src/local/index.ts @@ -1,3 +1,5 @@ +export * as Contact from './contact'; export * as Person from './person'; export * as Place from './place'; +export * as Report from './report'; export { getLocalDataContext } from './libs/data-context'; diff --git a/shared-libs/cht-datasource/src/local/libs/core.ts b/shared-libs/cht-datasource/src/local/libs/core.ts new file mode 100644 index 00000000000..238b3df40aa --- /dev/null +++ b/shared-libs/cht-datasource/src/local/libs/core.ts @@ -0,0 +1,18 @@ +import { Nullable } from '../../libs/core'; +import { InvalidArgumentError } from '../../libs/error'; + +/** @internal */ +export const validateCursor = (cursor: Nullable): number => { + const skip = Number(cursor); + if (isNaN(skip) || skip < 0 || !Number.isInteger(skip)) { + throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}].`); + } + return skip; +}; + +/** @internal */ +export const normalizeFreetext = ( + freetext: string, +): string => { + return freetext.trim().toLowerCase(); +}; diff --git a/shared-libs/cht-datasource/src/local/libs/doc.ts b/shared-libs/cht-datasource/src/local/libs/doc.ts index da8daf3d884..e7b5713674d 100644 --- a/shared-libs/cht-datasource/src/local/libs/doc.ts +++ b/shared-libs/cht-datasource/src/local/libs/doc.ts @@ -41,8 +41,20 @@ export const queryDocsByRange = ( view: string ) => async ( startkey: unknown, - endkey: unknown -): Promise[]> => queryDocs(db, view, { include_docs: true, startkey, endkey}); + endkey: unknown, + limit?: number, + skip = 0 +): Promise[]> => queryDocs( + db, + view, + { + include_docs: true, + startkey, + endkey, + limit, + skip, + } +); /** @internal */ export const queryDocsByKey = ( @@ -54,6 +66,45 @@ export const queryDocsByKey = ( skip: number ): Promise[]> => queryDocs(db, view, { include_docs: true, key, limit, skip }); +const queryDocUuids = ( + db: PouchDB.Database, + view: string, + options: PouchDB.Query.Options> +) => db + .query(view, options) + .then(({ rows }) => rows.map(item => item.id ? item.id as string : null)); + +/** @internal */ +export const queryDocUuidsByRange = ( + db: PouchDB.Database, + view: string +) => async ( + startkey: unknown, + endkey: unknown, + limit?: number, + skip = 0 +): Promise[]> => queryDocUuids( + db, + view, + { + include_docs: false, + startkey, + endkey, + limit, + skip, + } +); + +/** @internal */ +export const queryDocUuidsByKey = ( + db: PouchDB.Database, + view: string +) => async ( + key: unknown, + limit: number, + skip: number +): Promise[]> => queryDocUuids(db, view, { include_docs: false, key, limit, skip }); + /** * Resolves a page containing an array of T using the getFunction to retrieve documents from the database * and the filterFunction to validate the returned documents are all of type T. @@ -102,3 +153,21 @@ export const fetchAndFilter = ( }; return recursionInner; }; + +/** @internal */ +export const getPaginatedDocs = async ( + getDocsFn: (limit: number, skip: number) => Promise[]>, + limit: number, + skip: number +): Promise> => { + // fetching 1 extra to know if we are at the end or there's more + const pagedDocs = await getDocsFn(limit + 1, skip); + + const hasMore = pagedDocs.length > limit; + const docs = hasMore ? pagedDocs.slice(0, -1) : pagedDocs; + + return { + data: docs, + cursor: hasMore ? (skip + limit).toString() : null + } as Page; +}; diff --git a/shared-libs/cht-datasource/src/local/libs/lineage.ts b/shared-libs/cht-datasource/src/local/libs/lineage.ts index 15b7c97cbaa..8ff01c7a40a 100644 --- a/shared-libs/cht-datasource/src/local/libs/lineage.ts +++ b/shared-libs/cht-datasource/src/local/libs/lineage.ts @@ -1,6 +1,9 @@ -import { Contact, NormalizedParent } from '../../libs/contact'; +import * as Contact from '../../contact'; +import * as ContactTypes from '../../contact-types'; +import * as Person from '../../person'; import { DataObject, + deepCopy, findById, getLastElement, isIdentifiable, @@ -10,7 +13,7 @@ import { Nullable } from '../../libs/core'; import { Doc } from '../../libs/doc'; -import { queryDocsByRange } from './doc'; +import { getDocsByIds, queryDocsByRange } from './doc'; import logger from '@medic/logger'; /** @@ -47,7 +50,7 @@ export const hydratePrimaryContact = (contacts: Doc[]) => (place: Nullable) }; }; -const getParentUuid = (index: number, contact?: NormalizedParent): Nullable => { +const getParentUuid = (index: number, contact?: ContactTypes.v1.NormalizedParent): Nullable => { if (!contact) { return null; } @@ -71,9 +74,9 @@ const mergeLineage = (lineage: DataObject[], parent: DataObject): DataObject => /** @internal */ export const hydrateLineage = ( - contact: Contact, + contact: Contact.v1.Contact, lineage: Nullable[] -): Contact => { +): Contact.v1.Contact => { const fullLineage = lineage .map((place, index) => { if (place) { @@ -87,5 +90,31 @@ export const hydrateLineage = ( return { _id: parentId }; }); const hierarchy: NonEmptyArray = [contact, ...fullLineage]; - return mergeLineage(hierarchy.slice(0, -1), getLastElement(hierarchy)) as Contact; + return mergeLineage(hierarchy.slice(0, -1), getLastElement(hierarchy)) as Contact.v1.Contact; +}; + +/** @internal */ +export const getContactLineage = (medicDb: PouchDB.Database) => { + const getMedicDocsById = getDocsByIds(medicDb); + + return async ( + contacts: NonEmptyArray>, + person?: Person.v1.Person, + filterSelf = false, + ): Promise> => { + const contactUuids = getPrimaryContactIds(contacts); + const uuidsToFetch = filterSelf ? contactUuids.filter(uuid => uuid !== person?._id) : contactUuids; + const fetchedContacts = await getMedicDocsById(uuidsToFetch); + const allContacts = person ? [person, ...fetchedContacts] : fetchedContacts; + const contactsWithHydratedPrimaryContact = contacts.map( + hydratePrimaryContact(allContacts) + ).filter(item => item ?? false); + const [mainContact, ...lineageContacts] = contactsWithHydratedPrimaryContact; + const contactWithLineage = hydrateLineage( + (person ?? mainContact) as ContactTypes.v1.Contact, + person ? contactsWithHydratedPrimaryContact : lineageContacts + ); + + return deepCopy(contactWithLineage); + }; }; diff --git a/shared-libs/cht-datasource/src/local/person.ts b/shared-libs/cht-datasource/src/local/person.ts index ecf4f86061f..7b894ec3ee3 100644 --- a/shared-libs/cht-datasource/src/local/person.ts +++ b/shared-libs/cht-datasource/src/local/person.ts @@ -1,13 +1,17 @@ import { Doc } from '../libs/doc'; import contactTypeUtils from '@medic/contact-types-utils'; -import { deepCopy, isNonEmptyArray, Nullable, Page } from '../libs/core'; +import { isNonEmptyArray, Nullable, Page } from '../libs/core'; import { ContactTypeQualifier, UuidQualifier } from '../qualifier'; import * as Person from '../person'; -import {fetchAndFilter, getDocById, getDocsByIds, queryDocsByKey} from './libs/doc'; +import { fetchAndFilter, getDocById, queryDocsByKey } from './libs/doc'; import { LocalDataContext, SettingsService } from './libs/data-context'; import logger from '@medic/logger'; -import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage'; -import {InvalidArgumentError} from '../libs/error'; +import { + getContactLineage, + getLineageDocsById, +} from './libs/lineage'; +import { InvalidArgumentError } from '../libs/error'; +import { validateCursor } from './libs/core'; /** @internal */ export namespace v1 { @@ -41,7 +45,7 @@ export namespace v1 { /** @internal */ export const getWithLineage = ({ medicDb, settings }: LocalDataContext) => { const getLineageDocs = getLineageDocsById(medicDb); - const getMedicDocsById = getDocsByIds(medicDb); + return async (identifier: UuidQualifier): Promise> => { const [person, ...lineagePlaces] = await getLineageDocs(identifier.uuid); if (!isPerson(settings)(person, identifier.uuid)) { @@ -53,12 +57,7 @@ export namespace v1 { return person; } - const contactUuids = getPrimaryContactIds(lineagePlaces) - .filter(uuid => uuid !== person._id); - const contacts = [person, ...await getMedicDocsById(contactUuids)]; - const linagePlacesWithContact = lineagePlaces.map(hydratePrimaryContact(contacts)); - const personWithLineage = hydrateLineage(person, linagePlacesWithContact); - return deepCopy(personWithLineage); + return await getContactLineage(medicDb)(lineagePlaces, person, true) as Nullable; }; }; @@ -78,11 +77,7 @@ export namespace v1 { throw new InvalidArgumentError(`Invalid contact type [${personType.contactType}].`); } - // Adding a number skip variable here so as not to confuse ourselves - const skip = Number(cursor); - if (isNaN(skip) || skip < 0 || !Number.isInteger(skip)) { - throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}].`); - } + const skip = validateCursor(cursor); const getDocsByPageWithPersonType = ( limit: number, diff --git a/shared-libs/cht-datasource/src/local/place.ts b/shared-libs/cht-datasource/src/local/place.ts index 0c2663a04c7..473a1e0e4aa 100644 --- a/shared-libs/cht-datasource/src/local/place.ts +++ b/shared-libs/cht-datasource/src/local/place.ts @@ -1,14 +1,17 @@ import { Doc } from '../libs/doc'; import contactTypeUtils from '@medic/contact-types-utils'; -import { deepCopy, isNonEmptyArray, NonEmptyArray, Nullable, Page } from '../libs/core'; +import { isNonEmptyArray, NonEmptyArray, Nullable, Page } from '../libs/core'; import { ContactTypeQualifier, UuidQualifier } from '../qualifier'; import * as Place from '../place'; -import {fetchAndFilter, getDocById, getDocsByIds, queryDocsByKey} from './libs/doc'; +import { fetchAndFilter, getDocById, queryDocsByKey } from './libs/doc'; import { LocalDataContext, SettingsService } from './libs/data-context'; -import { Contact } from '../libs/contact'; import logger from '@medic/logger'; -import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage'; +import { + getContactLineage, + getLineageDocsById, +} from './libs/lineage'; import { InvalidArgumentError } from '../libs/error'; +import { validateCursor } from './libs/core'; /** @internal */ export namespace v1 { @@ -40,7 +43,7 @@ export namespace v1 { /** @internal */ export const getWithLineage = ({ medicDb, settings }: LocalDataContext) => { const getLineageDocs = getLineageDocsById(medicDb); - const getMedicDocsById = getDocsByIds(medicDb); + return async (identifier: UuidQualifier): Promise> => { const [place, ...lineagePlaces] = await getLineageDocs(identifier.uuid); if (!isPlace(settings)(place, identifier.uuid)) { @@ -53,11 +56,7 @@ export namespace v1 { } const places: NonEmptyArray> = [place, ...lineagePlaces]; - const contactUuids = getPrimaryContactIds(places); - const contacts = await getMedicDocsById(contactUuids); - const [placeWithContact, ...linagePlacesWithContact] = places.map(hydratePrimaryContact(contacts)); - const placeWithLineage = hydrateLineage(placeWithContact as Contact, linagePlacesWithContact); - return deepCopy(placeWithLineage); + return await getContactLineage(medicDb)(places) as Nullable; }; }; @@ -77,11 +76,7 @@ export namespace v1 { throw new InvalidArgumentError(`Invalid contact type [${placeType.contactType}].`); } - // Adding a number skip variable here so as not to confuse ourselves - const skip = Number(cursor); - if (isNaN(skip) || skip < 0 || !Number.isInteger(skip)) { - throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}].`); - } + const skip = validateCursor(cursor); const getDocsByPageWithPlaceType = ( limit: number, diff --git a/shared-libs/cht-datasource/src/local/report.ts b/shared-libs/cht-datasource/src/local/report.ts new file mode 100644 index 00000000000..0038b1ce98a --- /dev/null +++ b/shared-libs/cht-datasource/src/local/report.ts @@ -0,0 +1,74 @@ +import { LocalDataContext } from './libs/data-context'; +import { + getDocById, getPaginatedDocs, + queryDocUuidsByKey, + queryDocUuidsByRange +} from './libs/doc'; +import { FreetextQualifier, UuidQualifier, isKeyedFreetextQualifier } from '../qualifier'; +import { Nullable, Page } from '../libs/core'; +import * as Report from '../report'; +import { Doc } from '../libs/doc'; +import logger from '@medic/logger'; +import { normalizeFreetext, validateCursor } from './libs/core'; +import { END_OF_ALPHABET_MARKER } from '../libs/constants'; + +/** @internal */ +export namespace v1 { + const isReport = (doc: Nullable, uuid?: string): doc is Report.v1.Report => { + if (!doc) { + if (uuid) { + logger.warn(`No report found for identifier [${uuid}].`); + } + return false; + } else if (doc.type !== 'data_record' || !doc.form) { + logger.warn(`Document [${doc._id}] is not a valid report.`); + return false; + } + + return true; + }; + + /** @internal */ + export const get = ({ medicDb }: LocalDataContext) => { + const getMedicDocById = getDocById(medicDb); + return async (identifier: UuidQualifier): Promise> => { + const doc = await getMedicDocById(identifier.uuid); + + if (!isReport(doc, identifier.uuid)) { + return null; + } + return doc; + }; + }; + + /** @internal */ + export const getUuidsPage = ({ medicDb }: LocalDataContext) => { + const getByExactMatchFreetext = queryDocUuidsByKey(medicDb, 'medic-client/reports_by_freetext'); + const getByStartsWithFreetext = queryDocUuidsByRange(medicDb, 'medic-client/reports_by_freetext'); + + const getDocsFnForFreetextType = ( + qualifier: FreetextQualifier + ): (limit: number, skip: number) => Promise[]> => { + if (isKeyedFreetextQualifier(qualifier)) { + return (limit, skip) => getByExactMatchFreetext([normalizeFreetext(qualifier.freetext)], limit, skip); + } + return (limit, skip) => getByStartsWithFreetext( + [normalizeFreetext(qualifier.freetext)], + [normalizeFreetext(qualifier.freetext) + END_OF_ALPHABET_MARKER], + limit, + skip + ); + }; + + return async ( + qualifier: FreetextQualifier, + cursor: Nullable, + limit: number + ): Promise> => { + const skip = validateCursor(cursor); + const getDocsFn = getDocsFnForFreetextType(qualifier); + + return await getPaginatedDocs(getDocsFn, limit, skip); + }; + }; +} diff --git a/shared-libs/cht-datasource/src/person.ts b/shared-libs/cht-datasource/src/person.ts index 8d20f85c887..f863245bb91 100644 --- a/shared-libs/cht-datasource/src/person.ts +++ b/shared-libs/cht-datasource/src/person.ts @@ -1,20 +1,22 @@ -import { ContactTypeQualifier, isUuidQualifier, UuidQualifier } from './qualifier'; +import { ContactTypeQualifier, UuidQualifier } from './qualifier'; import { adapt, assertDataContext, DataContext } from './libs/data-context'; -import { Contact, NormalizedParent } from './libs/contact'; +import * as Contact from './contact'; +import * as ContactTypes from './contact-types'; import * as Remote from './remote'; import * as Local from './local'; import * as Place from './place'; import { LocalDataContext } from './local/libs/data-context'; import { RemoteDataContext } from './remote/libs/data-context'; -import { InvalidArgumentError } from './libs/error'; -import { assertCursor, assertLimit, assertTypeQualifier, getPagedGenerator, Nullable, Page } from './libs/core'; +import { getPagedGenerator, Nullable, Page } from './libs/core'; +import { DEFAULT_DOCS_PAGE_LIMIT } from './libs/constants'; +import { assertCursor, assertLimit, assertTypeQualifier, assertUuidQualifier } from './libs/parameter-validators'; /** */ export namespace v1 { /** * Immutable data about a person contact. */ - export interface Person extends Contact { + export interface Person extends Contact.v1.Contact { readonly date_of_birth?: Date; readonly phone?: string; readonly patient_id?: string; @@ -25,23 +27,18 @@ export namespace v1 { * Immutable data about a person contact, including the full records of the parent place lineage. */ export interface PersonWithLineage extends Person { - readonly parent?: Place.v1.PlaceWithLineage | NormalizedParent, + readonly parent?: Place.v1.PlaceWithLineage | ContactTypes.v1.NormalizedParent; } - const assertPersonQualifier: (qualifier: unknown) => asserts qualifier is UuidQualifier = (qualifier: unknown) => { - if (!isUuidQualifier(qualifier)) { - throw new InvalidArgumentError(`Invalid identifier [${JSON.stringify(qualifier)}].`); - } - }; - - const getPerson = ( - localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, - remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise - ) => (context: DataContext) => { + const getPerson = + ( + localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, + remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise + ) => (context: DataContext) => { assertDataContext(context); const fn = adapt(context, localFn, remoteFn); return async (qualifier: UuidQualifier): Promise => { - assertPersonQualifier(qualifier); + assertUuidQualifier(qualifier); return fn(qualifier); }; }; @@ -69,9 +66,7 @@ export namespace v1 { * @throws Error if a data context is not provided * @see {@link getAll} which provides the same data, but without having to manually account for paging */ - export const getPage = ( - context: DataContext - ): typeof curriedFn => { + export const getPage = (context: DataContext): typeof curriedFn => { assertDataContext(context); const fn = adapt(context, Local.Person.v1.getPage, Remote.Person.v1.getPage); @@ -82,20 +77,20 @@ export namespace v1 { * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. * @param limit the maximum number of people to return. Default is 100. * @returns a page of people for the provided specification - * @throws Error if no type is provided or if the type is not for a person - * @throws Error if the provided `limit` value is `<=0` - * @throws Error if the provided cursor is not a valid page token or `null` + * @throws InvalidArgumentError if no type is provided or if the type is not for a person + * @throws InvalidArgumentError if the provided `limit` value is `<=0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` */ const curriedFn = async ( personType: ContactTypeQualifier, cursor: Nullable = null, - limit = 100, + limit: number | `${number}` = DEFAULT_DOCS_PAGE_LIMIT ): Promise> => { assertTypeQualifier(personType); assertCursor(cursor); assertLimit(limit); - return fn(personType, cursor, limit); + return fn(personType, cursor, Number(limit)); }; return curriedFn; }; @@ -106,9 +101,7 @@ export namespace v1 { * @returns a function for getting a generator that fetches people * @throws Error if a data context is not provided */ - export const getAll = ( - context: DataContext - ): typeof curriedGen => { + export const getAll = (context: DataContext): typeof curriedGen => { assertDataContext(context); const getPage = context.bind(v1.getPage); @@ -116,7 +109,7 @@ export namespace v1 { * Returns a generator for fetching all people with the given type * @param personType the type of people to return * @returns a generator for fetching all people with the given type - * @throws Error if no type is provided or if the type is not for a person + * @throws InvalidArgumentError if no type is provided or if the type is not for a person */ const curriedGen = (personType: ContactTypeQualifier): AsyncGenerator => { assertTypeQualifier(personType); diff --git a/shared-libs/cht-datasource/src/place.ts b/shared-libs/cht-datasource/src/place.ts index 84c78d98de4..0e60b2cd823 100644 --- a/shared-libs/cht-datasource/src/place.ts +++ b/shared-libs/cht-datasource/src/place.ts @@ -1,22 +1,24 @@ -import { Contact, NormalizedParent } from './libs/contact'; +import * as Contact from './contact'; +import * as ContactTypes from './contact-types'; import * as Person from './person'; -import { LocalDataContext} from './local/libs/data-context'; -import {ContactTypeQualifier, isUuidQualifier, UuidQualifier} from './qualifier'; +import { LocalDataContext } from './local/libs/data-context'; +import { ContactTypeQualifier, UuidQualifier } from './qualifier'; import { RemoteDataContext } from './remote/libs/data-context'; import { adapt, assertDataContext, DataContext } from './libs/data-context'; import * as Local from './local'; import * as Remote from './remote'; -import {assertCursor, assertLimit, assertTypeQualifier, getPagedGenerator, Nullable, Page} from './libs/core'; +import { getPagedGenerator, Nullable, Page } from './libs/core'; +import { DEFAULT_DOCS_PAGE_LIMIT } from './libs/constants'; +import { assertCursor, assertLimit, assertTypeQualifier, assertUuidQualifier } from './libs/parameter-validators'; /** */ export namespace v1 { - /** * Immutable data about a place contact. */ - export interface Place extends Contact { - readonly contact?: NormalizedParent, - readonly place_id?: string, + export interface Place extends Contact.v1.Contact { + readonly contact?: ContactTypes.v1.NormalizedParent; + readonly place_id?: string; } /** @@ -24,24 +26,19 @@ export namespace v1 { * contact for the place. */ export interface PlaceWithLineage extends Place { - readonly contact?: Person.v1.PersonWithLineage | NormalizedParent, - readonly parent?: PlaceWithLineage | NormalizedParent, + readonly contact?: Person.v1.PersonWithLineage | ContactTypes.v1.NormalizedParent; + readonly parent?: PlaceWithLineage | ContactTypes.v1.NormalizedParent; } - const assertPlaceQualifier: (qualifier: unknown) => asserts qualifier is UuidQualifier = (qualifier: unknown) => { - if (!isUuidQualifier(qualifier)) { - throw new Error(`Invalid identifier [${JSON.stringify(qualifier)}].`); - } - }; - - const getPlace = ( - localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, - remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise - ) => (context: DataContext) => { + const getPlace = + ( + localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, + remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise + ) => (context: DataContext) => { assertDataContext(context); const fn = adapt(context, localFn, remoteFn); return async (qualifier: UuidQualifier): Promise => { - assertPlaceQualifier(qualifier); + assertUuidQualifier(qualifier); return fn(qualifier); }; }; @@ -69,9 +66,7 @@ export namespace v1 { * @throws Error if a data context is not provided * @see {@link getAll} which provides the same data, but without having to manually account for paging */ - export const getPage = ( - context: DataContext - ): typeof curriedFn => { + export const getPage = (context: DataContext): typeof curriedFn => { assertDataContext(context); const fn = adapt(context, Local.Place.v1.getPage, Remote.Place.v1.getPage); @@ -82,20 +77,20 @@ export namespace v1 { * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. * @param limit the maximum number of places to return. Default is 100. * @returns a page of places for the provided specification - * @throws Error if no type is provided or if the type is not for a place - * @throws Error if the provided `limit` value is `<=0` - * @throws Error if the provided cursor is not a valid page token or `null` + * @throws InvalidArgumentError if no type is provided or if the type is not for a place + * @throws InvalidArgumentError if the provided `limit` value is `<=0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` */ const curriedFn = async ( placeType: ContactTypeQualifier, cursor: Nullable = null, - limit = 100 + limit: number | `${number}` = DEFAULT_DOCS_PAGE_LIMIT ): Promise> => { assertTypeQualifier(placeType); assertCursor(cursor); assertLimit(limit); - return fn(placeType, cursor, limit); + return fn(placeType, cursor, Number(limit)); }; return curriedFn; }; @@ -106,9 +101,7 @@ export namespace v1 { * @returns a function for getting a generator that fetches places * @throws Error if a data context is not provided */ - export const getAll = ( - context: DataContext - ): typeof curriedGen => { + export const getAll = (context: DataContext): typeof curriedGen => { assertDataContext(context); const getPage = context.bind(v1.getPage); @@ -116,7 +109,7 @@ export namespace v1 { * Returns a generator for fetching all places with the given type * @param placeType the type of places to return * @returns a generator for fetching all places with the given type - * @throws Error if no type is provided or if the type is not for a place + * @throws InvaidArgumentError if no type is provided or if the type is not for a place */ const curriedGen = (placeType: ContactTypeQualifier) => { assertTypeQualifier(placeType); diff --git a/shared-libs/cht-datasource/src/qualifier.ts b/shared-libs/cht-datasource/src/qualifier.ts index 67c6ac67b1a..7e41b96cba7 100644 --- a/shared-libs/cht-datasource/src/qualifier.ts +++ b/shared-libs/cht-datasource/src/qualifier.ts @@ -1,4 +1,4 @@ -import { isString, hasField, isRecord } from './libs/core'; +import { isString, hasField, isRecord, Nullable } from './libs/core'; import { InvalidArgumentError } from './libs/error'; /** @@ -56,3 +56,71 @@ export const byContactType = (contactType: string): ContactTypeQualifier => { export const isContactTypeQualifier = (contactType: unknown): contactType is ContactTypeQualifier => { return isRecord(contactType) && hasField(contactType, { name: 'contactType', type: 'string' }); }; + +/** + * A qualifier that identifies entities based on a freetext search string. + */ +export type FreetextQualifier = Readonly<{ freetext: string }>; + +/** + * Builds a qualifier for finding entities by the given freetext string. + * @param freetext the text to search with + * @returns the qualifier + * @throws Error if the search string is not provided or has less than 3 characters + */ +export const byFreetext = (freetext: string): FreetextQualifier => { + if (!isString(freetext) || freetext.length < 3 || freetext.includes(' ')) { + throw new InvalidArgumentError(`Invalid freetext [${JSON.stringify(freetext)}].`); + } + + return { freetext }; +}; + +/** + * Returns `true` if the given qualifier is a {@link FreetextQualifier} otherwise `false`. + * @param qualifier the qualifier to check + * @returns `true` if the given type is a {@link FreetextQualifier}, otherwise `false`. + */ +export const isFreetextQualifier = (qualifier: unknown): qualifier is FreetextQualifier => { + return isRecord(qualifier) && + hasField(qualifier, { name: 'freetext', type: 'string' }) && + typeof qualifier.freetext === 'string' && + qualifier.freetext.length >= 3; +}; + +/** + * Returns `true` if the given FreetextQualifier is also a Key-Value based qualifier in the pattern "key:value" + * @param qualifier the FreetextQualifier to check + * @returns `true` if the given FreetextQualifier is also a Key-Value based qualifier + */ +export const isKeyedFreetextQualifier = (qualifier: FreetextQualifier): boolean => { + if (isFreetextQualifier(qualifier)) { + return qualifier.freetext.includes(':'); + } + + return false; +}; + +/** + * Combines multiple qualifiers into a single object. + * @returns the combined qualifier + * @throws Error if any of the qualifiers contain intersecting property names + */ +export const and = < + A, + B, + C = Nullable, + D = Nullable +>( + qualifierA: A, + qualifierB: B, + qualifierC?: C, + qualifierD?: D + ): A & B & Partial & Partial => { + return { + ...qualifierA, + ...qualifierB, + ...(qualifierC ?? {}), + ...(qualifierD ?? {}), + }; +}; diff --git a/shared-libs/cht-datasource/src/remote/contact.ts b/shared-libs/cht-datasource/src/remote/contact.ts new file mode 100644 index 00000000000..05f350413ee --- /dev/null +++ b/shared-libs/cht-datasource/src/remote/contact.ts @@ -0,0 +1,49 @@ +import { getResource, getResources, RemoteDataContext } from './libs/data-context'; +import { ContactTypeQualifier, FreetextQualifier, UuidQualifier } from '../qualifier'; +import { Nullable, Page } from '../libs/core'; +import * as ContactType from '../contact-types'; +import { isContactType, isFreetextType } from '../libs/parameter-validators'; + +/** @internal */ +export namespace v1 { + const getContact = (remoteContext: RemoteDataContext) => getResource(remoteContext, 'api/v1/contact'); + + const getContactUuids = (remoteContext: RemoteDataContext) => getResources(remoteContext, 'api/v1/contact/uuid'); + + /** @internal */ + export const get = (remoteContext: RemoteDataContext) => ( + identifier: UuidQualifier + ): Promise> => getContact(remoteContext)(identifier.uuid); + + /** @internal */ + export const getWithLineage = ( + remoteContext: RemoteDataContext + ) => ( + identifier: UuidQualifier + ): Promise> => getContact(remoteContext)(identifier.uuid, { + with_lineage: 'true', + }); + + /** @internal */ + export const getUuidsPage = + (remoteContext: RemoteDataContext) => ( + qualifier: ContactTypeQualifier | FreetextQualifier, + cursor: Nullable, + limit: number + ): Promise> => { + const freetextParams: Record = isFreetextType(qualifier) + ? { freetext: qualifier.freetext } + : {}; + const typeParams: Record = isContactType(qualifier) + ? { type: qualifier.contactType } + : {}; + + const queryParams = { + limit: limit.toString(), + ...(cursor ? { cursor } : {}), + ...typeParams, + ...freetextParams, + }; + return getContactUuids(remoteContext)(queryParams); + }; +} diff --git a/shared-libs/cht-datasource/src/remote/index.ts b/shared-libs/cht-datasource/src/remote/index.ts index 6db881ec27e..b9fc6320d14 100644 --- a/shared-libs/cht-datasource/src/remote/index.ts +++ b/shared-libs/cht-datasource/src/remote/index.ts @@ -1,3 +1,5 @@ +export * as Contact from './contact'; export * as Person from './person'; export * as Place from './place'; +export * as Report from './report'; export { getRemoteDataContext } from './libs/data-context'; diff --git a/shared-libs/cht-datasource/src/remote/libs/data-context.ts b/shared-libs/cht-datasource/src/remote/libs/data-context.ts index 0f6df7f5870..f8c994d74c7 100644 --- a/shared-libs/cht-datasource/src/remote/libs/data-context.ts +++ b/shared-libs/cht-datasource/src/remote/libs/data-context.ts @@ -50,7 +50,8 @@ export const getResource = (context: RemoteDataContext, path: string) => async < if (response.status === 404) { return null; } else if (response.status === 400) { - throw new InvalidArgumentError(response.statusText); + const errorMessage = await response.text(); + throw new InvalidArgumentError(errorMessage); } throw new Error(response.statusText); } @@ -70,7 +71,8 @@ export const getResources = (context: RemoteDataContext, path: string) => async try { const response = await fetch(`${context.url}/${path}?${params}`); if (response.status === 400) { - throw new InvalidArgumentError(response.statusText); + const errorMessage = await response.text(); + throw new InvalidArgumentError(errorMessage); } else if (!response.ok) { throw new Error(response.statusText); } diff --git a/shared-libs/cht-datasource/src/remote/place.ts b/shared-libs/cht-datasource/src/remote/place.ts index 351ab5dfab0..710abfdfb99 100644 --- a/shared-libs/cht-datasource/src/remote/place.ts +++ b/shared-libs/cht-datasource/src/remote/place.ts @@ -1,5 +1,5 @@ import { Nullable, Page } from '../libs/core'; -import {ContactTypeQualifier, UuidQualifier} from '../qualifier'; +import { ContactTypeQualifier, UuidQualifier } from '../qualifier'; import * as Place from '../place'; import { getResource, getResources, RemoteDataContext } from './libs/data-context'; diff --git a/shared-libs/cht-datasource/src/remote/report.ts b/shared-libs/cht-datasource/src/remote/report.ts new file mode 100644 index 00000000000..8773a2301f3 --- /dev/null +++ b/shared-libs/cht-datasource/src/remote/report.ts @@ -0,0 +1,30 @@ +import { getResource, RemoteDataContext, getResources } from './libs/data-context'; +import { FreetextQualifier, UuidQualifier } from '../qualifier'; +import * as Report from '../report'; +import { Nullable, Page } from '../libs/core'; + +/** @internal */ +export namespace v1 { + const getReport = (remoteContext: RemoteDataContext) => getResource(remoteContext, 'api/v1/report'); + + const getReportUuids = (remoteContext: RemoteDataContext) => getResources(remoteContext, 'api/v1/report/uuid'); + + /** @internal */ + export const get = (remoteContext: RemoteDataContext) => ( + identifier: UuidQualifier + ): Promise> => getReport(remoteContext)(identifier.uuid); + + /** @internal */ + export const getUuidsPage = (remoteContext: RemoteDataContext) => ( + qualifier: FreetextQualifier, + cursor: Nullable, + limit: number + ): Promise> => { + const queryParams = { + limit: limit.toString(), + freetext: qualifier.freetext, + ...(cursor ? { cursor } : {}), + }; + return getReportUuids(remoteContext)(queryParams); + }; +} diff --git a/shared-libs/cht-datasource/src/report.ts b/shared-libs/cht-datasource/src/report.ts new file mode 100644 index 00000000000..5daee903ba1 --- /dev/null +++ b/shared-libs/cht-datasource/src/report.ts @@ -0,0 +1,112 @@ +import { + DataObject, + getPagedGenerator, + Nullable, + Page +} from './libs/core'; +import { adapt, assertDataContext, DataContext } from './libs/data-context'; +import { Doc } from './libs/doc'; +import * as Local from './local'; +import { FreetextQualifier, UuidQualifier } from './qualifier'; +import * as Remote from './remote'; +import { DEFAULT_IDS_PAGE_LIMIT } from './libs/constants'; +import { assertCursor, assertFreetextQualifier, assertLimit, assertUuidQualifier } from './libs/parameter-validators'; + +/** */ +export namespace v1 { + /** + * A report document. + */ + export interface Report extends Doc { + readonly form: string; + readonly reported_date: Date; + readonly fields: DataObject; + } + + /** + * Returns a function for retrieving a report from the given data context. + * @param context the current data context + * @returns a function for retrieving a report + * @throws Error if a data context is not provided + */ + export const get = (context: DataContext): typeof curriedFn => { + assertDataContext(context); + const fn = adapt(context, Local.Report.v1.get, Remote.Report.v1.get); + + /** + * Returns a report for the given qualifier. + * @param qualifier identifier for the report to retrieve + * @returns the report or `null` if no report is found for the qualifier + * @throws Error if the qualifier is invalid + */ + const curriedFn = async ( + qualifier: UuidQualifier + ): Promise> => { + assertUuidQualifier(qualifier); + return fn(qualifier); + }; + return curriedFn; + }; + + /** + * Returns a function for retrieving a paged array of report identifiers from the given data context. + * @param context the current data context + * @returns a function for retrieving a paged array of report identifiers + * @throws Error if a data context is not provided + * @see {@link getUuids} which provides the same data, but without having to manually account for paging + */ + export const getUuidsPage = (context: DataContext): typeof curriedFn => { + assertDataContext(context); + const fn = adapt(context, Local.Report.v1.getUuidsPage, Remote.Report.v1.getUuidsPage); + + /** + * Returns an array of report identifiers for the provided page specifications. + * @param qualifier the limiter defining which identifiers to return + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of identifiers to return. Default is 10000. + * @returns a page of report identifiers for the provided specification + * @throws InvalidArgumentError if no qualifier is provided or if the qualifier is invalid + * @throws InvalidArgumentError if the provided `limit` value is `<=0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + const curriedFn = async ( + qualifier: FreetextQualifier, + cursor: Nullable = null, + limit: number | `${number}` = DEFAULT_IDS_PAGE_LIMIT + ): Promise> => { + assertFreetextQualifier(qualifier); + assertCursor(cursor); + assertLimit(limit); + + return fn(qualifier, cursor, Number(limit)); + }; + return curriedFn; + }; + + /** + * Returns a function for getting a generator that fetches report identifiers from the given data context. + * @param context the current data context + * @returns a function for getting a generator that fetches report identifiers + * @throws Error if a data context is not provided + */ + export const getUuids = (context: DataContext): typeof curriedGen => { + assertDataContext(context); + const getPage = context.bind(v1.getUuidsPage); + + /** + * Returns a generator for fetching all report identifiers that match the given qualifier + * @param qualifier the limiter defining which identifiers to return + * @returns a generator for fetching all report identifiers that match the given qualifier + * @throws InvalidArgumentError if no qualifier is provided or if the qualifier is invalid + */ + const curriedGen = ( + qualifier: FreetextQualifier + ): AsyncGenerator => { + assertFreetextQualifier(qualifier); + + return getPagedGenerator(getPage, qualifier); + }; + return curriedGen; + }; +} diff --git a/shared-libs/cht-datasource/test/contact.spec.ts b/shared-libs/cht-datasource/test/contact.spec.ts new file mode 100644 index 00000000000..72fe8dee3f6 --- /dev/null +++ b/shared-libs/cht-datasource/test/contact.spec.ts @@ -0,0 +1,432 @@ +import { DataContext } from '../src'; +import sinon, { SinonStub } from 'sinon'; +import * as Context from '../src/libs/data-context'; +import * as Local from '../src/local'; +import * as Remote from '../src/remote'; +import * as Qualifier from '../src/qualifier'; +import * as Contact from '../src/contact'; +import * as ContactType from '../src/contact-types'; +import { expect } from 'chai'; +import * as Core from '../src/libs/core'; + +describe('contact', () => { + const dataContext = { } as DataContext; + let assertDataContext: SinonStub; + let adapt: SinonStub; + let isUuidQualifier: SinonStub; + let isContactTypeQualifier: SinonStub; + let isFreetextQualifier: SinonStub; + + beforeEach(() => { + assertDataContext = sinon.stub(Context, 'assertDataContext'); + adapt = sinon.stub(Context, 'adapt'); + isUuidQualifier = sinon.stub(Qualifier, 'isUuidQualifier'); + isContactTypeQualifier = sinon.stub(Qualifier, 'isContactTypeQualifier'); + isFreetextQualifier = sinon.stub(Qualifier, 'isFreetextQualifier'); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('isNormalizedParent', () => { + let isDataObject: SinonStub; + + beforeEach(() => isDataObject = sinon.stub(Core, 'isDataObject')); + afterEach(() => sinon.restore()); + + ([ + [{ _id: 'my-id' }, true, true], + [{ _id: 'my-id' }, false, false], + [{ hello: 'my-id' }, true, false], + [{ _id: 1 }, true, false], + [{ _id: 'my-id', parent: 'hello' }, true, false], + [{ _id: 'my-id', parent: null }, true, true], + [{ _id: 'my-id', parent: { hello: 'world' } }, true, false], + [{ _id: 'my-id', parent: { _id: 'parent-id' } }, true, true], + [{ _id: 'my-id', parent: { _id: 'parent-id', parent: { hello: 'world' } } }, true, false], + [{ _id: 'my-id', parent: { _id: 'parent-id', parent: { _id: 'grandparent-id' } } }, true, true], + ] as [unknown, boolean, boolean][]).forEach(([value, dataObj, expected]) => { + it(`evaluates ${JSON.stringify(value)}`, () => { + isDataObject.returns(dataObj); + expect(ContactType.v1.isNormalizedParent(value)).to.equal(expected); + }); + }); + }); + + describe('get', () => { + const contact = { _id: 'my-contact' } as Contact.v1.Contact; + const qualifier = { uuid: contact._id } as const; + let getContact: SinonStub; + + beforeEach(() => { + getContact = sinon.stub(); + adapt.returns(getContact); + }); + + it('retrieves the contact for the given qualifier from the data context', async () => { + isUuidQualifier.returns(true); + getContact.returns(contact); + + const result = await Contact.v1.get(dataContext)(qualifier); + + expect(result).to.equal(contact); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.get, Remote.Contact.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getContact.calledOnceWithExactly(qualifier)).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isUuidQualifier.returns(false); + + await expect(Contact.v1.get(dataContext)(qualifier)) + .to.be.rejectedWith(`Invalid identifier [${JSON.stringify(qualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.get, Remote.Contact.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getContact.notCalled).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Contact.v1.get(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(isUuidQualifier.notCalled).to.be.true; + expect(getContact.notCalled).to.be.true; + }); + }); + + describe('getWithLineage', () => { + const contact = { _id: 'my-contact' } as Contact.v1.Contact; + const qualifier = { uuid: contact._id } as const; + let getContactWithLineage: SinonStub; + + beforeEach(() => { + getContactWithLineage = sinon.stub(); + adapt.returns(getContactWithLineage); + }); + + it('retrieves the contact with lineage for the given qualifier from the data context', async () => { + isUuidQualifier.returns(true); + getContactWithLineage.resolves(contact); + + const result = await Contact.v1.getWithLineage(dataContext)(qualifier); + + expect(result).to.equal(contact); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly( + dataContext, + Local.Contact.v1.getWithLineage, + Remote.Contact.v1.getWithLineage + )).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getContactWithLineage.calledOnceWithExactly(qualifier)).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isUuidQualifier.returns(false); + + await expect(Contact.v1.getWithLineage(dataContext)(qualifier)) + .to.be.rejectedWith(`Invalid identifier [${JSON.stringify(qualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly( + dataContext, + Local.Contact.v1.getWithLineage, + Remote.Contact.v1.getWithLineage + )).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getContactWithLineage.notCalled).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Contact.v1.getWithLineage(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(isUuidQualifier.notCalled).to.be.true; + expect(getContactWithLineage.notCalled).to.be.true; + }); + }); + + describe('getUuidsPage', () => { + const contactIds = ['contact1', 'contact2', 'contact3'] as string[]; + const cursor = '1'; + const pageData = { data: contactIds, cursor }; + const limit = 3; + const stringifiedLimit = '3'; + const contactTypeQualifier = { contactType: 'person' } as const; + const freetextQualifier = { freetext: 'freetext'} as const; + const qualifier = { + ...contactTypeQualifier, + ...freetextQualifier, + }; + const invalidContactTypeQualifier = { contactType: 'invalidContactType' } as const; + const invalidFreetextQualifier = { freetext: 'invalidFreetext' } as const; + const invalidQualifier = { + ...invalidContactTypeQualifier, + ...invalidFreetextQualifier, + }; + let getIdsPage: SinonStub; + + beforeEach(() => { + getIdsPage = sinon.stub(); + adapt.returns(getIdsPage); + }); + + it('retrieves contact id page from the data context when cursor is null', async () => { + isContactTypeQualifier.returns(true); + isFreetextQualifier.returns(true); + getIdsPage.resolves(pageData); + + const result = await Contact.v1.getUuidsPage(dataContext)(qualifier, null, limit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) + ).to.be.true; + expect(getIdsPage.calledOnceWithExactly(qualifier, null, limit)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + }); + + it('retrieves contact id page from the data context when cursor is not null', async () => { + isContactTypeQualifier.returns(true); + isFreetextQualifier.returns(true); + getIdsPage.resolves(pageData); + + const result = await Contact.v1.getUuidsPage(dataContext)(qualifier, cursor, limit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) + ).to.be.true; + expect(getIdsPage.calledOnceWithExactly(qualifier, cursor, limit)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + }); + + it('retrieves contact id page from the data context when cursor is not null and ' + + 'limit is stringified number', async () => { + isContactTypeQualifier.returns(true); + isFreetextQualifier.returns(true); + getIdsPage.resolves(pageData); + + const result = await Contact.v1.getUuidsPage(dataContext)(qualifier, cursor, stringifiedLimit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) + ).to.be.true; + expect(getIdsPage.calledOnceWithExactly(qualifier, cursor, limit)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + isContactTypeQualifier.returns(true); + isFreetextQualifier.returns(true); + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Contact.v1.getUuidsPage(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + expect(isContactTypeQualifier.notCalled).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + }); + + it('throws an error if the contact type qualifier is invalid', async () => { + isContactTypeQualifier.returns(false); + + await expect(Contact.v1.getUuidsPage(dataContext)(invalidContactTypeQualifier, cursor, limit)) + .to.be.rejectedWith(`Invalid contact type [${JSON.stringify(invalidContactTypeQualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) + ).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(invalidContactTypeQualifier)).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + }); + + it('throws an error if the freetext type qualifier is invalid', async () => { + isFreetextQualifier.returns(false); + + await expect(Contact.v1.getUuidsPage(dataContext)(invalidFreetextQualifier, cursor, limit)) + .to.be.rejectedWith(`Invalid freetext [${JSON.stringify(invalidFreetextQualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) + ).to.be.true; + expect(isContactTypeQualifier.notCalled).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(invalidFreetextQualifier)).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + }); + + it('throws an error if the qualifier is invalid for both contact type and freetext', async () => { + isContactTypeQualifier.returns(false); + isFreetextQualifier.returns(false); + + await expect(Contact.v1.getUuidsPage(dataContext)(invalidQualifier, cursor, limit)).to.be.rejectedWith( + `Invalid qualifier [${JSON.stringify(invalidQualifier)}]. Must be a contact type and/or freetext qualifier.` + ); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) + ).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(invalidQualifier)).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(invalidQualifier)).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + }); + + [ + -1, + null, + {}, + '', + 0, + 1.1, + false + ].forEach((limitValue) => { + it(`throws an error if limit is invalid: ${String(limitValue)}`, async () => { + isContactTypeQualifier.returns(true); + getIdsPage.resolves(pageData); + + await expect(Contact.v1.getUuidsPage(dataContext)(qualifier, cursor, limitValue as number)) + .to.be.rejectedWith(`The limit must be a positive number: [${String(limitValue)}]`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) + ).to.be.true; + expect(isContactTypeQualifier.notCalled).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + }); + }); + + [ + {}, + '', + 1, + false, + ].forEach((skipValue) => { + it('throws an error if cursor is invalid', async () => { + isContactTypeQualifier.returns(true); + getIdsPage.resolves(pageData); + + await expect(Contact.v1.getUuidsPage(dataContext)(qualifier, skipValue as string, limit)) + .to.be.rejectedWith(`Invalid cursor token: [${String(skipValue)}]`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) + ).to.be.true; + expect(isContactTypeQualifier.notCalled).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + }); + }); + }); + + describe('getUuids', () => { + const contactTypeQualifier = { contactType: 'person' } as const; + const freetextQualifier = { freetext: 'freetext'} as const; + const qualifier = { + ...contactTypeQualifier, + ...freetextQualifier, + }; + const invalidContactTypeQualifier = { contactType: 'invalidContactType' } as const; + const invalidFreetextQualifier = { freetext: 'invalidFreetext' } as const; + const invalidQualifier = { + ...invalidContactTypeQualifier, + ...invalidFreetextQualifier + }; + const mockGenerator = {} as Generator; + let contactGetIdsPage: sinon.SinonStub; + let getPagedGenerator: sinon.SinonStub; + + beforeEach(() => { + contactGetIdsPage = sinon.stub(Contact.v1, 'getUuidsPage'); + dataContext.bind = sinon.stub().returns(contactGetIdsPage); + getPagedGenerator = sinon.stub(Core, 'getPagedGenerator'); + }); + + it('should get contact generator with correct parameters', () => { + isContactTypeQualifier.returns(true); + isFreetextQualifier.returns(true); + getPagedGenerator.returns(mockGenerator); + + const generator = Contact.v1.getUuids(dataContext)(qualifier); + + expect(generator).to.deep.equal(mockGenerator); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(getPagedGenerator.calledOnceWithExactly(contactGetIdsPage, qualifier)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + }); + + it('should throw an error for invalid datacontext', () => { + const errMsg = 'Invalid data context [null].'; + isContactTypeQualifier.returns(true); + isFreetextQualifier.returns(true); + assertDataContext.throws(new Error(errMsg)); + + expect(() => Contact.v1.getUuids(dataContext)).to.throw(errMsg); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(contactGetIdsPage.notCalled).to.be.true; + expect(isContactTypeQualifier.notCalled).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + }); + + it('should throw an error for invalid contactType', () => { + isContactTypeQualifier.returns(false); + + expect(() => Contact.v1.getUuids(dataContext)(invalidContactTypeQualifier)) + .to.throw(`Invalid contact type [${JSON.stringify(invalidContactTypeQualifier)}]`); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(contactGetIdsPage.notCalled).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(invalidContactTypeQualifier)).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + }); + + it('should throw an error for invalid freetext', () => { + isFreetextQualifier.returns(false); + + expect(() => Contact.v1.getUuids(dataContext)(invalidFreetextQualifier)) + .to.throw(`Invalid freetext [${JSON.stringify(invalidFreetextQualifier)}]`); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(contactGetIdsPage.notCalled).to.be.true; + expect(isContactTypeQualifier.notCalled).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(invalidFreetextQualifier)).to.be.true; + }); + + it('should throw an error for both invalid contactType and freetext', () => { + isContactTypeQualifier.returns(false); + isFreetextQualifier.returns(false); + + expect(() => Contact.v1.getUuids(dataContext)(invalidQualifier)).to.throw( + `Invalid qualifier [${JSON.stringify(invalidQualifier)}]. Must be a contact type and/or freetext qualifier.` + ); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(contactGetIdsPage.notCalled).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(invalidQualifier)).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(invalidQualifier)).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/index.spec.ts b/shared-libs/cht-datasource/test/index.spec.ts index 19b47551dc2..ab628b84c6a 100644 --- a/shared-libs/cht-datasource/test/index.spec.ts +++ b/shared-libs/cht-datasource/test/index.spec.ts @@ -1,9 +1,11 @@ import { expect } from 'chai'; import * as Index from '../src'; import { hasAnyPermission, hasPermissions } from '../src/auth'; +import * as Contact from '../src/contact'; import * as Person from '../src/person'; import * as Place from '../src/place'; import * as Qualifier from '../src/qualifier'; +import * as Report from '../src/report'; import sinon, { SinonStub } from 'sinon'; import * as Context from '../src/libs/data-context'; import { DataContext } from '../src'; @@ -39,7 +41,7 @@ describe('CHT Script API - getDatasource', () => { beforeEach(() => v1 = datasource.v1); it('contains expected keys', () => expect(v1).to.have.all.keys([ - 'hasPermissions', 'hasAnyPermission', 'person', 'place' + 'contact', 'hasPermissions', 'hasAnyPermission', 'person', 'place', 'report' ])); it('permission', () => { @@ -202,5 +204,157 @@ describe('CHT Script API - getDatasource', () => { expect(byContactType.calledOnceWithExactly(personType)).to.be.true; }); }); + + describe('contact', () => { + let contact: typeof v1.contact; + + beforeEach(() => contact = v1.contact); + + it('contains expected keys', () => { + expect(contact).to.have.all.keys( + ['getUuids', 'getUuidsPageByTypeFreetext', 'getByUuid', 'getByUuidWithLineage'] + ); + }); + + it('getByUuid', async () => { + const expectedContact = {}; + const contactGet = sinon.stub().resolves(expectedContact); + dataContextBind.returns(contactGet); + const qualifier = { uuid: 'my-contact-uuid' }; + const byUuid = sinon.stub(Qualifier, 'byUuid').returns(qualifier); + + const returnedContact = await contact.getByUuid(qualifier.uuid); + + expect(returnedContact).to.equal(expectedContact); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.get)).to.be.true; + expect(contactGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; + }); + + it('getByUuidWithLineage', async () => { + const expectedContact = {}; + const contactGet = sinon.stub().resolves(expectedContact); + dataContextBind.returns(contactGet); + const qualifier = { uuid: 'my-contact-uuid' }; + const byUuid = sinon.stub(Qualifier, 'byUuid').returns(qualifier); + + const returnedContact = await contact.getByUuidWithLineage(qualifier.uuid); + + expect(returnedContact).to.equal(expectedContact); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + expect(contactGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; + }); + + it('getUuidsPageByTypeFreetext', async () => { + const expectedContactIds: Page = {data: [], cursor: null}; + const contactGetIdsPage = sinon.stub().resolves(expectedContactIds); + dataContextBind.returns(contactGetIdsPage); + const freetext = 'abc'; + const contactType = 'person'; + const limit = 2; + const cursor = '1'; + const contactTypeQualifier = { contactType }; + const freetextQualifier = {freetext }; + const qualifier = { contactType, freetext }; + const andQualifier = sinon.stub(Qualifier, 'and').returns(qualifier); + + const returnedContactIds = await contact.getUuidsPageByTypeFreetext(freetext, contactType, cursor, limit); + + expect(returnedContactIds).to.equal(expectedContactIds); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect( + contactGetIdsPage.calledOnceWithExactly(qualifier, cursor, limit) + ).to.be.true; + expect(andQualifier.calledOnceWithExactly(freetextQualifier, contactTypeQualifier)).to.be.true; + }); + + it('getUuids', () => { + const mockAsyncGenerator = async function* () { + await Promise.resolve(); + yield []; + }; + + const contactGetIds = sinon.stub().returns(mockAsyncGenerator); + dataContextBind.returns(contactGetIds); + const freetext = 'abc'; + const contactType = 'person'; + const contactTypeQualifier = { contactType }; + const freetextQualifier = {freetext }; + const qualifier = { contactType, freetext }; + const andQualifier = sinon.stub(Qualifier, 'and').returns(qualifier); + const res = contact.getUuids(freetext, contactType); + + expect(res).to.deep.equal(mockAsyncGenerator); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuids)).to.be.true; + expect(contactGetIds.calledOnceWithExactly(qualifier)).to.be.true; + expect(andQualifier.calledOnceWithExactly(freetextQualifier, contactTypeQualifier)).to.be.true; + }); + }); + + describe('report', () => { + let report: typeof v1.report; + + beforeEach(() => report = v1.report); + + it('contains expected keys', () => { + expect(report).to.have.all.keys(['getUuids', 'getUuidsPage', 'getByUuid']); + }); + + it('getByUuid', async () => { + const expectedReport = {}; + const reportGet = sinon.stub().resolves(expectedReport); + dataContextBind.returns(reportGet); + const qualifier = { uuid: 'my-report-uuid' }; + const byUuid = sinon.stub(Qualifier, 'byUuid').returns(qualifier); + + const returnedReport = await report.getByUuid(qualifier.uuid); + + expect(returnedReport).to.equal(expectedReport); + expect(dataContextBind.calledOnceWithExactly(Report.v1.get)).to.be.true; + expect(reportGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; + }); + + it('getUuidsPage', async () => { + const expectedReportIds: Page = {data: [], cursor: null}; + const reportGetIdsPage = sinon.stub().resolves(expectedReportIds); + dataContextBind.returns(reportGetIdsPage); + const freetext = 'abc'; + const limit = 2; + const cursor = '1'; + const qualifier = { freetext }; + const byFreetext = sinon.stub(Qualifier, 'byFreetext').returns(qualifier); + + const returnedContactIds = await report.getUuidsPage(freetext, cursor, limit); + + expect(returnedContactIds).to.equal(expectedReportIds); + expect(dataContextBind.calledOnceWithExactly(Report.v1.getUuidsPage)).to.be.true; + expect( + reportGetIdsPage.calledOnceWithExactly(qualifier, cursor, limit) + ).to.be.true; + expect(byFreetext.calledOnceWithExactly(freetext)).to.be.true; + }); + + it('getUuids', () => { + const mockAsyncGenerator = async function* () { + await Promise.resolve(); + yield []; + }; + + const contactGetIds = sinon.stub().returns(mockAsyncGenerator); + dataContextBind.returns(contactGetIds); + const freetext = 'abc'; + const qualifier = { freetext }; + const byFreetext = sinon.stub(Qualifier, 'byFreetext').returns(qualifier); + + const res = report.getUuids(freetext); + + expect(res).to.deep.equal(mockAsyncGenerator); + expect(dataContextBind.calledOnceWithExactly(Report.v1.getUuids)).to.be.true; + expect(contactGetIds.calledOnceWithExactly(qualifier)).to.be.true; + expect(byFreetext.calledOnceWithExactly(freetext)).to.be.true; + }); + }); }); }); diff --git a/shared-libs/cht-datasource/test/libs/contact.spec.ts b/shared-libs/cht-datasource/test/libs/contact.spec.ts deleted file mode 100644 index dadb7d71ca0..00000000000 --- a/shared-libs/cht-datasource/test/libs/contact.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect } from 'chai'; -import { isNormalizedParent } from '../../src/libs/contact'; -import sinon, { SinonStub } from 'sinon'; -import * as Core from '../../src/libs/core'; - -describe('contact lib', () => { - describe('isNormalizedParent', () => { - let isDataObject: SinonStub; - - beforeEach(() => isDataObject = sinon.stub(Core, 'isDataObject')); - afterEach(() => sinon.restore()); - - ([ - [{ _id: 'my-id' }, true, true], - [{ _id: 'my-id' }, false, false], - [{ hello: 'my-id' }, true, false], - [{ _id: 1 }, true, false], - [{ _id: 'my-id', parent: 'hello' }, true, false], - [{ _id: 'my-id', parent: null }, true, true], - [{ _id: 'my-id', parent: { hello: 'world' } }, true, false], - [{ _id: 'my-id', parent: { _id: 'parent-id' } }, true, true], - [{ _id: 'my-id', parent: { _id: 'parent-id', parent: { hello: 'world' } } }, true, false], - [{ _id: 'my-id', parent: { _id: 'parent-id', parent: { _id: 'grandparent-id' } } }, true, true], - ] as [unknown, boolean, boolean][]).forEach(([value, dataObj, expected]) => { - it(`evaluates ${JSON.stringify(value)}`, () => { - isDataObject.returns(dataObj); - expect(isNormalizedParent(value)).to.equal(expected); - }); - }); - }); -}); diff --git a/shared-libs/cht-datasource/test/libs/core.spec.ts b/shared-libs/cht-datasource/test/libs/core.spec.ts index 94d60c946f3..b9656845142 100644 --- a/shared-libs/cht-datasource/test/libs/core.spec.ts +++ b/shared-libs/cht-datasource/test/libs/core.spec.ts @@ -1,8 +1,14 @@ import { expect } from 'chai'; import { - AbstractDataContext, deepCopy, findById, getLastElement, getPagedGenerator, + AbstractDataContext, + deepCopy, + findById, + getLastElement, + getPagedGenerator, hasField, - hasFields, isDataObject, isIdentifiable, + hasFields, + isDataObject, + isIdentifiable, isNonEmptyArray, isRecord, isString, diff --git a/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts b/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts new file mode 100644 index 00000000000..01c9d1e1b7f --- /dev/null +++ b/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts @@ -0,0 +1,199 @@ +import { ContactTypeQualifier, FreetextQualifier } from '../../src/qualifier'; +import { expect } from 'chai'; +import { + assertCursor, + assertFreetextQualifier, + assertLimit, + assertTypeQualifier, + assertContactTypeFreetextQualifier, assertUuidQualifier +} from '../../src/libs/parameter-validators'; +import { InvalidArgumentError } from '../../src'; + +describe('libs parameter-validators', () => { + describe('assertTypeQualifier', () => { + it('should not throw for valid contact type qualifier', () => { + const validQualifier: ContactTypeQualifier = { contactType: 'person' }; + expect(() => assertTypeQualifier(validQualifier)).to.not.throw(); + }); + + [ + null, + undefined, + '', + 123, + {}, + { wrongProp: 'value' } + ].forEach((typeValue) => { + it(`should throw for invalid contact type qualifier: ${typeValue as string}`, () => { + expect(() => assertTypeQualifier(typeValue)) + .to.throw(InvalidArgumentError) + .with.property('message') + .that.includes('Invalid contact type'); + }); + }); + }); + + describe('assertLimit', () => { + it('should not throw for valid number limits', () => { + const validLimits = [1, 10, '1', '10']; + validLimits.forEach(limit => { + expect(() => assertLimit(limit)).to.not.throw(); + }); + }); + + [ + 0, + -1, + '0', + '-1', + 'abc', + null, + undefined, + {}, + [], + 1.5, + '1.5' + ].forEach(limit => { + it(`should throw for invalid limits: ${JSON.stringify(limit)}`, () => { + expect(() => assertLimit(limit)) + .to.throw(InvalidArgumentError) + .with.property('message') + .that.includes('The limit must be a positive number'); + }); + }); + }); + + describe('assertCursor', () => { + it('should not throw for valid cursors', () => { + const validCursors = ['valid-cursor', 'abc123', null]; + validCursors.forEach(cursor => { + expect(() => assertCursor(cursor)).to.not.throw(); + }); + }); + + ['', undefined, {}, [], 123].forEach(cursor => { + it(`should throw for invalid cursors: ${JSON.stringify(cursor)}`, () => { + expect(() => assertCursor(cursor)) + .to.throw(InvalidArgumentError) + .with.property('message') + .that.includes('Invalid cursor token'); + }); + }); + }); + + describe('assertFreetextQualifier', () => { + it('should not throw for valid freetext qualifier', () => { + const validQualifier: FreetextQualifier = { freetext: 'search text' }; + expect(() => assertFreetextQualifier(validQualifier)).to.not.throw(); + }); + + [ + null, + undefined, + '', + 123, + {}, + { wrongProp: 'value' } + ].forEach(freetext => { + it(`should throw for invalid freetext qualifier: ${JSON.stringify(freetext)}`, () => { + expect(() => assertFreetextQualifier(freetext)) + .to.throw(InvalidArgumentError) + .with.property('message') + .that.includes('Invalid freetext'); + }); + }); + }); + + describe('assertContactTypeFreetextQualifier', () => { + it('should pass when given a valid contact type qualifier', () => { + const validContactType = { contactType: 'email' }; + + expect(() => assertContactTypeFreetextQualifier(validContactType)).to.not.throw(); + }); + + it('should pass when given a valid freetext qualifier', () => { + const validFreetext = { freetext: 'some text' }; + + expect(() => assertContactTypeFreetextQualifier(validFreetext)).to.not.throw(); + }); + + it('should throw InvalidArgumentError when given an invalid qualifier', () => { + const invalidQualifier = { invalid: 'data' }; + + expect(() => assertContactTypeFreetextQualifier(invalidQualifier)).to.throw(InvalidArgumentError); + }); + + it('should throw InvalidArgumentError with correct message for invalid qualifier', () => { + const invalidQualifier = { invalid: 'data' }; + + expect(() => assertContactTypeFreetextQualifier(invalidQualifier)).to.throw( + InvalidArgumentError, + 'Invalid qualifier [{"invalid":"data"}]. Must be a contact type and/or freetext qualifier.' + ); + }); + + it('should throw InvalidArgumentError when freetext is too short', () => { + const shortFreetext = { freetext: 'ab' }; // Less than 3 characters + + expect(() => assertContactTypeFreetextQualifier(shortFreetext)).to.throw(InvalidArgumentError); + }); + + it('should pass when object satisfies both qualifier types', () => { + const validBothTypes = { + contactType: 'email', + freetext: 'some text' + }; + + expect(() => assertContactTypeFreetextQualifier(validBothTypes)).to.not.throw(); + }); + + it('should handle null input appropriately', () => { + expect(() => assertContactTypeFreetextQualifier(null)).to.throw(InvalidArgumentError); + }); + + it('should handle undefined input appropriately', () => { + expect(() => assertContactTypeFreetextQualifier(undefined)).to.throw(InvalidArgumentError); + }); + }); + + describe('assertUuidQualifier', () => { + it('should pass when given a valid UUID qualifier', () => { + const validUuid = { uuid: '123e4567-e89b-12d3-a456-426614174000' }; + + expect(() => assertUuidQualifier(validUuid)).to.not.throw(); + }); + + it('should throw InvalidArgumentError when given an invalid object', () => { + const invalidObject = { somethingElse: '123e4567-e89b-12d3-a456-426614174000' }; + + expect(() => assertUuidQualifier(invalidObject)).to.throw(InvalidArgumentError); + }); + + it('should throw InvalidArgumentError with correct message for invalid qualifier', () => { + const invalidObject = { somethingElse: '123e4567-e89b-12d3-a456-426614174000' }; + + expect(() => assertUuidQualifier(invalidObject)).to.throw( + InvalidArgumentError, + `Invalid identifier [{"somethingElse":"123e4567-e89b-12d3-a456-426614174000"}].` + ); + }); + + it('should handle null input appropriately', () => { + expect(() => assertUuidQualifier(null)).to.throw(InvalidArgumentError); + }); + + it('should handle undefined input appropriately', () => { + expect(() => assertUuidQualifier(undefined)).to.throw(InvalidArgumentError); + }); + + it('should throw InvalidArgumentError when given an empty object', () => { + expect(() => assertUuidQualifier({})).to.throw(InvalidArgumentError); + }); + + it('should throw InvalidArgumentError when uuid property is not a string', () => { + const invalidType = { uuid: 123 }; + + expect(() => assertUuidQualifier(invalidType)).to.throw(InvalidArgumentError); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/contact.spec.ts b/shared-libs/cht-datasource/test/local/contact.spec.ts new file mode 100644 index 00000000000..63b137ac090 --- /dev/null +++ b/shared-libs/cht-datasource/test/local/contact.spec.ts @@ -0,0 +1,970 @@ +import { LocalDataContext } from '../../src/local/libs/data-context'; +import sinon, { SinonStub } from 'sinon'; +import { Doc } from '../../src/libs/doc'; +import logger from '@medic/logger'; +import contactTypeUtils from '@medic/contact-types-utils'; +import * as LocalDoc from '../../src/local/libs/doc'; +import * as Contact from '../../src/local/contact'; +import { expect } from 'chai'; +import * as Lineage from '../../src/local/libs/lineage'; +import { END_OF_ALPHABET_MARKER } from '../../src/libs/constants'; + +describe('local contact', () => { + let localContext: LocalDataContext; + let settingsGetAll: SinonStub; + let warn: SinonStub; + let debug: SinonStub; + let getContactTypes: SinonStub; + let isContact: SinonStub; + + beforeEach(() => { + settingsGetAll = sinon.stub(); + localContext = { + medicDb: {} as PouchDB.Database, + settings: { getAll: settingsGetAll } + } as unknown as LocalDataContext; + warn = sinon.stub(logger, 'warn'); + debug = sinon.stub(logger, 'debug'); + getContactTypes = sinon.stub(contactTypeUtils, 'getContactTypes'); + isContact = sinon.stub(contactTypeUtils, 'isContact'); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + const settings = { hello: 'world' } as const; + + describe('get', () => { + const identifier = { uuid: 'uuid' } as const; + let getDocByIdOuter: SinonStub; + let getDocByIdInner: SinonStub; + + beforeEach(() => { + getDocByIdInner = sinon.stub(); + getDocByIdOuter = sinon.stub(LocalDoc, 'getDocById').returns(getDocByIdInner); + }); + + it('returns a contact by UUID', async () => { + const doc = { type: 'person' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + isContact.returns(true); + + const result = await Contact.v1.get(localContext)(identifier); + + expect(result).to.equal(doc); + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isContact.calledOnceWithExactly(settingsGetAll(), doc)).to.be.true; + expect(warn.notCalled).to.be.true; + }); + + it('returns null if the identified doc does not have a contact type', async () => { + const doc = { type: 'not-contact', _id: '_id' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + isContact.returns(false); + + const result = await Contact.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isContact.calledOnceWithExactly(settingsGetAll(), doc)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${doc._id}] is not a valid contact.`)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getDocByIdInner.resolves(null); + + const result = await Contact.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(settingsGetAll.notCalled).to.be.true; + expect(isContact.notCalled).to.be.true; + expect(warn.calledOnceWithExactly(`No contact found for identifier [${identifier.uuid}].`)).to.be.true; + }); + }); + + describe('getWithLineage', () => { + const identifier = { uuid: 'uuid' } as const; + let getLineageDocsByIdInner: SinonStub; + let getLineageDocsByIdOuter: SinonStub; + let getContactLineageInner: SinonStub; + let getContactLineageOuter: SinonStub; + let isPerson: SinonStub; + + beforeEach(() => { + getLineageDocsByIdInner = sinon.stub(); + getLineageDocsByIdOuter = sinon + .stub(Lineage, 'getLineageDocsById') + .returns(getLineageDocsByIdInner); + getContactLineageInner = sinon.stub(); + getContactLineageOuter = sinon + .stub(Lineage, 'getContactLineage') + .returns(getContactLineageInner); + }); + + it('returns a contact with lineage for person type contact', async () => { + const person = { type: 'person', _id: 'uuid', _rev: 'rev' }; + const place0 = { _id: 'place0', _rev: 'rev' }; + const place1 = { _id: 'place1', _rev: 'rev' }; + const place2 = { _id: 'place2', _rev: 'rev' }; + const lineageDocs = [place0, place1, place2]; + getLineageDocsByIdInner.resolves([person, ...lineageDocs]); + isContact.returns(true); + settingsGetAll.returns(settings); + const personWithLineage = { ...person, lineage: true }; + const copiedPerson = { ...personWithLineage }; + getContactLineageInner.returns(copiedPerson); + isPerson = sinon.stub(contactTypeUtils, 'isPerson').returns(true); + + const result = await Contact.v1.getWithLineage(localContext)(identifier); + + expect(result).to.equal(copiedPerson); + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isContact.calledOnceWithExactly(settingsGetAll(), person)).to.be.true; + expect(warn.notCalled).to.be.true; + expect(debug.notCalled).to.be.true; + expect(isPerson.calledOnceWithExactly(settingsGetAll(), person)).to.be.true; + expect(getContactLineageOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getContactLineageInner.calledOnceWithExactly(lineageDocs, person, true)).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + }); + + it('returns a contact with lineage for place type contact', async () => { + const place0 = {_id: 'place0', _rev: 'rev'}; + const place1 = {_id: 'place1', _rev: 'rev'}; + const place2 = {_id: 'place2', _rev: 'rev'}; + const contact0 = { _id: 'contact0', _rev: 'rev' }; + const lineageDocs = [ place0, place1, place2 ]; + getLineageDocsByIdInner.resolves(lineageDocs); + isContact.returns(true); + settingsGetAll.returns(settings); + const place0WithContact = { ...place0, contact: contact0 }; + const place0WithLineage = { ...place0WithContact, lineage: true }; + const copiedPlace = { ...place0WithLineage }; + getContactLineageInner.returns(copiedPlace); + isPerson = sinon.stub(contactTypeUtils, 'isPerson').returns(false); + + const result = await Contact.v1.getWithLineage(localContext)(identifier); + + expect(result).to.equal(copiedPlace); + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isContact.calledOnceWithExactly(settingsGetAll(), place0)).to.be.true; + expect(warn.notCalled).to.be.true; + expect(debug.notCalled).to.be.true; + expect(isPerson.calledOnceWithExactly(settingsGetAll(), place0)).to.be.true; + expect(getContactLineageOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getContactLineageInner.calledOnceWithExactly(lineageDocs)).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + }); + + it('returns null when no contact or lineage is found', async () => { + getLineageDocsByIdInner.resolves([]); + + const result = await Contact.v1.getWithLineage(localContext)(identifier); + + expect(result).to.be.null; + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(getContactTypes.notCalled).to.be.true; + expect(isContact.notCalled).to.be.true; + expect(warn.calledOnceWithExactly(`No contact found for identifier [${identifier.uuid}].`)).to.be.true; + expect(debug.notCalled).to.be.true; + expect(getContactLineageInner.notCalled).to.be.true; + expect(getContactLineageOuter.notCalled).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + }); + + it('returns null if the doc returned is not a contact', async () => { + const person = { type: 'not-person', _id: 'uuid', _rev: 'rev' }; + const place0 = { _id: 'place0', _rev: 'rev' }; + const place1 = { _id: 'place1', _rev: 'rev' }; + const place2 = { _id: 'place2', _rev: 'rev' }; + getLineageDocsByIdInner.resolves([person, place0, place1, place2]); + isContact.returns(false); + settingsGetAll.returns(settings); + + const result = await Contact.v1.getWithLineage(localContext)(identifier); + + expect(result).to.be.null; + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isContact.calledOnceWithExactly(settingsGetAll(), person)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${identifier.uuid}] is not a valid contact.`)).to.be.true; + expect(debug.notCalled).to.be.true; + expect(getContactLineageInner.notCalled).to.be.true; + expect(getContactLineageOuter.notCalled).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + }); + + it('returns a contact if no lineage is found', async () => { + const person = { type: 'person', _id: 'uuid', _rev: 'rev' }; + getLineageDocsByIdInner.resolves([person]); + isContact.returns(true); + settingsGetAll.returns(settings); + + const result = await Contact.v1.getWithLineage(localContext)(identifier); + + expect(result).to.equal(person); + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isContact.calledOnceWithExactly(settingsGetAll(), person)).to.be.true; + expect(warn.notCalled).to.be.true; + expect(debug.calledOnceWithExactly(`No lineage contacts found for person [${identifier.uuid}].`)).to.be.true; + expect(getContactLineageInner.notCalled).to.be.true; + expect(getContactLineageOuter.notCalled).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + }); + }); + + describe('getUuidsPage', () => { + const limit = 3; + const cursor = null; + const notNullCursor = '5'; + const contactType = 'person'; + const invalidContactTypeQualifier = { contactType: 'invalid' } as const; + const validContactTypes = [{ id: 'person' }, { id: 'place' }]; + let getByTypeExactMatchFreetext: SinonStub; + let getByExactMatchFreetext: SinonStub; + let getByType: SinonStub; + let getByTypeStartsWithFreetext: SinonStub; + let getByStartsWithFreetext: SinonStub; + let queryDocUuidsByKeyOuter: SinonStub; + let queryDocUuidsByRangeOuter: SinonStub; + let getPaginatedDocs: SinonStub; + + beforeEach(() => { + getByTypeExactMatchFreetext = sinon.stub(); + getByExactMatchFreetext = sinon.stub(); + getByType = sinon.stub(); + getByTypeStartsWithFreetext = sinon.stub(); + getByStartsWithFreetext = sinon.stub(); + // comment to encapsulate assigning of exact match functions + queryDocUuidsByKeyOuter = sinon.stub(LocalDoc, 'queryDocUuidsByKey'); + queryDocUuidsByKeyOuter.withArgs( + localContext.medicDb, 'medic-client/contacts_by_type_freetext' + ).returns(getByTypeExactMatchFreetext); + queryDocUuidsByKeyOuter.withArgs( + localContext.medicDb, 'medic-client/contacts_by_freetext' + ).returns(getByExactMatchFreetext); + queryDocUuidsByKeyOuter.withArgs(localContext.medicDb, 'medic-client/contacts_by_type').returns(getByType); + // end comment + // comment to encapsulate assigning of "StartsWith" functions + queryDocUuidsByRangeOuter = sinon.stub(LocalDoc, 'queryDocUuidsByRange'); + queryDocUuidsByRangeOuter.withArgs( + localContext.medicDb, 'medic-client/contacts_by_type_freetext' + ).returns(getByTypeStartsWithFreetext); + queryDocUuidsByRangeOuter.withArgs( + localContext.medicDb, 'medic-client/contacts_by_freetext' + ).returns(getByStartsWithFreetext); + // end comment + getContactTypes.returns(validContactTypes); + settingsGetAll.returns(settings); + getPaginatedDocs = sinon.stub(LocalDoc, 'getPaginatedDocs'); + }); + + it('returns a page of contact identifiers for contactType only qualifier', async () => { + const qualifier = { contactType } as const; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '3', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '3', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(1); + expect(getContactTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(cursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(cursor)); + expect(getByType.calledWithExactly([qualifier.contactType], limit, Number(cursor))).to.be.true; + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + + it('returns a page of contact identifiers for freetext only qualifier with : delimiter', async () => { + const freetext = 'has : delimiter'; + const qualifier = { + freetext + }; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '3', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '3', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.notCalled).to.be.true; + expect(getContactTypes.notCalled).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(cursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(cursor)); + expect(getByExactMatchFreetext.calledWithExactly([qualifier.freetext], limit, Number(cursor))).to.be.true; + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + + it('returns a page of contact identifiers for freetext only qualifier without : delimiter', async () => { + const freetext = 'does not have colon delimiter'; + const qualifier = { + freetext + }; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '3', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '3', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.notCalled).to.be.true; + expect(getContactTypes.notCalled).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(cursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(cursor)); + expect(getByStartsWithFreetext.calledWithExactly( + [qualifier.freetext], [qualifier.freetext + END_OF_ALPHABET_MARKER], limit, Number(cursor) + )).to.be.true; + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + }); + + it('returns a page of contact identifiers for contactType and freetext qualifier with : delimiter', async () => { + const freetext = 'has : delimiter'; + const qualifier = { + contactType, + freetext + }; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '3', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '3', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(1); + expect(getContactTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(cursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(cursor)); + expect(getByTypeExactMatchFreetext.calledWithExactly( + [qualifier.contactType, qualifier.freetext], limit, Number(cursor) + )).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + + it('returns a page of contact identifiers for contactType and freetext qualifier without delimiter', async () => { + const freetext = 'does not have colon delimiter'; + const qualifier = { + contactType, + freetext + }; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '3', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '3', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(1); + expect(getContactTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(cursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(cursor)); + expect(getByTypeStartsWithFreetext.calledWithExactly( + [qualifier.contactType, qualifier.freetext], + [qualifier.contactType, qualifier.freetext + END_OF_ALPHABET_MARKER], + limit, + Number(cursor) + )).to.be.true; + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + + it('returns a page of contact identifiers for contactType only qualifier for not-null cursor', async () => { + const qualifier = { contactType } as const; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '8', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '8', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, notNullCursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(1); + expect(getContactTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(notNullCursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(notNullCursor)); + expect(getByType.calledOnceWithExactly([qualifier.contactType], limit, Number(notNullCursor))).to.be.true; + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + + it('returns a page of contact identifiers for freetext only' + + 'qualifier with : delimiter for not-null cursor', async () => { + const freetext = 'has : delimiter'; + const qualifier = { + freetext + }; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '8', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '8', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, notNullCursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.notCalled).to.be.true; + expect(getContactTypes.notCalled).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(notNullCursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(notNullCursor)); + expect( + getByExactMatchFreetext.calledOnceWithExactly([qualifier.freetext], limit, Number(notNullCursor)) + ).to.be.true; + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + + it('returns a page of contact identifiers for freetext only qualifier' + + ' without : delimiter for not-null cursor', async () => { + const freetext = 'does not have colon delimiter'; + const qualifier = { + freetext + }; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '8', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '8', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, notNullCursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.notCalled).to.be.true; + expect(getContactTypes.notCalled).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(notNullCursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(notNullCursor)); + expect(getByStartsWithFreetext.calledOnceWithExactly( + [qualifier.freetext], [qualifier.freetext + END_OF_ALPHABET_MARKER], limit, Number(notNullCursor) + )).to.be.true; + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + }); + + it( + 'returns a page of contact identifiers for contactType and freetext qualifier' + + 'with : delimiter for not-null cursor', async () => { + const freetext = 'has : delimiter'; + const qualifier = { + contactType, + freetext + }; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '8', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '8', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, notNullCursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(1); + expect(getContactTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(notNullCursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(notNullCursor)); + expect(getByTypeExactMatchFreetext.calledWithExactly( + [qualifier.contactType, qualifier.freetext], limit, Number(notNullCursor) + )).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + } + ); + + it('returns a page of contact identifiers for contactType and freetext qualifier' + + 'without delimiter for not-null cursor', async () => { + const freetext = 'does not have colon delimiter'; + const qualifier = { + contactType, + freetext + }; + const docs = [ + { type: contactType, _id: '1' }, + { type: contactType, _id: '2' }, + { type: contactType, _id: '3' } + ]; + const getPaginatedDocsResult = { + cursor: '8', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '8', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, notNullCursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(1); + expect(getContactTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(notNullCursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(notNullCursor)); + expect(getByTypeStartsWithFreetext.calledWithExactly( + [qualifier.contactType, qualifier.freetext], + [qualifier.contactType, qualifier.freetext + END_OF_ALPHABET_MARKER], + limit, + Number(notNullCursor) + )).to.be.true; + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + + it('throws an error if contact type is invalid', async () => { + await expect( + Contact.v1.getUuidsPage(localContext)(invalidContactTypeQualifier, cursor, limit) + ).to.be.rejectedWith( + `Invalid contact type [${invalidContactTypeQualifier.contactType}].` + ); + + expect(settingsGetAll.calledOnce).to.be.true; + expect(getContactTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + + [ + {}, + '-1', + undefined, + ].forEach((invalidCursor ) => { + it(`throws an error if cursor is invalid: ${String(invalidCursor)}`, async () => { + const qualifier = { + contactType, + }; + + await expect(Contact.v1.getUuidsPage(localContext)(qualifier, invalidCursor as string, limit)) + .to.be.rejectedWith(`Invalid cursor token: [${String(invalidCursor)}]`); + + expect(settingsGetAll.calledOnce).to.be.true; + expect(getContactTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByType.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + }); + + it('returns empty array if contacts do not exist', async () => { + const qualifier = { + contactType + }; + const expectedResult = { + data: [], + cursor + }; + getPaginatedDocs.resolves(expectedResult); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.calledOnce).to.be.true; + expect(getContactTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(3); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect( + queryDocUuidsByKeyOuter.getCall(2).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type']); + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(2); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_type_freetext']); + expect( + queryDocUuidsByRangeOuter.getCall(1).args + ).to.deep.equal([localContext.medicDb, 'medic-client/contacts_by_freetext']); + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(cursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(cursor)); + expect(getByType.calledOnceWithExactly([qualifier.contactType], limit, Number(cursor))).to.be.true; + expect(getByTypeExactMatchFreetext.notCalled).to.be.true; + expect(getByExactMatchFreetext.notCalled).to.be.true; + expect(getByTypeStartsWithFreetext.notCalled).to.be.true; + expect(getByStartsWithFreetext.notCalled).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/libs/core.spec.ts b/shared-libs/cht-datasource/test/local/libs/core.spec.ts new file mode 100644 index 00000000000..b5e83105bbe --- /dev/null +++ b/shared-libs/cht-datasource/test/local/libs/core.spec.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai'; +import { validateCursor } from '../../../src/local/libs/core'; +import { InvalidArgumentError } from '../../../src'; + +describe('validateCursor', () => { + it('should return the numeric value when given a valid numeric string', () => { + expect(validateCursor('0')).to.equal(0); + expect(validateCursor('1')).to.equal(1); + expect(validateCursor('100')).to.equal(100); + }); + + it('should accept null and treat it as 0', () => { + expect(validateCursor(null)).to.equal(0); + }); + + it('should throw InvalidArgumentError for negative numbers', () => { + expect(() => validateCursor('-1')).to.throw( + InvalidArgumentError, + 'Invalid cursor token: [-1].' + ); + expect(() => validateCursor('-100')).to.throw( + InvalidArgumentError, + 'Invalid cursor token: [-100].' + ); + }); + + it('should throw InvalidArgumentError for non-integer numbers', () => { + expect(() => validateCursor('1.5')).to.throw( + InvalidArgumentError, + 'Invalid cursor token: [1.5].' + ); + expect(() => validateCursor('0.1')).to.throw( + InvalidArgumentError, + 'Invalid cursor token: [0.1].' + ); + }); + + it('should throw InvalidArgumentError for non-numeric strings', () => { + expect(() => validateCursor('abc')).to.throw( + InvalidArgumentError, + 'Invalid cursor token: [abc].' + ); + expect(() => validateCursor('123abc')).to.throw( + InvalidArgumentError, + 'Invalid cursor token: [123abc].' + ); + }); + + it('should handle string numbers with leading zeros', () => { + expect(validateCursor('00')).to.equal(0); + expect(validateCursor('01')).to.equal(1); + expect(validateCursor('000100')).to.equal(100); + }); +}); 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 c3f8d12f9cf..7fb4812167f 100644 --- a/shared-libs/cht-datasource/test/local/libs/doc.spec.ts +++ b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts @@ -150,6 +150,9 @@ describe('local doc lib', () => { }); describe('queryDocsByRange', () => { + const limit = 3; + const skip = 2; + it('returns lineage docs for the given id', async () => { const doc0 = { _id: 'doc0' }; const doc1 = { _id: 'doc1' }; @@ -170,7 +173,9 @@ describe('local doc lib', () => { expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { include_docs: true, startkey: doc0._id, - endkey: doc1._id + endkey: doc1._id, + limit: undefined, + skip: 0 })).to.be.true; expect(isDoc.args).to.deep.equal([[doc0], [doc1], [doc2]]); }); @@ -187,13 +192,15 @@ describe('local doc lib', () => { }); isDoc.returns(true); - const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc2._id); + const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc2._id, limit, skip); expect(result).to.deep.equal([doc0, null, doc2]); expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { startkey: doc0._id, endkey: doc2._id, - include_docs: true + include_docs: true, + limit, + skip, })).to.be.true; expect(isDoc.args).to.deep.equal([[doc0], [null], [doc2]]); }); @@ -205,13 +212,15 @@ describe('local doc lib', () => { }); isDoc.returns(false); - const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc0._id); + const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc0._id, limit, skip); expect(result).to.deep.equal([null]); expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { startkey: doc0._id, endkey: doc0._id, - include_docs: true + include_docs: true, + limit, + skip })).to.be.true; expect(isDoc.calledOnceWithExactly(doc0)).to.be.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 60539b9cb93..420b64dd70c 100644 --- a/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts +++ b/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts @@ -1,14 +1,11 @@ import { expect } from 'chai'; -import { - getLineageDocsById, - getPrimaryContactIds, - hydrateLineage, - hydratePrimaryContact -} from '../../../src/local/libs/lineage'; +import * as Lineage from '../../../src/local/libs/lineage'; import sinon, { SinonStub } from 'sinon'; import * as LocalDoc from '../../../src/local/libs/doc'; import { Doc } from '../../../src/libs/doc'; import logger from '@medic/logger'; +import * as Core from '../../../src/libs/core'; +import { NonEmptyArray, Nullable } from '../../../src'; describe('local lineage lib', () => { let debug: SinonStub; @@ -26,7 +23,7 @@ describe('local lineage lib', () => { .returns(queryFn); const medicDb = { hello: 'world' } as unknown as PouchDB.Database; - const fn = getLineageDocsById(medicDb); + const fn = Lineage.getLineageDocsById(medicDb); const result = await fn(uuid); expect(result).to.deep.equal([]); @@ -40,7 +37,7 @@ describe('local lineage lib', () => { const place1 = { _id: 'place-1', _rev: 'rev-2', contact: { _id: 'contact-1' } }; const place2 = { _id: 'place-2', _rev: 'rev-3', contact: { _id: 'contact-2' } }; - const result = getPrimaryContactIds([place0, place1, place2]); + const result = Lineage.getPrimaryContactIds([place0, place1, place2]); expect(result).to.deep.equal([place0.contact._id, place1.contact._id, place2.contact._id]); }); @@ -52,7 +49,7 @@ describe('local lineage lib', () => { { _id: 'place-0', _rev: 'rev-1', contact: { _id: '' } } ].forEach((place) => { it(`returns nothing for ${JSON.stringify(place)}`, () => { - const result = getPrimaryContactIds([place]); + const result = Lineage.getPrimaryContactIds([place]); expect(result).to.be.empty; }); }); @@ -64,7 +61,7 @@ describe('local lineage lib', () => { const contacts = [{ _id: 'contact-1', _rev: 'rev-1', type: 'person' }, contact]; const place0 = { _id: 'place-0', _rev: 'rev-1', contact: { _id: 'contact-0' } }; - const result = hydratePrimaryContact(contacts)(place0); + const result = Lineage.hydratePrimaryContact(contacts)(place0); expect(result).to.deep.equal({ ...place0, contact }); expect(debug.notCalled).to.be.true; @@ -73,7 +70,7 @@ describe('local lineage lib', () => { it('returns a place unchanged if no contacts are provided', () => { const place0 = { _id: 'place-0', _rev: 'rev-1', contact: { _id: 'contact-0' } }; - const result = hydratePrimaryContact([])(place0); + const result = Lineage.hydratePrimaryContact([])(place0); expect(result).to.equal(place0); expect(debug.calledOnceWithExactly( @@ -88,7 +85,7 @@ describe('local lineage lib', () => { ]; const place0 = { _id: 'place-0', _rev: 'rev-1', contact: { _id: 'contact-0' } }; - const result = hydratePrimaryContact(contacts)(place0); + const result = Lineage.hydratePrimaryContact(contacts)(place0); expect(result).to.equal(place0); expect(debug.calledOnceWithExactly( @@ -103,7 +100,7 @@ describe('local lineage lib', () => { ]; const place0 = { _id: 'place-0', _rev: 'rev-1', contact: { hello: 'contact-1' } }; - const result = hydratePrimaryContact(contacts)(place0); + const result = Lineage.hydratePrimaryContact(contacts)(place0); expect(result).to.equal(place0); expect(debug.notCalled).to.be.true; @@ -115,7 +112,7 @@ describe('local lineage lib', () => { { _id: 'contact-2', _rev: 'rev-0', type: 'person' } ]; - const result = hydratePrimaryContact(contacts)(null); + const result = Lineage.hydratePrimaryContact(contacts)(null); expect(result).to.be.null; expect(debug.notCalled).to.be.true; @@ -130,7 +127,7 @@ describe('local lineage lib', () => { const place2 = { _id: 'place-2', _rev: 'rev-3' }; const places = [place0, place1, place2]; - const result = hydrateLineage(contact, places); + const result = Lineage.hydrateLineage(contact, places); expect(result).to.deep.equal({ ...contact, @@ -160,7 +157,7 @@ describe('local lineage lib', () => { const place2 = { _id: 'place-2', _rev: 'rev-3' }; const places = [place0, place1, place2, null]; - const result = hydrateLineage(contact, places); + const result = Lineage.hydrateLineage(contact, places); expect(result).to.deep.equal({ ...contact, @@ -186,4 +183,108 @@ describe('local lineage lib', () => { )).to.be.true; }); }); + + describe('getContactLineage', () => { + const medicDb = { hello: 'world' } as unknown as PouchDB.Database; + let getDocsByIdsInner: SinonStub; + let getDocsByIdsOuter: SinonStub; + let getPrimaryContactIds: SinonStub; + let hydratePrimaryContactInner: SinonStub; + let hydratePrimaryContactOuter: SinonStub; + let hydrateLineage: SinonStub; + let deepCopy: SinonStub; + + beforeEach(() => { + getDocsByIdsInner = sinon.stub(); + getDocsByIdsOuter = sinon + .stub(LocalDoc, 'getDocsByIds') + .returns(getDocsByIdsInner); + getPrimaryContactIds = sinon.stub(Lineage, 'getPrimaryContactIds'); + hydratePrimaryContactInner = sinon.stub(); + hydratePrimaryContactOuter = sinon + .stub(Lineage, 'hydratePrimaryContact') + .returns(hydratePrimaryContactInner); + hydrateLineage = sinon.stub(Lineage, 'hydrateLineage'); + deepCopy = sinon.stub(Core, 'deepCopy'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('returns a contact with lineage for no person and filterSelf set to false', async () => { + const person = { type: 'person', _id: 'uuid', _rev: 'rev' }; + const place0 = { _id: 'place0', _rev: 'rev' }; + const place1 = { _id: 'place1', _rev: 'rev' }; + const place2 = { _id: 'place2', _rev: 'rev' }; + const contact0 = { _id: 'contact0', _rev: 'rev' }; + const contact1 = { _id: 'contact1', _rev: 'rev' }; + const lineageContacts = [person, place0, place1, place2]; + + getPrimaryContactIds.returns([contact0._id, contact1._id, person._id]); + getDocsByIdsInner.resolves([contact0, contact1]); + const place0WithContact = { ...place0, contact: contact0 }; + const place1WithContact = { ...place1, contact: contact1 }; + hydratePrimaryContactInner.onFirstCall().returns(place0WithContact); + hydratePrimaryContactInner.onSecondCall().returns(place1WithContact); + hydratePrimaryContactInner.onThirdCall().returns(place2); + const personWithLineage = { ...person, lineage: true }; + hydrateLineage.returns(personWithLineage); + const copiedPerson = { ...personWithLineage }; + deepCopy.returns(copiedPerson); + + const result = await Lineage.getContactLineage(medicDb)(lineageContacts as NonEmptyArray>); + + expect(result).to.equal(copiedPerson); + expect(getPrimaryContactIds.calledOnceWithExactly(lineageContacts)).to.be.true; + expect(getDocsByIdsOuter.calledOnceWithExactly(medicDb)).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly([contact0._id, contact1._id, person._id])).to.be.true; + expect(hydratePrimaryContactOuter.calledOnceWithExactly([contact0, contact1])).to.be.true; + expect(hydratePrimaryContactInner.callCount).to.be.equal(4); + expect(hydratePrimaryContactInner.calledWith(person)).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place0)).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place1)).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place2)).to.be.true; + expect(hydrateLineage.calledOnceWithExactly(place0WithContact, [place1WithContact, place2])).to.be.true; + expect(deepCopy.calledOnceWithExactly(personWithLineage)).to.be.true; + }); + + it('returns a contact with lineage for person and filterSelf set to true', async () => { + const person = { type: 'person', _id: 'uuid', _rev: 'rev' }; + const place0 = { _id: 'place0', _rev: 'rev' }; + const place1 = { _id: 'place1', _rev: 'rev' }; + const place2 = { _id: 'place2', _rev: 'rev' }; + const contact0 = { _id: 'contact0', _rev: 'rev' }; + const contact1 = { _id: 'contact1', _rev: 'rev' }; + const lineageContacts = [place0, place1, place2]; + + getPrimaryContactIds.returns([contact0._id, contact1._id, person._id]); + getDocsByIdsInner.resolves([contact0, contact1]); + const place0WithContact = { ...place0, contact: contact0 }; + const place1WithContact = { ...place1, contact: contact1 }; + hydratePrimaryContactInner.onFirstCall().returns(place0WithContact); + hydratePrimaryContactInner.onSecondCall().returns(place1WithContact); + hydratePrimaryContactInner.onThirdCall().returns(place2); + const personWithLineage = { ...person, lineage: true }; + hydrateLineage.returns(personWithLineage); + const copiedPerson = { ...personWithLineage }; + deepCopy.returns(copiedPerson); + + const result = await Lineage.getContactLineage(medicDb)( + lineageContacts as NonEmptyArray>, person, true + ); + + expect(result).to.equal(copiedPerson); + expect(getPrimaryContactIds.calledOnceWithExactly(lineageContacts)).to.be.true; + expect(getDocsByIdsOuter.calledOnceWithExactly(medicDb)).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly([contact0._id, contact1._id])).to.be.true; + expect(hydratePrimaryContactOuter.calledOnceWithExactly([person, contact0, contact1])).to.be.true; + expect(hydratePrimaryContactInner.callCount).to.be.equal(3); + expect(hydratePrimaryContactInner.calledWith(place0)).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place1)).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place2)).to.be.true; + expect(hydrateLineage.calledOnceWithExactly(person, [place0WithContact, place1WithContact, place2])).to.be.true; + expect(deepCopy.calledOnceWithExactly(personWithLineage)).to.be.true; + }); + }); }); diff --git a/shared-libs/cht-datasource/test/local/person.spec.ts b/shared-libs/cht-datasource/test/local/person.spec.ts index 580d08b45ef..c12fdd5f3ec 100644 --- a/shared-libs/cht-datasource/test/local/person.spec.ts +++ b/shared-libs/cht-datasource/test/local/person.spec.ts @@ -7,7 +7,6 @@ import * as LocalDoc from '../../src/local/libs/doc'; import * as Lineage from '../../src/local/libs/lineage'; import { expect } from 'chai'; import { LocalDataContext } from '../../src/local/libs/data-context'; -import * as Core from '../../src/libs/core'; describe('local person', () => { let localContext: LocalDataContext; @@ -90,35 +89,18 @@ describe('local person', () => { const identifier = { uuid: 'uuid' } as const; let getLineageDocsByIdInner: SinonStub; let getLineageDocsByIdOuter: SinonStub; - let getDocsByIdsInner: SinonStub; - let getDocsByIdsOuter: SinonStub; - let getPrimaryContactIds: SinonStub; - let hydratePrimaryContactInner: SinonStub; - let hydratePrimaryContactOuter: SinonStub; - let hydrateLineage: SinonStub; - let deepCopy: SinonStub; + let getContactLineageInner: SinonStub; + let getContactLineageOuter: SinonStub; beforeEach(() => { getLineageDocsByIdInner = sinon.stub(); getLineageDocsByIdOuter = sinon .stub(Lineage, 'getLineageDocsById') .returns(getLineageDocsByIdInner); - getDocsByIdsInner = sinon.stub(); - getDocsByIdsOuter = sinon - .stub(LocalDoc, 'getDocsByIds') - .returns(getDocsByIdsInner); - getPrimaryContactIds = sinon.stub(Lineage, 'getPrimaryContactIds'); - hydratePrimaryContactInner = sinon.stub(); - hydratePrimaryContactOuter = sinon - .stub(Lineage, 'hydratePrimaryContact') - .returns(hydratePrimaryContactInner); - hydrateLineage = sinon.stub(Lineage, 'hydrateLineage'); - deepCopy = sinon.stub(Core, 'deepCopy'); - }); - - afterEach(() => { - expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; - expect(getDocsByIdsOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + getContactLineageInner = sinon.stub(); + getContactLineageOuter = sinon + .stub(Lineage, 'getContactLineage') + .returns(getContactLineageInner); }); it('returns a person with lineage', async () => { @@ -126,22 +108,14 @@ describe('local person', () => { const place0 = { _id: 'place0', _rev: 'rev' }; const place1 = { _id: 'place1', _rev: 'rev' }; const place2 = { _id: 'place2', _rev: 'rev' }; - const contact0 = { _id: 'contact0', _rev: 'rev' }; - const contact1 = { _id: 'contact1', _rev: 'rev' }; - getLineageDocsByIdInner.resolves([person, place0, place1, place2]); + const lineageDocs = [person, place0, place1, place2]; + const lineagePlaces = [place0, place1, place2]; + getLineageDocsByIdInner.resolves(lineageDocs); isPerson.returns(true); settingsGetAll.returns(settings); - getPrimaryContactIds.returns([contact0._id, contact1._id, person._id]); - getDocsByIdsInner.resolves([contact0, contact1]); - const place0WithContact = { ...place0, contact: contact0 }; - const place1WithContact = { ...place1, contact: contact1 }; - hydratePrimaryContactInner.onFirstCall().returns(place0WithContact); - hydratePrimaryContactInner.onSecondCall().returns(place1WithContact); - hydratePrimaryContactInner.onThirdCall().returns(place2); const personWithLineage = { ...person, lineage: true }; - hydrateLineage.returns(personWithLineage); const copiedPerson = { ...personWithLineage }; - deepCopy.returns(copiedPerson); + getContactLineageInner.returns(copiedPerson); const result = await Person.v1.getWithLineage(localContext)(identifier); @@ -150,15 +124,9 @@ describe('local person', () => { expect(isPerson.calledOnceWithExactly(settings, person)).to.be.true; expect(warn.notCalled).to.be.true; expect(debug.notCalled).to.be.true; - expect(getPrimaryContactIds.calledOnceWithExactly([place0, place1, place2])).to.be.true; - expect(getDocsByIdsInner.calledOnceWithExactly([contact0._id, contact1._id])).to.be.true; - expect(hydratePrimaryContactOuter.calledOnceWithExactly([person, contact0, contact1])).to.be.true; - expect(hydratePrimaryContactInner.calledThrice).to.be.true; - expect(hydratePrimaryContactInner.calledWith(place0)).to.be.true; - expect(hydratePrimaryContactInner.calledWith(place1)).to.be.true; - expect(hydratePrimaryContactInner.calledWith(place2)).to.be.true; - expect(hydrateLineage.calledOnceWithExactly(person, [place0WithContact, place1WithContact, place2])).to.be.true; - expect(deepCopy.calledOnceWithExactly(personWithLineage)).to.be.true; + expect(getContactLineageOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getContactLineageInner.calledOnceWithExactly(lineagePlaces, person, true)).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; }); it('returns null when no person or lineage is found', async () => { @@ -171,12 +139,9 @@ describe('local person', () => { expect(isPerson.notCalled).to.be.true; expect(warn.calledOnceWithExactly(`No person found for identifier [${identifier.uuid}].`)).to.be.true; expect(debug.notCalled).to.be.true; - expect(getPrimaryContactIds.notCalled).to.be.true; - expect(getDocsByIdsInner.notCalled).to.be.true; - expect(hydratePrimaryContactOuter.notCalled).to.be.true; - expect(hydratePrimaryContactInner.notCalled).to.be.true; - expect(hydrateLineage.notCalled).to.be.true; - expect(deepCopy.notCalled).to.be.true; + expect(getContactLineageInner.notCalled).to.be.true; + expect(getContactLineageOuter.notCalled).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; }); it('returns null if the doc returned is not a person', async () => { @@ -195,12 +160,9 @@ describe('local person', () => { expect(isPerson.calledOnceWithExactly(settings, person)).to.be.true; expect(warn.calledOnceWithExactly(`Document [${identifier.uuid}] is not a valid person.`)).to.be.true; expect(debug.notCalled).to.be.true; - expect(getPrimaryContactIds.notCalled).to.be.true; - expect(getDocsByIdsInner.notCalled).to.be.true; - expect(hydratePrimaryContactOuter.notCalled).to.be.true; - expect(hydratePrimaryContactInner.notCalled).to.be.true; - expect(hydrateLineage.notCalled).to.be.true; - expect(deepCopy.notCalled).to.be.true; + expect(getContactLineageInner.notCalled).to.be.true; + expect(getContactLineageOuter.notCalled).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; }); it('returns a person if no lineage is found', async () => { @@ -216,12 +178,9 @@ describe('local person', () => { expect(isPerson.calledOnceWithExactly(settings, person)).to.be.true; expect(warn.notCalled).to.be.true; expect(debug.calledOnceWithExactly(`No lineage places found for person [${identifier.uuid}].`)).to.be.true; - expect(getPrimaryContactIds.notCalled).to.be.true; - expect(getDocsByIdsInner.notCalled).to.be.true; - expect(hydratePrimaryContactOuter.notCalled).to.be.true; - expect(hydratePrimaryContactInner.notCalled).to.be.true; - expect(hydrateLineage.notCalled).to.be.true; - expect(deepCopy.notCalled).to.be.true; + expect(getContactLineageInner.notCalled).to.be.true; + expect(getContactLineageOuter.notCalled).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; }); }); diff --git a/shared-libs/cht-datasource/test/local/place.spec.ts b/shared-libs/cht-datasource/test/local/place.spec.ts index 914fdf64840..610ddfb8acc 100644 --- a/shared-libs/cht-datasource/test/local/place.spec.ts +++ b/shared-libs/cht-datasource/test/local/place.spec.ts @@ -7,7 +7,6 @@ import * as LocalDoc from '../../src/local/libs/doc'; import { expect } from 'chai'; import { LocalDataContext } from '../../src/local/libs/data-context'; import * as Lineage from '../../src/local/libs/lineage'; -import * as Core from '../../src/libs/core'; describe('local place', () => { let localContext: LocalDataContext; @@ -90,35 +89,18 @@ describe('local place', () => { const identifier = { uuid: 'place0' } as const; let getLineageDocsByIdInner: SinonStub; let getLineageDocsByIdOuter: SinonStub; - let getDocsByIdsInner: SinonStub; - let getDocsByIdsOuter: SinonStub; - let getPrimaryContactIds: SinonStub; - let hydratePrimaryContactInner: SinonStub; - let hydratePrimaryContactOuter: SinonStub; - let hydrateLineage: SinonStub; - let deepCopy: SinonStub; + let getContactLineageInner: SinonStub; + let getContactLineageOuter: SinonStub; beforeEach(() => { getLineageDocsByIdInner = sinon.stub(); getLineageDocsByIdOuter = sinon .stub(Lineage, 'getLineageDocsById') .returns(getLineageDocsByIdInner); - getDocsByIdsInner = sinon.stub(); - getDocsByIdsOuter = sinon - .stub(LocalDoc, 'getDocsByIds') - .returns(getDocsByIdsInner); - getPrimaryContactIds = sinon.stub(Lineage, 'getPrimaryContactIds'); - hydratePrimaryContactInner = sinon.stub(); - hydratePrimaryContactOuter = sinon - .stub(Lineage, 'hydratePrimaryContact') - .returns(hydratePrimaryContactInner); - hydrateLineage = sinon.stub(Lineage, 'hydrateLineage'); - deepCopy = sinon.stub(Core, 'deepCopy'); - }); - - afterEach(() => { - expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; - expect(getDocsByIdsOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + getContactLineageInner = sinon.stub(); + getContactLineageOuter = sinon + .stub(Lineage, 'getContactLineage') + .returns(getContactLineageInner); }); it('returns a place with lineage', async () => { @@ -126,21 +108,14 @@ describe('local place', () => { const place1 = { _id: 'place1', _rev: 'rev' }; const place2 = { _id: 'place2', _rev: 'rev' }; const contact0 = { _id: 'contact0', _rev: 'rev' }; - const contact1 = { _id: 'contact1', _rev: 'rev' }; - getLineageDocsByIdInner.resolves([place0, place1, place2]); + const lineageDocs = [place0, place1, place2]; + getLineageDocsByIdInner.resolves(lineageDocs); isPlace.returns(true); settingsGetAll.returns(settings); - getPrimaryContactIds.returns([contact0._id, contact1._id]); - getDocsByIdsInner.resolves([contact0, contact1]); const place0WithContact = { ...place0, contact: contact0 }; - const place1WithContact = { ...place1, contact: contact1 }; - hydratePrimaryContactInner.onFirstCall().returns(place0WithContact); - hydratePrimaryContactInner.onSecondCall().returns(place1WithContact); - hydratePrimaryContactInner.onThirdCall().returns(place2); const place0WithLineage = { ...place0WithContact, lineage: true }; - hydrateLineage.returns(place0WithLineage); const copiedPlace = { ...place0WithLineage }; - deepCopy.returns(copiedPlace); + getContactLineageInner.returns(copiedPlace); const result = await Place.v1.getWithLineage(localContext)(identifier); @@ -149,15 +124,9 @@ describe('local place', () => { expect(isPlace.calledOnceWithExactly(settings, place0)).to.be.true; expect(warn.notCalled).to.be.true; expect(debug.notCalled).to.be.true; - expect(getPrimaryContactIds.calledOnceWithExactly([place0, place1, place2])).to.be.true; - expect(getDocsByIdsInner.calledOnceWithExactly([contact0._id, contact1._id])).to.be.true; - expect(hydratePrimaryContactOuter.calledOnceWithExactly([contact0, contact1])).to.be.true; - expect(hydratePrimaryContactInner.calledThrice).to.be.true; - expect(hydratePrimaryContactInner.calledWith(place0)).to.be.true; - expect(hydratePrimaryContactInner.calledWith(place1)).to.be.true; - expect(hydratePrimaryContactInner.calledWith(place2)).to.be.true; - expect(hydrateLineage.calledOnceWithExactly(place0WithContact, [place1WithContact, place2])).to.be.true; - expect(deepCopy.calledOnceWithExactly(place0WithLineage)).to.be.true; + expect(getContactLineageOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getContactLineageInner.calledOnceWithExactly(lineageDocs)).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; }); it('returns null when no place or lineage is found', async () => { @@ -170,12 +139,9 @@ describe('local place', () => { expect(isPlace.notCalled).to.be.true; expect(warn.calledOnceWithExactly(`No place found for identifier [${identifier.uuid}].`)).to.be.true; expect(debug.notCalled).to.be.true; - expect(getPrimaryContactIds.notCalled).to.be.true; - expect(getDocsByIdsInner.notCalled).to.be.true; - expect(hydratePrimaryContactOuter.notCalled).to.be.true; - expect(hydratePrimaryContactInner.notCalled).to.be.true; - expect(hydrateLineage.notCalled).to.be.true; - expect(deepCopy.notCalled).to.be.true; + expect(getContactLineageInner.notCalled).to.be.true; + expect(getContactLineageOuter.notCalled).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; }); it('returns null if the doc returned is not a place', async () => { @@ -193,12 +159,9 @@ describe('local place', () => { expect(isPlace.calledOnceWithExactly(settings, place0)).to.be.true; expect(warn.calledOnceWithExactly(`Document [${identifier.uuid}] is not a valid place.`)).to.be.true; expect(debug.notCalled).to.be.true; - expect(getPrimaryContactIds.notCalled).to.be.true; - expect(getDocsByIdsInner.notCalled).to.be.true; - expect(hydratePrimaryContactOuter.notCalled).to.be.true; - expect(hydratePrimaryContactInner.notCalled).to.be.true; - expect(hydrateLineage.notCalled).to.be.true; - expect(deepCopy.notCalled).to.be.true; + expect(getContactLineageInner.notCalled).to.be.true; + expect(getContactLineageOuter.notCalled).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; }); it('returns a place if no lineage is found', async () => { @@ -214,12 +177,9 @@ describe('local place', () => { expect(isPlace.calledOnceWithExactly(settings, place)).to.be.true; expect(warn.notCalled).to.be.true; expect(debug.calledOnceWithExactly(`No lineage places found for place [${identifier.uuid}].`)).to.be.true; - expect(getPrimaryContactIds.notCalled).to.be.true; - expect(getDocsByIdsInner.notCalled).to.be.true; - expect(hydratePrimaryContactOuter.notCalled).to.be.true; - expect(hydratePrimaryContactInner.notCalled).to.be.true; - expect(hydrateLineage.notCalled).to.be.true; - expect(deepCopy.notCalled).to.be.true; + expect(getContactLineageInner.notCalled).to.be.true; + expect(getContactLineageOuter.notCalled).to.be.true; + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; }); }); diff --git a/shared-libs/cht-datasource/test/local/report.spec.ts b/shared-libs/cht-datasource/test/local/report.spec.ts new file mode 100644 index 00000000000..bf311afc0b5 --- /dev/null +++ b/shared-libs/cht-datasource/test/local/report.spec.ts @@ -0,0 +1,368 @@ +import { LocalDataContext } from '../../src/local/libs/data-context'; +import sinon, { SinonStub } from 'sinon'; +import { Doc } from '../../src/libs/doc'; +import logger from '@medic/logger'; +import * as LocalDoc from '../../src/local/libs/doc'; +import * as Report from '../../src/local/report'; +import { expect } from 'chai'; +import { END_OF_ALPHABET_MARKER } from '../../src/libs/constants'; + +describe('local report', () => { + let localContext: LocalDataContext; + let settingsGetAll: SinonStub; + let warn: SinonStub; + + beforeEach(() => { + settingsGetAll = sinon.stub(); + localContext = { + medicDb: {} as PouchDB.Database, + settings: {getAll: settingsGetAll} + } as unknown as LocalDataContext; + warn = sinon.stub(logger, 'warn'); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + const settings = {hello: 'world'} as const; + + describe('get', () => { + const identifier = { uuid: 'uuid' } as const; + let getDocByIdOuter: SinonStub; + let getDocByIdInner: SinonStub; + + beforeEach(() => { + getDocByIdInner = sinon.stub(); + getDocByIdOuter = sinon.stub(LocalDoc, 'getDocById').returns(getDocByIdInner); + }); + + it('returns a report by UUID', async () => { + const doc = { type: 'data_record', form: 'yes' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + + const result = await Report.v1.get(localContext)(identifier); + + expect(result).to.equal(doc); + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(warn.notCalled).to.be.true; + }); + + it('returns null if the identified doc does not have a record type', async () => { + const doc = { type: 'not-data-record', _id: '_id' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + + const result = await Report.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${doc._id}] is not a valid report.`)).to.be.true; + }); + + it('returns null if the identified doc does not have a form field', async () => { + const doc = { type: 'data_record', _id: '_id' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + + const result = await Report.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${doc._id}] is not a valid report.`)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getDocByIdInner.resolves(null); + + const result = await Report.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(settingsGetAll.notCalled).to.be.true; + expect(warn.calledOnceWithExactly(`No report found for identifier [${identifier.uuid}].`)).to.be.true; + }); + }); + + describe('getUuidsPage', () => { + const limit = 3; + const cursor = null; + const notNullCursor = '5'; + const reportType = 'data_record'; + let queryDocUuidsByKeyInner: SinonStub; + let queryDocUuidsByKeyOuter: SinonStub; + let queryDocUuidsByRangeInner: SinonStub; + let queryDocUuidsByRangeOuter: SinonStub; + let getPaginatedDocs: SinonStub; + + beforeEach(() => { + queryDocUuidsByKeyInner = sinon.stub(); + queryDocUuidsByKeyOuter = sinon.stub(LocalDoc, 'queryDocUuidsByKey').returns(queryDocUuidsByKeyInner); + queryDocUuidsByRangeInner = sinon.stub(); + queryDocUuidsByRangeOuter = sinon.stub(LocalDoc, 'queryDocUuidsByRange').returns(queryDocUuidsByRangeInner); + getPaginatedDocs = sinon.stub(LocalDoc, 'getPaginatedDocs'); + }); + + it('returns a page of report identifiers for freetext only qualifier with : delimiter', async () => { + const freetext = 'has : delimiter'; + const qualifier = { + freetext + }; + const docs = [ + { type: reportType, _id: '1', form: 'yes' }, + { type: reportType, _id: '2', form: 'yes' }, + { type: reportType, _id: '3', form: 'yes' } + ]; + const getPaginatedDocsResult = { + cursor: '3', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '3', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Report.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(queryDocUuidsByKeyInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(cursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(cursor)); + expect(queryDocUuidsByKeyInner.calledWithExactly([qualifier.freetext], limit, Number(cursor))).to.be.true; + expect(queryDocUuidsByRangeInner.notCalled).to.be.true; + }); + + it('returns a page of report identifiers for freetext only qualifier without : delimiter', async () => { + const freetext = 'does not have colon delimiter'; + const qualifier = { + freetext + }; + const docs = [ + { type: reportType, _id: '1', form: 'yes' }, + { type: reportType, _id: '2', form: 'yes' }, + { type: reportType, _id: '3', form: 'yes' } + ]; + const getPaginatedDocsResult = { + cursor: '3', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '3', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Report.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + const fetchAndFilterOuterFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(queryDocUuidsByKeyInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(cursor)); + // call the argument to check which one of the inner functions was called + fetchAndFilterOuterFirstArg(limit, Number(cursor)); + expect(queryDocUuidsByRangeInner.calledWithExactly( + [qualifier.freetext], + [qualifier.freetext + END_OF_ALPHABET_MARKER], + limit, + Number(cursor) + )).to.be.true; + expect(queryDocUuidsByKeyInner.notCalled).to.be.true; + }); + + it('returns a page of report identifiers for freetext only qualifier' + + 'with : delimiter for not-null cursor', async () => { + const freetext = 'has : delimiter'; + const qualifier = { + freetext + }; + const docs = [ + { type: reportType, _id: '1', form: 'yes' }, + { type: reportType, _id: '2', form: 'yes' }, + { type: reportType, _id: '3', form: 'yes' } + ]; + const getPaginatedDocsResult = { + cursor: '8', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '8', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Report.v1.getUuidsPage(localContext)(qualifier, notNullCursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(queryDocUuidsByKeyInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(notNullCursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(notNullCursor)); + expect( + queryDocUuidsByKeyInner.calledWithExactly([qualifier.freetext], limit, Number(notNullCursor)) + ).to.be.true; + expect(queryDocUuidsByRangeInner.notCalled).to.be.true; + }); + + it('returns a page of report identifiers for freetext only qualifier' + + 'without : delimiter for not-null cursor', async () => { + const freetext = 'does not have colon delimiter'; + const qualifier = { + freetext + }; + const docs = [ + { type: reportType, _id: '1', form: 'yes' }, + { type: reportType, _id: '2', form: 'yes' }, + { type: reportType, _id: '3', form: 'yes' } + ]; + const getPaginatedDocsResult = { + cursor: '8', + data: docs.map(doc => doc._id) + }; + const expectedResult = { + cursor: '8', + data: ['1', '2', '3'] + }; + getPaginatedDocs.resolves(getPaginatedDocsResult); + + const res = await Report.v1.getUuidsPage(localContext)(qualifier, notNullCursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(queryDocUuidsByKeyInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(getPaginatedDocs.calledOnce).to.be.true; + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(notNullCursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(notNullCursor)); + expect(queryDocUuidsByRangeInner.calledWithExactly( + [qualifier.freetext], + [qualifier.freetext + END_OF_ALPHABET_MARKER], + limit, + Number(notNullCursor) + )).to.be.true; + expect(queryDocUuidsByKeyInner.notCalled).to.be.true; + }); + + [ + {}, + '-1', + undefined, + ].forEach((invalidSkip ) => { + it(`throws an error if cursor is invalid: ${String(invalidSkip)}`, async () => { + const freetext = 'nice report'; + const qualifier = { + freetext, + }; + + await expect(Report.v1.getUuidsPage(localContext)(qualifier, invalidSkip as string, limit)) + .to.be.rejectedWith(`Invalid cursor token: [${String(invalidSkip)}]`); + + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([ localContext.medicDb, 'medic-client/reports_by_freetext' ]); + expect(queryDocUuidsByKeyInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([ localContext.medicDb, 'medic-client/reports_by_freetext' ]); + expect(getPaginatedDocs.notCalled).to.be.true; + }); + }); + + it('returns empty array if reports do not exist', async () => { + const freetext = 'non-existent-report'; + const qualifier = { + freetext + }; + const expectedResult = { + data: [], + cursor + }; + getPaginatedDocs.resolves(expectedResult); + + const res = await Report.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + const getPaginatedDocsFirstArg = getPaginatedDocs.firstCall.args[0] as (...args: unknown[]) => unknown; + + expect(res).to.deep.equal(expectedResult); + expect(queryDocUuidsByKeyOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByKeyOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(queryDocUuidsByKeyInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeInner.notCalled).to.be.true; + expect(queryDocUuidsByRangeOuter.callCount).to.be.equal(1); + expect( + queryDocUuidsByRangeOuter.getCall(0).args + ).to.deep.equal([localContext.medicDb, 'medic-client/reports_by_freetext']); + expect(getPaginatedDocs.firstCall.args[0]).to.be.a('function'); + expect(getPaginatedDocs.firstCall.args[1]).to.be.equal(limit); + expect(getPaginatedDocs.firstCall.args[2]).to.be.equal(Number(cursor)); + // call the argument to check which one of the inner functions was called + getPaginatedDocsFirstArg(limit, Number(cursor)); + expect(queryDocUuidsByRangeInner.calledWithExactly( + [qualifier.freetext], + [qualifier.freetext + END_OF_ALPHABET_MARKER], + limit, + Number(cursor) + )).to.be.true; + expect(queryDocUuidsByKeyInner.notCalled).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/person.spec.ts b/shared-libs/cht-datasource/test/person.spec.ts index 7e739c3a167..339322d2303 100644 --- a/shared-libs/cht-datasource/test/person.spec.ts +++ b/shared-libs/cht-datasource/test/person.spec.ts @@ -132,6 +132,7 @@ describe('person', () => { const cursor = '1'; const pageData = { data: people, cursor }; const limit = 3; + const stringifiedLimit = '3'; const personTypeQualifier = {contactType: 'person'} as const; const invalidQualifier = { contactType: 'invalid' } as const; let getPage: SinonStub; @@ -167,6 +168,20 @@ describe('person', () => { expect(isContactTypeQualifier.calledOnceWithExactly((personTypeQualifier))).to.be.true; }); + it('retrieves people from the data context when cursor is not null and ' + + 'limit is stringified number', async () => { + isContactTypeQualifier.returns(true); + getPage.resolves(pageData); + + const result = await Person.v1.getPage(dataContext)(personTypeQualifier, cursor, stringifiedLimit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.getPage, Remote.Person.v1.getPage)).to.be.true; + expect(getPage.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((personTypeQualifier))).to.be.true; + }); + it('throws an error if the data context is invalid', () => { isContactTypeQualifier.returns(true); assertDataContext.throws(new Error(`Invalid data context [null].`)); @@ -202,7 +217,7 @@ describe('person', () => { ].forEach((limitValue) => { it(`throws an error if limit is invalid: ${String(limitValue)}`, async () => { isContactTypeQualifier.returns(true); - getPage.resolves(people); + getPage.resolves(pageData); await expect(Person.v1.getPage(dataContext)(personTypeQualifier, cursor, limitValue as number)) .to.be.rejectedWith(`The limit must be a positive number: [${String(limitValue)}]`); @@ -223,7 +238,7 @@ describe('person', () => { ].forEach((skipValue) => { it('throws an error if cursor is invalid', async () => { isContactTypeQualifier.returns(true); - getPage.resolves(people); + getPage.resolves(pageData); await expect(Person.v1.getPage(dataContext)(personTypeQualifier, skipValue as string, limit)) .to.be.rejectedWith(`Invalid cursor token: [${String(skipValue)}]`); @@ -263,7 +278,7 @@ describe('person', () => { isContactTypeQualifier.returns(true); getPagedGenerator.returns(mockGenerator); - const generator = Person.v1.getAll(dataContext)(personTypeQualifier); + const generator = Person.v1.getAll(dataContext)(personTypeQualifier); expect(generator).to.deep.equal(mockGenerator); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; diff --git a/shared-libs/cht-datasource/test/place.spec.ts b/shared-libs/cht-datasource/test/place.spec.ts index bd3d4963fb9..23833939238 100644 --- a/shared-libs/cht-datasource/test/place.spec.ts +++ b/shared-libs/cht-datasource/test/place.spec.ts @@ -132,6 +132,7 @@ describe('place', () => { const cursor = '1'; const pageData = { data: places, cursor }; const limit = 3; + const stringifiedLimit = '3'; const placeTypeQualifier = {contactType: 'place'} as const; const invalidQualifier = { contactType: 'invalid' } as const; let getPage: SinonStub; @@ -167,6 +168,20 @@ describe('place', () => { expect(isContactTypeQualifier.calledOnceWithExactly((placeTypeQualifier))).to.be.true; }); + it('retrieves places from the data context when cursor is not null and ' + + 'limit is stringified number', async () => { + isContactTypeQualifier.returns(true); + getPage.resolves(pageData); + + const result = await Place.v1.getPage(dataContext)(placeTypeQualifier, cursor, stringifiedLimit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Place.v1.getPage, Remote.Place.v1.getPage)).to.be.true; + expect(getPage.calledOnceWithExactly(placeTypeQualifier, cursor, limit)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((placeTypeQualifier))).to.be.true; + }); + it('throws an error if the data context is invalid', () => { isContactTypeQualifier.returns(true); assertDataContext.throws(new Error(`Invalid data context [null].`)); diff --git a/shared-libs/cht-datasource/test/qualifier.spec.ts b/shared-libs/cht-datasource/test/qualifier.spec.ts index 49a06da641c..1169004ec76 100644 --- a/shared-libs/cht-datasource/test/qualifier.spec.ts +++ b/shared-libs/cht-datasource/test/qualifier.spec.ts @@ -1,4 +1,11 @@ -import {byContactType, byUuid, isContactTypeQualifier, isUuidQualifier} from '../src/qualifier'; +import { + byContactType, + byFreetext, + byUuid, + isContactTypeQualifier, + isFreetextQualifier, + isUuidQualifier +} from '../src/qualifier'; import { expect } from 'chai'; describe('qualifier', () => { @@ -63,4 +70,37 @@ describe('qualifier', () => { }); }); }); + + describe('byFreetext', () => { + it('builds a qualifier for searching an entity by freetext', () => { + expect(byFreetext('freetext')).to.deep.equal({ freetext: 'freetext' }); + }); + + [ + null, + '', + { }, + 'ab', + ' ' + ].forEach(freetext => { + it(`throws an error for ${JSON.stringify(freetext)}`, () => { + expect(() => byFreetext(freetext as string)).to.throw( + `Invalid freetext [${JSON.stringify(freetext)}].` + ); + }); + }); + }); + + describe('isFreetextQualifier', () => { + [ + [ null, false ], + [ 'freetext', false ], + [ { freetext: 'freetext' }, true ], + [ { freetext: 'freetext', other: 'other' }, true ] + ].forEach(([ freetext, expected ]) => { + it(`evaluates ${JSON.stringify(freetext)}`, () => { + expect(isFreetextQualifier(freetext)).to.equal(expected); + }); + }); + }); }); diff --git a/shared-libs/cht-datasource/test/remote/contact.spec.ts b/shared-libs/cht-datasource/test/remote/contact.spec.ts new file mode 100644 index 00000000000..3fe9eb6f111 --- /dev/null +++ b/shared-libs/cht-datasource/test/remote/contact.spec.ts @@ -0,0 +1,111 @@ +import { RemoteDataContext } from '../../src/remote/libs/data-context'; +import sinon, { SinonStub } from 'sinon'; +import * as RemoteEnv from '../../src/remote/libs/data-context'; +import * as Contact from '../../src/remote/contact'; +import { expect } from 'chai'; + +describe('remote contact', () => { + const remoteContext = {} as RemoteDataContext; + let getResourceInner: SinonStub; + let getResourceOuter: SinonStub; + let getResourcesInner: SinonStub; + let getResourcesOuter: SinonStub; + + beforeEach(() => { + getResourceInner = sinon.stub(); + getResourceOuter = sinon.stub(RemoteEnv, 'getResource').returns(getResourceInner); + getResourcesInner = sinon.stub(); + getResourcesOuter = sinon.stub(RemoteEnv, 'getResources').returns(getResourcesInner); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + const identifier = {uuid: 'uuid'} as const; + + describe('get', () => { + it('returns a contact by UUID', async () => { + const doc = { type: 'person' }; + getResourceInner.resolves(doc); + + const result = await Contact.v1.get(remoteContext)(identifier); + + expect(result).to.equal(doc); + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/contact')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getResourceInner.resolves(null); + + const result = await Contact.v1.get(remoteContext)(identifier); + + expect(result).to.be.null; + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/contact')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + }); + + describe('getWithLineage', () => { + it('returns a contact with lineage by UUID', async () => { + const doc = { type: 'person' }; + getResourceInner.resolves(doc); + + const result = await Contact.v1.getWithLineage(remoteContext)(identifier); + + expect(result).to.equal(doc); + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/contact')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid, { with_lineage: 'true' })).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getResourceInner.resolves(null); + + const result = await Contact.v1.getWithLineage(remoteContext)(identifier); + + expect(result).to.be.null; + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/contact')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid, { with_lineage: 'true' })).to.be.true; + }); + }); + + describe('getUuidsPage', () => { + const limit = 3; + const cursor = '1'; + const contactType = 'person'; + const freetext = 'contact'; + const qualifier = { + contactType, + freetext + }; + const queryParam = { + limit: limit.toString(), + freetext: freetext, + type: contactType, + cursor, + }; + + it('returns array of contact identifiers', async () => { + const doc = [{ type: 'person' }, {type: 'person'}]; + const expectedResponse = { data: doc, cursor }; + getResourcesInner.resolves(expectedResponse); + + const result = await Contact.v1.getUuidsPage(remoteContext)(qualifier, cursor, limit); + + expect(result).to.equal(expectedResponse); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/contact/uuid')).to.be.true; + expect(getResourcesInner.calledOnceWithExactly(queryParam)).to.be.true; + }); + + it('returns empty array if docs are not found', async () => { + getResourcesInner.resolves([]); + + const result = await Contact.v1.getUuidsPage(remoteContext)(qualifier, cursor, limit); + + expect(result).to.deep.equal([]); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/contact/uuid')).to.be.true; + expect(getResourcesInner.calledOnceWithExactly(queryParam)).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts b/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts index 7c9b8eabbbc..17dd0cf9930 100644 --- a/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts +++ b/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts @@ -13,7 +13,7 @@ import { DataContext, InvalidArgumentError } from '../../../src'; describe('remote context lib', () => { const context = { url: 'hello.world' } as RemoteDataContext; - let fetchResponse: { ok: boolean, status: number, statusText: string, json: SinonStub }; + let fetchResponse: { ok: boolean, status: number, statusText: string, json: SinonStub, text: SinonStub }; let fetchStub: SinonStub; let loggerError: SinonStub; @@ -22,7 +22,8 @@ describe('remote context lib', () => { ok: true, status: 200, statusText: 'OK', - json: sinon.stub().resolves() + json: sinon.stub().resolves(), + text: sinon.stub().resolves(), }; fetchStub = sinon.stub(global, 'fetch').resolves(fetchResponse as unknown as Response); loggerError = sinon.stub(logger, 'error'); @@ -61,7 +62,6 @@ describe('remote context lib', () => { }); describe('getRemoteDataContext', () => { - [ '', 'hello.world', @@ -75,7 +75,6 @@ describe('remote context lib', () => { }); }); - [ null, 0, @@ -124,11 +123,15 @@ describe('remote context lib', () => { fetchResponse.ok = false; fetchResponse.status = 400; fetchResponse.statusText = errorMsg; + // in case of 400 error which is for when input like query params is invalid + // messages like `Invalid limit` is stored in the text() method + fetchResponse.text.resolves(errorMsg); const expectedError = new InvalidArgumentError(errorMsg); await expect(getResource(context, path)(resourceId)).to.be.rejectedWith(errorMsg); expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}/${resourceId}?`)).to.be.true; + expect(fetchResponse.text.called).to.be.true; expect(fetchResponse.json.notCalled).to.be.true; expect(loggerError.args[0]).to.deep.equal([ `Failed to fetch ${resourceId} from ${context.url}/${path}`, @@ -207,11 +210,15 @@ describe('remote context lib', () => { fetchResponse.ok = false; fetchResponse.status = 400; fetchResponse.statusText = errorMsg; + // in case of 400 error which is for when input like query params is invalid + // messages like `Invalid limit` is stored in the text() method + fetchResponse.text.resolves(errorMsg); const expectedError = new InvalidArgumentError(errorMsg); await expect(getResources(context, path)(params)).to.be.rejectedWith(errorMsg); expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}?${stringifiedParams}`)).to.be.true; + expect(fetchResponse.text.called).to.be.true; expect(fetchResponse.json.notCalled).to.be.true; expect(loggerError.args[0]).to.deep.equal([ `Failed to fetch resources from ${context.url}/${path} with params: ${stringifiedParams}`, diff --git a/shared-libs/cht-datasource/test/remote/report.spec.ts b/shared-libs/cht-datasource/test/remote/report.spec.ts new file mode 100644 index 00000000000..bd57aa51303 --- /dev/null +++ b/shared-libs/cht-datasource/test/remote/report.spec.ts @@ -0,0 +1,85 @@ +import { RemoteDataContext } from '../../src/remote/libs/data-context'; +import sinon, { SinonStub } from 'sinon'; +import * as RemoteEnv from '../../src/remote/libs/data-context'; +import * as Report from '../../src/remote/report'; +import { expect } from 'chai'; + +describe('remote report', () => { + const remoteContext = {} as RemoteDataContext; + let getResourceInner: SinonStub; + let getResourceOuter: SinonStub; + let getResourcesInner: SinonStub; + let getResourcesOuter: SinonStub; + + beforeEach(() => { + getResourceInner = sinon.stub(); + getResourceOuter = sinon.stub(RemoteEnv, 'getResource').returns(getResourceInner); + getResourcesInner = sinon.stub(); + getResourcesOuter = sinon.stub(RemoteEnv, 'getResources').returns(getResourcesInner); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + const identifier = {uuid: 'uuid'} as const; + + describe('get', () => { + it('returns a report by UUID', async () => { + const doc = { type: 'data_record', form: 'yes' }; + getResourceInner.resolves(doc); + + const result = await Report.v1.get(remoteContext)(identifier); + + expect(result).to.equal(doc); + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/report')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getResourceInner.resolves(null); + + const result = await Report.v1.get(remoteContext)(identifier); + + expect(result).to.be.null; + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/report')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + }); + + describe('getUuidsPage', () => { + const limit = 3; + const cursor = '1'; + const freetext = 'report'; + const qualifier = { + freetext + }; + const queryParam = { + limit: limit.toString(), + freetext: freetext, + cursor, + }; + + it('returns an array of report identifiers', async () => { + const doc = [{ type: 'data_record', form: 'yes' }, {type: 'data_record', form: 'yes'}]; + const expectedResponse = { data: doc, cursor }; + getResourcesInner.resolves(expectedResponse); + + const result = await Report.v1.getUuidsPage(remoteContext)(qualifier, cursor, limit); + + expect(result).to.equal(expectedResponse); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/report/uuid')).to.be.true; + expect(getResourcesInner.calledOnceWithExactly(queryParam)).to.be.true; + }); + + it('returns empty array if docs are not found', async () => { + getResourcesInner.resolves([]); + + const result = await Report.v1.getUuidsPage(remoteContext)(qualifier, cursor, limit); + + expect(result).to.deep.equal([]); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/report/uuid')).to.be.true; + expect(getResourcesInner.calledOnceWithExactly(queryParam)).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/report.spec.ts b/shared-libs/cht-datasource/test/report.spec.ts new file mode 100644 index 00000000000..ef2cdfad785 --- /dev/null +++ b/shared-libs/cht-datasource/test/report.spec.ts @@ -0,0 +1,260 @@ +import { DataContext } from '../src'; +import sinon, { SinonStub } from 'sinon'; +import * as Context from '../src/libs/data-context'; +import * as Qualifier from '../src/qualifier'; +import * as Report from '../src/report'; +import { expect } from 'chai'; +import * as Local from '../src/local'; +import * as Remote from '../src/remote'; +import * as Core from '../src/libs/core'; + +describe('report', () => { + const dataContext = { } as DataContext; + let assertDataContext: SinonStub; + let adapt: SinonStub; + let isUuidQualifier: SinonStub; + let isFreetextQualifier: SinonStub; + + beforeEach(() => { + assertDataContext = sinon.stub(Context, 'assertDataContext'); + adapt = sinon.stub(Context, 'adapt'); + isUuidQualifier = sinon.stub(Qualifier, 'isUuidQualifier'); + isFreetextQualifier = sinon.stub(Qualifier, 'isFreetextQualifier'); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + const report = { _id: 'report' } as Report.v1.Report; + const qualifier = { uuid: report._id} as const; + let getReport: SinonStub; + + beforeEach(() => { + getReport = sinon.stub(); + adapt.returns(getReport); + }); + + it('retrieves the report for the given qualifier from the data context', async () => { + isUuidQualifier.returns(true); + getReport.resolves(report); + + const result = await Report.v1.get(dataContext)(qualifier); + + expect(result).to.equal(report); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Report.v1.get, Remote.Report.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getReport.calledOnceWithExactly(qualifier)).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isUuidQualifier.returns(false); + + await expect(Report.v1.get(dataContext)(qualifier)) + .to.be.rejectedWith(`Invalid identifier [${JSON.stringify(qualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Report.v1.get, Remote.Report.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getReport.notCalled).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Report.v1.get(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(isUuidQualifier.notCalled).to.be.true; + expect(getReport.notCalled).to.be.true; + }); + }); + + describe('getUuidsPage', () => { + const ids = ['report1', 'report2', 'report3']; + const cursor = '1'; + const pageData = { data: ids, cursor }; + const limit = 3; + const stringifiedLimit = '3'; + const freetextQualifier = { freetext: 'freetext'} as const; + const invalidFreetextQualifier = { freetext: 'invalid_freetext'} as const; + let getIdsPage: SinonStub; + + beforeEach(() => { + getIdsPage = sinon.stub(); + adapt.returns(getIdsPage); + }); + + it('retrieves report ids from the data context when cursor is null', async () => { + isFreetextQualifier.returns(true); + getIdsPage.resolves(pageData); + + const result = await Report.v1.getUuidsPage(dataContext)(freetextQualifier, null, limit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Report.v1.getUuidsPage, Remote.Report.v1.getUuidsPage) + ).to.be.true; + expect(getIdsPage.calledOnceWithExactly(freetextQualifier, null, limit)).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(freetextQualifier)).to.be.true; + }); + + it('retrieves report ids from the data context when cursor is not null', async () => { + isFreetextQualifier.returns(true); + getIdsPage.resolves(pageData); + + const result = await Report.v1.getUuidsPage(dataContext)(freetextQualifier, cursor, limit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Report.v1.getUuidsPage, Remote.Report.v1.getUuidsPage) + ).to.be.true; + expect(getIdsPage.calledOnceWithExactly(freetextQualifier, cursor, limit)).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(freetextQualifier)).to.be.true; + }); + + it('retrieves report ids from the data context when cursor is not null and ' + + 'limit is stringified number', async () => { + isFreetextQualifier.returns(true); + getIdsPage.resolves(pageData); + + const result = await Report.v1.getUuidsPage(dataContext)(freetextQualifier, cursor, stringifiedLimit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Report.v1.getUuidsPage, Remote.Report.v1.getUuidsPage) + ).to.be.true; + expect(getIdsPage.calledOnceWithExactly(freetextQualifier, cursor, limit)).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(freetextQualifier)).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + isFreetextQualifier.returns(true); + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Report.v1.getUuidsPage(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isFreetextQualifier.returns(false); + + await expect(Report.v1.getUuidsPage(dataContext)(invalidFreetextQualifier, cursor, limit)) + .to.be.rejectedWith(`Invalid freetext [${JSON.stringify(invalidFreetextQualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Report.v1.getUuidsPage, Remote.Report.v1.getUuidsPage) + ).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(invalidFreetextQualifier)).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + }); + + [ + -1, + null, + {}, + '', + 0, + 1.1, + false + ].forEach((limitValue) => { + it(`throws an error if limit is invalid: ${String(limitValue)}`, async () => { + isFreetextQualifier.returns(true); + getIdsPage.resolves(pageData); + + await expect(Report.v1.getUuidsPage(dataContext)(freetextQualifier, cursor, limitValue as number)) + .to.be.rejectedWith(`The limit must be a positive number: [${String(limitValue)}]`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Report.v1.getUuidsPage, Remote.Report.v1.getUuidsPage)) + .to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(freetextQualifier)).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + }); + }); + + [ + {}, + '', + 1, + false, + ].forEach((skipValue) => { + it('throws an error if cursor is invalid', async () => { + isFreetextQualifier.returns(true); + getIdsPage.resolves(pageData); + + await expect(Report.v1.getUuidsPage(dataContext)(freetextQualifier, skipValue as string, limit)) + .to.be.rejectedWith(`Invalid cursor token: [${String(skipValue)}]`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Report.v1.getUuidsPage, Remote.Report.v1.getUuidsPage)) + .to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(freetextQualifier)).to.be.true; + expect(getIdsPage.notCalled).to.be.true; + }); + }); + }); + + describe('getUuids', () => { + const freetextQualifier = { freetext: 'freetext' } as const; + const reportIds = ['report1', 'report2', 'report3']; + const mockGenerator = function* () { + for (const reportId of reportIds) { + yield reportId; + } + }; + + let reportGetIdsPage: sinon.SinonStub; + let getPagedGenerator: sinon.SinonStub; + + beforeEach(() => { + reportGetIdsPage = sinon.stub(Report.v1, 'getUuidsPage'); + dataContext.bind = sinon.stub().returns(reportGetIdsPage); + getPagedGenerator = sinon.stub(Core, 'getPagedGenerator'); + }); + + it('should get report generator with correct parameters', () => { + isFreetextQualifier.returns(true); + getPagedGenerator.returns(mockGenerator); + + const generator = Report.v1.getUuids(dataContext)(freetextQualifier); + + expect(generator).to.deep.equal(mockGenerator); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(getPagedGenerator.calledOnceWithExactly(reportGetIdsPage, freetextQualifier)).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(freetextQualifier)).to.be.true; + }); + + it('should throw an error for invalid datacontext', () => { + const errMsg = 'Invalid data context [null].'; + isFreetextQualifier.returns(true); + assertDataContext.throws(new Error(errMsg)); + + expect(() => Report.v1.getUuids(dataContext)).to.throw(errMsg); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(reportGetIdsPage.notCalled).to.be.true; + expect(isFreetextQualifier.notCalled).to.be.true; + }); + + it('should throw an error for invalid freetext', () => { + isFreetextQualifier.returns(false); + + expect(() => Report.v1.getUuids(dataContext)(freetextQualifier)) + .to.throw(`Invalid freetext [${JSON.stringify(freetextQualifier)}]`); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(reportGetIdsPage.notCalled).to.be.true; + expect(isFreetextQualifier.calledOnceWithExactly(freetextQualifier)).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/contact-types-utils/src/index.d.ts b/shared-libs/contact-types-utils/src/index.d.ts index 1eb73feebcf..856486d26b7 100644 --- a/shared-libs/contact-types-utils/src/index.d.ts +++ b/shared-libs/contact-types-utils/src/index.d.ts @@ -8,6 +8,7 @@ export function getLeafPlaceTypes(config: Record): Record, contact: Record): Record | undefined; export function isPerson(config: Record, contact: Record): boolean; export function isPlace(config: Record, contact: Record): boolean; +export function isContact(config: Record, contact: Record): boolean; export function isHardcodedType(type: string): boolean; export declare const HARDCODED_TYPES: string[]; export function getContactTypes(config?: Record): Record[]; diff --git a/shared-libs/contact-types-utils/src/index.js b/shared-libs/contact-types-utils/src/index.js index 6a829002bcd..42e66423aa0 100644 --- a/shared-libs/contact-types-utils/src/index.js +++ b/shared-libs/contact-types-utils/src/index.js @@ -67,6 +67,10 @@ const isPlace = (config, contact) => { return isPlaceType(type); }; +const isContact = (config, contact) => { + return isPlace(config, contact) || isPerson(config, contact); +}; + const isSameContactType = (contacts) => { const contactTypes = new Set(contacts.map(contact => getTypeId(contact))); return contactTypes.size === 1; @@ -98,6 +102,7 @@ module.exports = { getContactType, isPerson, isPlace, + isContact, isSameContactType, isHardcodedType, HARDCODED_TYPES, diff --git a/shared-libs/contact-types-utils/test/index.js b/shared-libs/contact-types-utils/test/index.js index 6850c28db89..f57aa418b62 100644 --- a/shared-libs/contact-types-utils/test/index.js +++ b/shared-libs/contact-types-utils/test/index.js @@ -367,6 +367,38 @@ describe('ContactType Utils', () => { }); }); + describe('isContact', () => { + it('should return falsy for falsy input', () => { + chai.expect(utils.isContact()).to.equal(false); + chai.expect(utils.isContact(false, false)).to.equal(false); + chai.expect(utils.isContact({}, false)).to.equal(false); + chai.expect(utils.isContact([], false)).to.equal(false); + chai.expect(utils.isContact(settings, 'whaaat')).to.equal(false); + }); + + it('should return falsy for non existent contact types', () => { + chai.expect(utils.isContact(settings, { type: 'other' })).to.equal(false); + chai.expect(utils.isContact(settings, { type: 'contact', contact_type: 'other' })).to.equal(false); + }); + + it('should return true for person types', () => { + chai.expect(utils.isContact({}, { type: 'person' })).to.equal(true); + chai.expect(utils.isContact(settings, { type: personType.id })).to.equal(true); + chai.expect(utils.isContact(settings, { type: patientType.id })).to.equal(true); + chai.expect(utils.isContact(settings, { type: 'contact', contact_type: personType.id })).to.equal(true); + chai.expect(utils.isContact(settings, { type: 'contact', contact_type: patientType.id })).to.equal(true); + }); + + it('should return true for place types', () => { + chai.expect(utils.isContact(settings, { type: districtHospitalType.id })).to.equal(true); + chai.expect(utils.isContact(settings, { type: clinicType.id })).to.equal(true); + chai.expect( + utils.isContact(settings, { type: 'contact', contact_type: districtHospitalType.id }) + ).to.equal(true); + chai.expect(utils.isContact(settings, { type: 'contact', contact_type: clinicType.id })).to.equal(true); + }); + }); + describe('isHardcodedType', () => { it('should return true for hardcoded types', () => { chai.expect(utils.isHardcodedType('district_hospital')).to.equal(true); diff --git a/tests/integration/api/controllers/contact.spec.js b/tests/integration/api/controllers/contact.spec.js new file mode 100644 index 00000000000..f37b57e3ffb --- /dev/null +++ b/tests/integration/api/controllers/contact.spec.js @@ -0,0 +1,558 @@ +const utils = require('@utils'); +const personFactory = require('@factories/cht/contacts/person'); +const placeFactory = require('@factories/cht/contacts/place'); +const userFactory = require('@factories/cht/users/users'); +const {expect} = require('chai'); + +describe('Contact API', () => { + // just a random string to be added to every doc so that it can be used to retrieve all the docs + const commonWord = 'freetext'; + const contact0 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'chw', notes: commonWord })); + const contact1 = utils.deepFreeze(personFactory.build({ + name: 'contact1', + role: 'chw_supervisor', + notes: commonWord + })); + const contact2 = utils.deepFreeze(personFactory.build({ + name: 'contact2', + role: 'program_officer', + notes: commonWord + })); + const placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const place1 = utils.deepFreeze({ + ...placeMap.get('health_center'), + contact: { _id: contact1._id }, + notes: commonWord + }); + const place2 = utils.deepFreeze({ + ...placeMap.get('district_hospital'), + contact: { _id: contact2._id }, + notes: commonWord + }); + const place0 = utils.deepFreeze({ + ...placeMap.get('clinic'), + notes: commonWord, + contact: { _id: contact0._id }, + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + }); + const patient = utils.deepFreeze(personFactory.build({ + parent: { + _id: place0._id, + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + }, + phone: '1234567890', + role: 'patient', + short_name: 'Mary' + })); + const placeType = 'clinic'; + const clinic1 = utils.deepFreeze(placeFactory.place().build({ + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + type: placeType, + contact: {}, + name: 'clinic1' + })); + const clinic2 = utils.deepFreeze(placeFactory.place().build({ + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + type: placeType, + contact: {}, + name: 'clinic2' + })); + + const userNoPerms = utils.deepFreeze(userFactory.build({ + username: 'online-no-perms', + place: place1._id, + contact: { + _id: 'fixture:user:online-no-perms', + name: 'Online User', + }, + roles: ['mm-online'] + })); + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-has-perms', + place: place0._id, + contact: { + _id: 'fixture:user:offline-has-perms', + name: 'Offline User', + }, + roles: ['chw'] + })); + const allDocItems = [contact0, contact1, contact2, place0, place1, place2, clinic1, clinic2, patient]; + const personType = 'person'; + const e2eTestUser = { + '_id': 'e2e_contact_test_id', + 'type': personType, + }; + const onlineUserPlaceHierarchy = { + parent: { + _id: place1._id, + parent: { + _id: place2._id, + } + } + }; + const offlineUserPlaceHierarchy = { + parent: { + _id: place0._id, + ...onlineUserPlaceHierarchy + } + }; + const expectedPeople = [ + contact0, + contact1, + contact2, + patient, + e2eTestUser, + { + type: personType, + ...userNoPerms.contact, + ...onlineUserPlaceHierarchy + }, + { + type: personType, + ...offlineUser.contact, + ...offlineUserPlaceHierarchy + } + ]; + const expectedPeopleIds = expectedPeople.map(person => person._id); + const expectedPlaces = [place0, clinic1, clinic2]; + const expectedPlacesIds = expectedPlaces.map(place => place._id); + + before(async () => { + await utils.saveDocs(allDocItems); + await utils.createUsers([userNoPerms, offlineUser]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([userNoPerms, offlineUser]); + }); + + describe('GET /api/v1/contact/:uuid', async () => { + const endpoint = '/api/v1/contact'; + + it('returns the person contact matching the provided UUID', async () => { + const opts = { + path: `${endpoint}/${patient._id}`, + }; + const person = await utils.request(opts); + expect(person).excluding([ '_rev', 'reported_date' ]).to.deep.equal(patient); + }); + + it('returns the place contact matching the provided UUID', async () => { + const opts = { + path: `${endpoint}/${place0._id}`, + }; + const place = await utils.request(opts); + expect(place).excluding(['_rev', 'reported_date']).to.deep.equal(place0); + }); + + it('returns the person contact with lineage when the withLineage query parameter is provided', async () => { + const opts = { + path: `${endpoint}/${patient._id}?with_lineage=true`, + }; + const person = await utils.request(opts); + expect(person).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equal({ + ...patient, + parent: { + ...place0, + contact: contact0, + parent: { + ...place1, + contact: contact1, + parent: { + ...place2, + contact: contact2 + } + } + } + }); + }); + + it('returns the place contact with lineage when the withLineage query parameter is provided', async () => { + const opts = { + path: `${endpoint}/${place0._id}?with_lineage=true`, + }; + const place = await utils.request(opts); + expect(place).excludingEvery(['_rev', 'reported_date']).to.deep.equal({ + ...place0, + contact: contact0, + parent: { + ...place1, + contact: contact1, + parent: { + ...place2, + contact: contact2 + } + } + }); + }); + + it('throws 404 error when no contact is found for the UUID', async () => { + const opts = { + path: `${endpoint}/invalid-uuid`, + }; + await expect(utils.request(opts)).to.be.rejectedWith('404 - {"code":404,"error":"Contact not found"}'); + }); + + [ + ['does not have can_view_contacts permission', userNoPerms], + ['is not an online user', offlineUser] + ].forEach(([description, user]) => { + it(`throws error when user ${description}`, async () => { + const opts = { + path: `${endpoint}/${patient._id}`, + auth: {username: user.username, password: user.password}, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + }); + }); + + describe('GET /api/v1/contact/uuid', async () => { + const fourLimit = 4; + const threeLimit = 3; + const twoLimit = 2; + const invalidContactType = 'invalidPerson'; + const freetext = 'freetext'; + const placeFreetext = 'clinic'; + const endpoint = '/api/v1/contact/uuid'; + + it('returns a page of people type contact ids for no limit and cursor passed', async () => { + const queryParams = { + type: personType + }; + const stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const responsePage = await utils.request(opts); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPeopleIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of place type contact for no limit and cursor passed', async () => { + const queryParams = { + type: placeType + }; + const stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const responsePage = await utils.request(opts); + const responsePlaces = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePlaces).excludingEvery(['_rev', 'reported_date']) + .to.deep.equalInAnyOrder(expectedPlacesIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of contact ids for freetext with no limit and cursor passed', async () => { + const queryParams = { + freetext + }; + const stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const expectedContactIds = [contact0._id, contact1._id, contact2._id, place0._id, place1._id, place2._id]; + + const responsePage = await utils.request(opts); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedContactIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of people type contact ids and freetext for no limit and cursor passed', async () => { + const queryParams = { + type: personType, + freetext + }; + const stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const expectedContactIds = [contact0._id, contact1._id, contact2._id]; + + const responsePage = await utils.request(opts); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedContactIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of place type contact with freetext for no limit and cursor passed', async () => { + const queryParams = { + type: placeType, + freetext: placeFreetext + }; + const stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const expectedContactIds = [place0._id, clinic1._id, clinic2._id]; + + const responsePage = await utils.request(opts); + const responsePlaces = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePlaces).excludingEvery(['_rev', 'reported_date']) + .to.deep.equalInAnyOrder(expectedContactIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of people type contact ids when limit and cursor is passed and cursor can be reused', + async () => { + // first request + const queryParams = { + type: personType, + limit: fourLimit + }; + let stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const firstPage = await utils.request(opts); + + // second request + queryParams.cursor = firstPage.cursor; + stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts2 = { + path: `${endpoint}?${stringQueryParams}`, + }; + const secondPage = await utils.request(opts2); + + const allData = [...firstPage.data, ...secondPage.data]; + + expect(allData).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPeopleIds); + expect(firstPage.data.length).to.be.equal(4); + expect(secondPage.data.length).to.be.equal(3); + expect(firstPage.cursor).to.be.equal('4'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('returns a page of place type contact ids when limit and cursor is passed and cursor can be reused', + async () => { + // first request + const queryParams = { + type: placeType, + limit: twoLimit + }; + let stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const firstPage = await utils.request(opts); + + // second request + queryParams.cursor = firstPage.cursor; + stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts2 = { + path: `${endpoint}?${stringQueryParams}`, + }; + const secondPage = await utils.request(opts2); + + const allData = [...firstPage.data, ...secondPage.data]; + + expect(allData).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPlacesIds); + expect(firstPage.data.length).to.be.equal(2); + expect(secondPage.data.length).to.be.equal(1); + expect(firstPage.cursor).to.be.equal('2'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('returns a page of contact ids with freetext when limit and cursor is passed and cursor can be reused', + async () => { + // first request + const expectedContactIds = [contact0._id, contact1._id, contact2._id, place0._id, place1._id, place2._id]; + const queryParams = { + freetext, + limit: threeLimit + }; + let stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const firstPage = await utils.request(opts); + + // second request + queryParams.cursor = firstPage.cursor; + stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts2 = { + path: `${endpoint}?${stringQueryParams}`, + }; + const secondPage = await utils.request(opts2); + + const allData = [...firstPage.data, ...secondPage.data]; + + expect(allData).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedContactIds); + expect(firstPage.data.length).to.be.equal(3); + expect(secondPage.data.length).to.be.equal(3); + expect(firstPage.cursor).to.be.equal('3'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('returns a page of people type contact ids with freetext when limit and cursor is passed' + + 'and cursor can be reused', + async () => { + // first request + const expectedContactIds = [contact0._id, contact1._id, contact2._id]; + const queryParams = { + freetext, + type: personType, + limit: twoLimit + }; + let stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const firstPage = await utils.request(opts); + + // second request + queryParams.cursor = firstPage.cursor; + stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts2 = { + path: `${endpoint}?${stringQueryParams}`, + }; + const secondPage = await utils.request(opts2); + + const allData = [...firstPage.data, ...secondPage.data]; + + expect(allData).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedContactIds); + expect(firstPage.data.length).to.be.equal(2); + expect(secondPage.data.length).to.be.equal(1); + expect(firstPage.cursor).to.be.equal('2'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('returns a page of place type contact ids when limit and cursor is passed and cursor can be reused', + async () => { + // first request + const expectedContactIds = [place0._id, clinic1._id, clinic2._id]; + const queryParams = { + freetext: placeFreetext, + type: placeType, + limit: twoLimit + }; + let stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const firstPage = await utils.request(opts); + + // second request + queryParams.cursor = firstPage.cursor; + stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts2 = { + path: `${endpoint}?${stringQueryParams}`, + }; + const secondPage = await utils.request(opts2); + + const allData = [...firstPage.data, ...secondPage.data]; + + expect(allData).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedContactIds); + expect(firstPage.data.length).to.be.equal(2); + expect(secondPage.data.length).to.be.equal(1); + expect(firstPage.cursor).to.be.equal('2'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it(`throws error when user does not have can_view_contacts permission`, async () => { + const opts = { + path: endpoint, + auth: { username: userNoPerms.username, password: userNoPerms.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + + it(`throws error when user is not an online user`, async () => { + const opts = { + path: endpoint, + auth: { username: offlineUser.username, password: offlineUser.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + + it('throws 400 error when contactType is invalid', async () => { + const queryParams = { + type: invalidContactType + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith(`400 - {"code":400,"error":"Invalid contact type [${invalidContactType}]."}`); + }); + + it('should 400 error when freetext is invalid', async () => { + const queryParams = { + freetext: ' ' + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith(`400 - {"code":400,"error":"Invalid freetext [\\" \\"]."}`); + }); + + it('throws 400 error when limit is invalid', async () => { + const queryParams = { + type: personType, + limit: -1 + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith(`400 - {"code":400,"error":"The limit must be a positive number: [${-1}]."}`); + }); + + it('throws 400 error when cursor is invalid', async () => { + const queryParams = { + type: personType, + cursor: '-1' + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith( + `400 - {"code":400,"error":"Invalid cursor token: [${-1}]."}` + ); + }); + }); +}); diff --git a/tests/integration/api/controllers/person.spec.js b/tests/integration/api/controllers/person.spec.js index fa21dc6f22e..04d3056262f 100644 --- a/tests/integration/api/controllers/person.spec.js +++ b/tests/integration/api/controllers/person.spec.js @@ -93,13 +93,24 @@ describe('Person API', () => { }); describe('GET /api/v1/person/:uuid', async () => { + const endpoint = '/api/v1/person'; + it('returns the person matching the provided UUID', async () => { - const person = await utils.request(`/api/v1/person/${patient._id}`); + const opts = { + path: `${endpoint}/${patient._id}`, + }; + const person = await utils.request(opts); expect(person).excluding(['_rev', 'reported_date']).to.deep.equal(patient); }); it('returns the person with lineage when the withLineage query parameter is provided', async () => { - const person = await utils.request({ path: `/api/v1/person/${patient._id}`, qs: { with_lineage: true } }); + const opts = { + path: `${endpoint}/${patient._id}`, + qs: { + with_lineage: true + } + }; + const person = await utils.request(opts); expect(person).excludingEvery(['_rev', 'reported_date']).to.deep.equal({ ...patient, parent: { @@ -117,9 +128,11 @@ describe('Person API', () => { }); }); - it('returns null when no person is found for the UUID', async () => { - await expect(utils.request('/api/v1/person/invalid-uuid')) - .to.be.rejectedWith('404 - {"code":404,"error":"Person not found"}'); + it('throws 404 error when no person is found for the UUID', async () => { + const opts = { + path: `${endpoint}/invalid-uuid`, + }; + await expect(utils.request(opts)).to.be.rejectedWith('404 - {"code":404,"error":"Person not found"}'); }); [ @@ -139,9 +152,16 @@ describe('Person API', () => { describe('GET /api/v1/person', async () => { const limit = 4; const invalidContactType = 'invalidPerson'; + const endpoint = '/api/v1/person'; it('returns a page of people for no limit and cursor passed', async () => { - const responsePage = await utils.request({ path: `/api/v1/person`, qs: { type: personType } }); + const opts = { + path: `${endpoint}`, + qs: { + type: personType + } + }; + const responsePage = await utils.request(opts); const responsePeople = responsePage.data; const responseCursor = responsePage.cursor; @@ -150,9 +170,9 @@ describe('Person API', () => { }); it('returns a page of people when limit and cursor is passed and cursor can be reused', async () => { - const firstPage = await utils.request({ path: `/api/v1/person`, qs: { type: personType, limit } }); + const firstPage = await utils.request({ path: endpoint, qs: { type: personType, limit } }); const secondPage = await utils.request({ - path: `/api/v1/person`, + path: endpoint, qs: { type: personType, cursor: firstPage.cursor, limit } }); @@ -189,7 +209,6 @@ describe('Person API', () => { const opts = { path: `/api/v1/person?${queryString}`, }; - await expect(utils.request(opts)) .to.be.rejectedWith(`400 - {"code":400,"error":"Invalid contact type [${invalidContactType}]."}`); }); @@ -224,19 +243,4 @@ describe('Person API', () => { ); }); }); - - // todo rethink this once datasource works with authentication #9701 - // describe('Person.v1.getAll', async () => { - // it('fetches all data by iterating through generator', async () => { - // const docs = []; - // - // const generator = Person.v1.getAll(dataContext)(Qualifier.byContactType(personType)); - // - // for await (const doc of generator) { - // docs.push(doc); - // } - // - // expect(docs).excluding(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPeople); - // }); - // }); }); diff --git a/tests/integration/api/controllers/place.spec.js b/tests/integration/api/controllers/place.spec.js index 962144d403f..b6355762b41 100644 --- a/tests/integration/api/controllers/place.spec.js +++ b/tests/integration/api/controllers/place.spec.js @@ -73,13 +73,24 @@ describe('Place API', () => { }); describe('GET /api/v1/place/:uuid', async () => { + const endpoint = '/api/v1/place'; + it('returns the place matching the provided UUID', async () => { - const place = await utils.request(`/api/v1/place/${place0._id}`); + const opts = { + path: `${endpoint}/${place0._id}`, + }; + const place = await utils.request(opts); expect(place).excluding(['_rev', 'reported_date']).to.deep.equal(place0); }); it('returns the place with lineage when the withLineage query parameter is provided', async () => { - const place = await utils.request({ path: `/api/v1/place/${place0._id}`, qs: { with_lineage: true } }); + const opts = { + path: `${endpoint}/${place0._id}`, + qs: { + with_lineage: true + } + }; + const place = await utils.request(opts); expect(place).excludingEvery(['_rev', 'reported_date']).to.deep.equal({ ...place0, contact: contact0, @@ -94,9 +105,11 @@ describe('Place API', () => { }); }); - it('returns null when no place is found for the UUID', async () => { - await expect(utils.request('/api/v1/place/invalid-uuid')) - .to.be.rejectedWith('404 - {"code":404,"error":"Place not found"}'); + it('throws 404 error when no place is found for the UUID', async () => { + const opts = { + path: `${endpoint}/invalid-uuid`, + }; + await expect(utils.request(opts)).to.be.rejectedWith('404 - {"code":404,"error":"Place not found"}'); }); [ @@ -116,9 +129,16 @@ describe('Place API', () => { describe('GET /api/v1/place', async () => { const limit = 2; const invalidContactType = 'invalidPlace'; + const endpoint = '/api/v1/place'; it('returns a page of places for no limit and cursor passed', async () => { - const responsePage = await utils.request({ path: `/api/v1/place`, qs: { type: placeType } }); + const opts = { + path: `${endpoint}`, + qs: { + type: placeType + } + }; + const responsePage = await utils.request(opts); const responsePlaces = responsePage.data; const responseCursor = responsePage.cursor; @@ -128,9 +148,9 @@ describe('Place API', () => { }); it('returns a page of places when limit and cursor is passed and cursor can be reused', async () => { - const firstPage = await utils.request({ path: `/api/v1/place`, qs: { type: placeType, limit } }); + const firstPage = await utils.request({ path: endpoint, qs: { type: placeType, limit } }); const secondPage = await utils.request({ - path: `/api/v1/place`, + path: endpoint, qs: { type: placeType, cursor: firstPage.cursor, limit } }); @@ -202,19 +222,4 @@ describe('Place API', () => { ); }); }); - - // todo rethink this once datasource works with authentication #9701 - // describe('Place.v1.getAll', async () => { - // it('fetches all data by iterating through generator', async () => { - // const docs = []; - // - // const generator = Place.v1.getAll(dataContext)(Qualifier.byContactType(placeType)); - // - // for await (const doc of generator) { - // docs.push(doc); - // } - // - // expect(docs).excluding(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPlaces); - // }); - // }); }); diff --git a/tests/integration/api/controllers/report.spec.js b/tests/integration/api/controllers/report.spec.js new file mode 100644 index 00000000000..0b08bab08b9 --- /dev/null +++ b/tests/integration/api/controllers/report.spec.js @@ -0,0 +1,222 @@ +const reportFactory = require('@factories/cht/reports/generic-report'); +const utils = require('@utils'); +const userFactory = require('@factories/cht/users/users'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const {expect} = require('chai'); + +describe('Report API', () => { + const contact0 = utils.deepFreeze(personFactory.build({name: 'contact0', role: 'chw'})); + const contact1 = utils.deepFreeze(personFactory.build({name: 'contact1', role: 'chw_supervisor'})); + const contact2 = utils.deepFreeze(personFactory.build({name: 'contact2', role: 'program_officer'})); + const placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const place1 = utils.deepFreeze({...placeMap.get('health_center'), contact: {_id: contact1._id}}); + const place2 = utils.deepFreeze({...placeMap.get('district_hospital'), contact: {_id: contact2._id}}); + const place0 = utils.deepFreeze({ + ...placeMap.get('clinic'), contact: {_id: contact0._id}, parent: { + _id: place1._id, parent: { + _id: place2._id + } + }, + }); + const patient = utils.deepFreeze(personFactory.build({ + parent: { + _id: place0._id, parent: { + _id: place1._id, parent: { + _id: place2._id + } + }, + }, phone: '1234567890', role: 'patient', short_name: 'Mary' + })); + const report0 = utils.deepFreeze(reportFactory.report().build({ + form: 'report0' + }, { + patient, submitter: contact0 + })); + const report1 = utils.deepFreeze(reportFactory.report().build({ + form: 'report1' + }, { + patient, submitter: contact0 + })); + const report2 = utils.deepFreeze(reportFactory.report().build({ + form: 'report2' + }, { + patient, submitter: contact0 + })); + const report3 = utils.deepFreeze(reportFactory.report().build({ + form: 'report3' + }, { + patient, submitter: contact0 + })); + const report4 = utils.deepFreeze(reportFactory.report().build({ + form: 'report4' + }, { + patient, submitter: contact0 + })); + const report5 = utils.deepFreeze(reportFactory.report().build({ + form: 'report5' + }, { + patient, submitter: contact0 + })); + + const userNoPerms = utils.deepFreeze(userFactory.build({ + username: 'online-no-perms', place: place1._id, contact: { + _id: 'fixture:user:online-no-perms', name: 'Online User', + }, roles: [ 'mm-online' ] + })); + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-has-perms', place: place0._id, contact: { + _id: 'fixture:user:offline-has-perms', name: 'Offline User', + }, roles: [ 'chw' ] + })); + + const allDocItems = [ contact0, contact1, contact2, place0, place1, place2, patient ]; + const allReports = [ report0, report1, report2, report3, report4, report5 ]; + const allReportsIds = allReports.map(report => report._id); + + before(async () => { + await utils.saveDocs(allDocItems); + await utils.saveDocs(allReports); + await utils.createUsers([ userNoPerms, offlineUser ]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([ userNoPerms, offlineUser ]); + }); + + describe('GET /api/v1/report/:uuid', async () => { + const endpoint = '/api/v1/report'; + + it('should return the report matching the provided UUID', async () => { + const opts = { + path: `${endpoint}/${report0._id}`, + }; + const resReport = await utils.request(opts); + expect(resReport).excluding([ '_rev', 'reported_date' ]).to.deep.equal(report0); + }); + + it('throws 404 error when no report is found for the UUID', async () => { + const opts = { + path: `${endpoint}/invalid-uuid`, + }; + await expect(utils.request(opts)).to.be.rejectedWith('404 - {"code":404,"error":"Report not found"}'); + }); + + [ + [ 'does not have can_view_reports permission', userNoPerms ], + [ 'is not an online user', offlineUser ] + ].forEach(([ description, user ]) => { + it(`throws error when user ${description}`, async () => { + const opts = { + path: `/api/v1/report/${patient._id}`, auth: {username: user.username, password: user.password}, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + }); + }); + + describe('GET /api/v1/report/uuid', async () => { + const freetext = 'report'; + const limit = 4; + const endpoint = '/api/v1/report/uuid'; + + it('returns a page of report ids for no limit and cursor passed', async () => { + const queryParams = { + freetext + }; + const stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const responsePage = await utils.request(opts); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(allReportsIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of report ids when limit and cursor is passed and cursor can be reused', async () => { + // first request + const queryParams = { + freetext, + limit + }; + let stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${stringQueryParams}`, + }; + const firstPage = await utils.request(opts); + + // second request + queryParams.cursor = firstPage.cursor; + stringQueryParams = new URLSearchParams(queryParams).toString(); + const opts2 = { + path: `${endpoint}?${stringQueryParams}`, + }; + const secondPage = await utils.request(opts2); + + const allReports = [ ...firstPage.data, ...secondPage.data ]; + + expect(allReports).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(allReportsIds); + expect(firstPage.data.length).to.be.equal(4); + expect(secondPage.data.length).to.be.equal(2); + expect(firstPage.cursor).to.be.equal('4'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it(`throws error when user does not have can_view_reports permission`, async () => { + const opts = { + path: endpoint, auth: {username: userNoPerms.username, password: userNoPerms.password}, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + + it(`throws error when user is not an online user`, async () => { + const opts = { + path: endpoint, auth: {username: offlineUser.username, password: offlineUser.password}, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + + it('throws 400 error when freetext is invalid', async () => { + const queryParams = { + freetext: '' + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith(`400 - {"code":400,"error":"Invalid freetext [\\"\\"]."}`); + }); + + it('throws 400 error when limit is invalid', async () => { + const queryParams = { + freetext, limit: -1 + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith(`400 - {"code":400,"error":"The limit must be a positive number: [${-1}]."}`); + }); + + it('throws 400 error when cursor is invalid', async () => { + const queryParams = { + freetext, cursor: '-1' + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `${endpoint}?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith(`400 - {"code":400,"error":"Invalid cursor token: [${-1}]."}`); + }); + }); +}); diff --git a/tests/integration/shared-libs/cht-datasource/auth.js b/tests/integration/shared-libs/cht-datasource/auth.js new file mode 100644 index 00000000000..4e83886e69a --- /dev/null +++ b/tests/integration/shared-libs/cht-datasource/auth.js @@ -0,0 +1,17 @@ +const { USERNAME, PASSWORD } = require('@constants'); +const initialFetch = global.fetch; + +const setAuth = () => { + const headers = new Headers(); + headers.set('Authorization', 'Basic ' + Buffer.from(`${USERNAME}:${PASSWORD}`).toString('base64')); + global.fetch = (url, options) => initialFetch(url, { headers, ...options, }); +}; + +const removeAuth = () => { + global.fetch = initialFetch; +}; + +module.exports = { + setAuth, + removeAuth, +}; diff --git a/tests/integration/shared-libs/cht-datasource/contact.spec.js b/tests/integration/shared-libs/cht-datasource/contact.spec.js new file mode 100644 index 00000000000..03fcaa9d424 --- /dev/null +++ b/tests/integration/shared-libs/cht-datasource/contact.spec.js @@ -0,0 +1,325 @@ +const utils = require('@utils'); +const personFactory = require('@factories/cht/contacts/person'); +const placeFactory = require('@factories/cht/contacts/place'); +const userFactory = require('@factories/cht/users/users'); +const {getRemoteDataContext, Qualifier, Contact} = require('@medic/cht-datasource'); +const {expect} = require('chai'); +const {setAuth, removeAuth} = require('./auth'); + +describe('cht-datasource Contact', () => { + const contact0 = utils.deepFreeze(personFactory.build({name: 'contact0', role: 'chw'})); + const contact1 = utils.deepFreeze(personFactory.build({name: 'contact1', role: 'chw_supervisor'})); + const contact2 = utils.deepFreeze(personFactory.build({name: 'contact2', role: 'program_officer'})); + const placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const place1 = utils.deepFreeze({...placeMap.get('health_center'), contact: {_id: contact1._id}}); + const place2 = utils.deepFreeze({...placeMap.get('district_hospital'), contact: {_id: contact2._id}}); + const place0 = utils.deepFreeze({ + ...placeMap.get('clinic'), contact: {_id: contact0._id}, parent: { + _id: place1._id, parent: { + _id: place2._id + } + }, + }); + const patient = utils.deepFreeze(personFactory.build({ + parent: { + _id: place0._id, parent: { + _id: place1._id, parent: { + _id: place2._id + } + }, + }, phone: '1234567890', role: 'patient', short_name: 'Mary' + })); + const placeType = 'clinic'; + const clinic1 = utils.deepFreeze(placeFactory.place().build({ + parent: { + _id: place1._id, parent: { + _id: place2._id + } + }, type: placeType, contact: {}, name: 'clinic1' + })); + const clinic2 = utils.deepFreeze(placeFactory.place().build({ + parent: { + _id: place1._id, parent: { + _id: place2._id + } + }, type: placeType, contact: {}, name: 'clinic2' + })); + + const userNoPerms = utils.deepFreeze(userFactory.build({ + username: 'online-no-perms', place: place1._id, contact: { + _id: 'fixture:user:online-no-perms', name: 'Online User', + }, roles: [ 'mm-online' ] + })); + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-has-perms', place: place0._id, contact: { + _id: 'fixture:user:offline-has-perms', name: 'Offline User', + }, roles: [ 'chw' ] + })); + const allDocItems = [ contact0, contact1, contact2, place0, place1, place2, clinic1, clinic2, patient ]; + const dataContext = getRemoteDataContext(utils.getOrigin()); + const personType = 'person'; + const e2eTestUser = { + '_id': 'e2e_contact_test_id', 'type': personType, + }; + const onlineUserPlaceHierarchy = { + parent: { + _id: place1._id, parent: { + _id: place2._id, + } + } + }; + const offlineUserPlaceHierarchy = { + parent: { + _id: place0._id, ...onlineUserPlaceHierarchy + } + }; + const expectedPeople = [ contact0, contact1, contact2, patient, e2eTestUser, { + type: personType, ...userNoPerms.contact, ...onlineUserPlaceHierarchy + }, { + type: personType, ...offlineUser.contact, ...offlineUserPlaceHierarchy + } ]; + const expectedPeopleIds = expectedPeople.map(person => person._id); + const expectedPlaces = [ place0, clinic1, clinic2 ]; + const expectedPlacesIds = expectedPlaces.map(place => place._id); + + before(async () => { + setAuth(); + await utils.saveDocs(allDocItems); + await utils.createUsers([ userNoPerms, offlineUser ]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([ userNoPerms, offlineUser ]); + removeAuth(); + }); + + describe('v1', () => { + describe('get', async () => { + const getContact = Contact.v1.get(dataContext); + const getContactWithLineage = Contact.v1.getWithLineage(dataContext); + + it('returns the person contact matching the provided UUID', async () => { + const person = await getContact(Qualifier.byUuid(patient._id)); + expect(person).excluding([ '_rev', 'reported_date' ]).to.deep.equal(patient); + }); + + it('returns the place contact matching the provided UUID', async () => { + const place = await getContact(Qualifier.byUuid(place0._id)); + expect(place).excluding([ '_rev', 'reported_date' ]).to.deep.equal(place0); + }); + + it('returns the person contact with lineage when the withLineage query parameter is provided', async () => { + const person = await getContactWithLineage(Qualifier.byUuid(patient._id)); + expect(person).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equal({ + ...patient, parent: { + ...place0, contact: contact0, parent: { + ...place1, contact: contact1, parent: { + ...place2, contact: contact2 + } + } + } + }); + }); + + it('returns the place contact with lineage when the withLineage query parameter is provided', async () => { + const place = await getContactWithLineage(Qualifier.byUuid(place0._id)); + expect(place).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equal({ + ...place0, contact: contact0, parent: { + ...place1, contact: contact1, parent: { + ...place2, contact: contact2 + } + } + }); + }); + + it('returns null when no contact is found for the UUID', async () => { + const contact = await getContact(Qualifier.byUuid('invalid-uuid')); + expect(contact).to.be.null; + }); + }); + + describe('getUuidsPage', async () => { + const getUuidsPage = Contact.v1.getUuidsPage(dataContext); + const fourLimit = 4; + const twoLimit = 2; + const cursor = null; + const freetext = 'contact'; + const placeFreetext = 'clinic'; + const invalidLimit = 'invalidLimit'; + const invalidCursor = 'invalidCursor'; + + it('returns a page of people type contact ids for no limit and cursor passed', async () => { + const responsePage = await getUuidsPage(Qualifier.byContactType(personType)); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPeopleIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of place type contact for no limit and cursor passed', async () => { + const responsePage = await getUuidsPage(Qualifier.byContactType(placeType)); + const responsePlaces = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePlaces).excludingEvery([ '_rev', 'reported_date' ]) + .to.deep.equalInAnyOrder(expectedPlacesIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of contact ids for freetext with no limit and cursor passed', async () => { + const expectedContactIds = [ contact0._id, contact1._id, contact2._id ]; + const responsePage = await getUuidsPage(Qualifier.byFreetext(freetext)); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedContactIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of people type contact ids and freetext for no limit and cursor passed', async () => { + const responsePage = await getUuidsPage({ + ...Qualifier.byContactType(personType), ...Qualifier.byFreetext(freetext), + }); + const expectedContactIds = [ contact0._id, contact1._id, contact2._id ]; + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedContactIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of place type contact with freetext for no limit and cursor passed', async () => { + const freetext = 'clinic'; + const responsePage = await getUuidsPage({ + ...Qualifier.byContactType(placeType), ...Qualifier.byFreetext(freetext) + }); + const responsePlaces = responsePage.data; + const responseCursor = responsePage.cursor; + const expectedContactIds = [ place0._id, clinic1._id, clinic2._id ]; + + expect(responsePlaces).excludingEvery([ '_rev', 'reported_date' ]) + .to.deep.equalInAnyOrder(expectedContactIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of people type contact ids' + + ' when limit and cursor is passed and cursor can be reused', async () => { + const firstPage = await getUuidsPage(Qualifier.byContactType(personType), cursor, fourLimit); + const secondPage = await getUuidsPage(Qualifier.byContactType(personType), firstPage.cursor, fourLimit); + + const allData = [ ...firstPage.data, ...secondPage.data ]; + + expect(allData).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPeopleIds); + expect(firstPage.data.length).to.be.equal(4); + expect(secondPage.data.length).to.be.equal(3); + expect(firstPage.cursor).to.be.equal('4'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('returns a page of place type contact ids' + + ' when limit and cursor is passed and cursor can be reused', async () => { + const firstPage = await getUuidsPage(Qualifier.byContactType(placeType), cursor, twoLimit); + const secondPage = await getUuidsPage(Qualifier.byContactType(placeType), firstPage.cursor, twoLimit); + + const allData = [ ...firstPage.data, ...secondPage.data ]; + + expect(allData).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPlacesIds); + expect(firstPage.data.length).to.be.equal(2); + expect(secondPage.data.length).to.be.equal(1); + expect(firstPage.cursor).to.be.equal('2'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('returns a page of contact ids with freetext' + + ' when limit and cursor is passed and cursor can be reused', async () => { + const expectedContactIds = [ contact0._id, contact1._id, contact2._id ]; + const firstPage = await getUuidsPage(Qualifier.byFreetext(freetext), cursor, twoLimit); + const secondPage = await getUuidsPage(Qualifier.byFreetext(freetext), firstPage.cursor, twoLimit); + + const allData = [ ...firstPage.data, ...secondPage.data ]; + + expect(allData).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedContactIds); + expect(firstPage.data.length).to.be.equal(2); + expect(secondPage.data.length).to.be.equal(1); + expect(firstPage.cursor).to.be.equal('2'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('returns a page of people type contact ids with freetext' + + ' when limit and cursor is passed' + 'and cursor can be reused', async () => { + const firstPage = await getUuidsPage({ + ...Qualifier.byContactType(personType), ...Qualifier.byFreetext(freetext), + }, cursor, twoLimit); + const secondPage = await getUuidsPage({ + ...Qualifier.byContactType(personType), ...Qualifier.byFreetext(freetext), + }, firstPage.cursor, twoLimit); + const expectedContactIds = [ contact0._id, contact1._id, contact2._id ]; + + const allData = [ ...firstPage.data, ...secondPage.data ]; + + expect(allData).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedContactIds); + expect(firstPage.data.length).to.be.equal(2); + expect(secondPage.data.length).to.be.equal(1); + expect(firstPage.cursor).to.be.equal('2'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('returns a page of place type contact ids' + + ' when limit and cursor is passed and cursor can be reused', async () => { + const firstPage = await getUuidsPage({ + ...Qualifier.byContactType(placeType), ...Qualifier.byFreetext(placeFreetext), + }, cursor, twoLimit); + const secondPage = await getUuidsPage({ + ...Qualifier.byContactType(placeType), ...Qualifier.byFreetext(placeFreetext), + }, firstPage.cursor, twoLimit); + const expectedContactIds = [ place0._id, clinic1._id, clinic2._id ]; + + const allData = [ ...firstPage.data, ...secondPage.data ]; + + expect(allData).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedContactIds); + expect(firstPage.data.length).to.be.equal(2); + expect(secondPage.data.length).to.be.equal(1); + expect(firstPage.cursor).to.be.equal('2'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('throws error when limit is invalid', async () => { + await expect( + getUuidsPage({ + ...Qualifier.byContactType(placeType), + ...Qualifier.byFreetext(placeFreetext) + }, cursor, invalidLimit) + ).to.be.rejectedWith( + `The limit must be a positive number: [${invalidLimit}].` + ); + }); + + it('throws error when cursor is invalid', async () => { + await expect( + getUuidsPage({ + ...Qualifier.byContactType(placeType), + ...Qualifier.byFreetext(placeFreetext), + }, invalidCursor, twoLimit) + ).to.be.rejectedWith( + `Invalid cursor token: [${invalidCursor}].` + ); + }); + }); + + describe('Contact.v1.getUuids', async () => { + it('fetches all data by iterating through generator', async () => { + const docs = []; + + const generator = Contact.v1.getUuids(dataContext)(Qualifier.byContactType(personType)); + + for await (const doc of generator) { + docs.push(doc); + } + + expect(docs).excluding([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPeopleIds); + }); + }); + }); +}); diff --git a/tests/integration/shared-libs/cht-datasource/person.spec.js b/tests/integration/shared-libs/cht-datasource/person.spec.js new file mode 100644 index 00000000000..fe0beb8fb22 --- /dev/null +++ b/tests/integration/shared-libs/cht-datasource/person.spec.js @@ -0,0 +1,208 @@ +const utils = require('@utils'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const { getRemoteDataContext, Person, Qualifier } = require('@medic/cht-datasource'); +const { expect } = require('chai'); +const userFactory = require('@factories/cht/users/users'); +const {setAuth, removeAuth} = require('./auth'); + +describe('cht-datasource Person', () => { + const contact0 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'chw' })); + const contact1 = utils.deepFreeze(personFactory.build({ name: 'contact1', role: 'chw_supervisor' })); + const contact2 = utils.deepFreeze(personFactory.build({ name: 'contact2', role: 'program_officer' })); + const placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const place0 = utils.deepFreeze({ ...placeMap.get('clinic'), contact: { _id: contact0._id } }); + const place1 = utils.deepFreeze({ ...placeMap.get('health_center'), contact: { _id: contact1._id } }); + const place2 = utils.deepFreeze({ ...placeMap.get('district_hospital'), contact: { _id: contact2._id } }); + + const patient = utils.deepFreeze(personFactory.build({ + parent: { + _id: place0._id, + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + }, + phone: '1234567890', + role: 'patient', + short_name: 'Mary' + })); + const userNoPerms = utils.deepFreeze(userFactory.build({ + username: 'online-no-perms', + place: place1._id, + contact: { + _id: 'fixture:user:online-no-perms', + name: 'Online User', + }, + roles: ['mm-online'] + })); + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-has-perms', + place: place0._id, + contact: { + _id: 'fixture:user:offline-has-perms', + name: 'Offline User', + }, + roles: ['chw'] + })); + const allDocItems = [contact0, contact1, contact2, place0, place1, place2, patient]; + const dataContext = getRemoteDataContext(utils.getOrigin()); + const personType = 'person'; + const e2eTestUser = { + '_id': 'e2e_contact_test_id', + 'type': personType, + }; + const onlineUserPlaceHierarchy = { + parent: { + _id: place1._id, + parent: { + _id: place2._id, + } + } + }; + const offlineUserPlaceHierarchy = { + parent: { + _id: place0._id, + ...onlineUserPlaceHierarchy + } + }; + const expectedPeople = [ + contact0, + contact1, + contact2, + patient, + e2eTestUser, + { + type: personType, + ...userNoPerms.contact, + ...onlineUserPlaceHierarchy + }, + { + type: personType, + ...offlineUser.contact, + ...offlineUserPlaceHierarchy + } + ]; + + before(async () => { + setAuth(); + await utils.saveDocs(allDocItems); + await utils.createUsers([userNoPerms, offlineUser]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([userNoPerms, offlineUser]); + removeAuth(); + }); + + describe('v1', () => { + describe('get', async () => { + const getPerson = Person.v1.get(dataContext); + const getPersonWithLineage = Person.v1.getWithLineage(dataContext); + + it('returns the person matching the provided UUID', async () => { + const person = await getPerson(Qualifier.byUuid(patient._id)); + expect(person).excluding([ '_rev', 'reported_date' ]).to.deep.equal(patient); + }); + + it('returns the person with lineage when the withLineage query parameter is provided', async () => { + const person = await getPersonWithLineage(Qualifier.byUuid(patient._id)); + expect(person).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equal({ + ...patient, + parent: { + ...place0, + contact: contact0, + parent: { + ...place1, + contact: contact1, + parent: { + ...place2, + contact: contact2 + } + } + } + }); + }); + + it('returns null when no person is found for the UUID', async () => { + const person = await getPerson(Qualifier.byUuid('invalid-uuid')); + expect(person).to.be.null; + }); + }); + + describe('getPage', async () => { + const getPage = Person.v1.getPage(dataContext); + const limit = 4; + const stringifiedLimit = '7'; + const cursor = null; + const invalidLimit = 'invalidLimit'; + const invalidCursor = 'invalidCursor'; + + it('returns a page of people for no limit and cursor passed', async () => { + const responsePage = await getPage(Qualifier.byContactType(personType)); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPeople); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of people for stringified limit and null cursor passed', async () => { + const responsePage = await getPage(Qualifier.byContactType(personType), null, stringifiedLimit); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPeople); + expect(responseCursor).to.be.equal('7'); + }); + + it('returns a page of people when limit and cursor is passed and cursor can be reused', async () => { + const firstPage = await getPage(Qualifier.byContactType(personType), cursor, limit); + const secondPage = await getPage(Qualifier.byContactType(personType), firstPage.cursor, limit); + + const allPeople = [ ...firstPage.data, ...secondPage.data ]; + + expect(allPeople).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPeople); + expect(firstPage.data.length).to.be.equal(4); + expect(secondPage.data.length).to.be.equal(3); + expect(firstPage.cursor).to.be.equal('4'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('throws error when limit is invalid', async () => { + await expect( + getPage({...Qualifier.byContactType(personType)}, cursor, invalidLimit) + ).to.be.rejectedWith( + `The limit must be a positive number: [${invalidLimit}].` + ); + }); + + it('throws error when cursor is invalid', async () => { + await expect( + getPage({ + ...Qualifier.byContactType(personType), + }, invalidCursor, limit) + ).to.be.rejectedWith( + `Invalid cursor token: [${invalidCursor}].` + ); + }); + }); + + describe('getAll', async () => { + it('fetches all data by iterating through generator', async () => { + const docs = []; + + const generator = Person.v1.getAll(dataContext)(Qualifier.byContactType(personType)); + + for await (const doc of generator) { + docs.push(doc); + } + + expect(docs).excluding([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPeople); + }); + }); + }); +}); diff --git a/tests/integration/shared-libs/cht-datasource/place.spec.js b/tests/integration/shared-libs/cht-datasource/place.spec.js new file mode 100644 index 00000000000..1a8789b5e54 --- /dev/null +++ b/tests/integration/shared-libs/cht-datasource/place.spec.js @@ -0,0 +1,187 @@ +const utils = require('@utils'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const { getRemoteDataContext, Place, Qualifier } = require('@medic/cht-datasource'); +const { expect } = require('chai'); +const userFactory = require('@factories/cht/users/users'); +const {setAuth, removeAuth} = require('./auth'); + +describe('cht-datasource Place', () => { + const contact0 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'chw' })); + const contact1 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'chw_supervisor' })); + const contact2 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'program_officer' })); + const placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const place1 = utils.deepFreeze({ ...placeMap.get('health_center'), contact: { _id: contact1._id } }); + const place2 = utils.deepFreeze({ ...placeMap.get('district_hospital'), contact: { _id: contact2._id } }); + const place0 = utils.deepFreeze({ + ...placeMap.get('clinic'), + contact: { _id: contact0._id }, + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + }); + const placeType = 'clinic'; + const clinic1 = utils.deepFreeze(placeFactory.place().build({ + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + type: placeType, + contact: {} + })); + const clinic2 = utils.deepFreeze(placeFactory.place().build({ + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + type: placeType, + contact: {} + })); + + const userNoPerms = utils.deepFreeze(userFactory.build({ + username: 'online-no-perms', + place: place1._id, + contact: { + _id: 'fixture:user:online-no-perms', + name: 'Online User', + }, + roles: ['mm-online'] + })); + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-has-perms', + place: place0._id, + contact: { + _id: 'fixture:user:offline-has-perms', + name: 'Offline User', + }, + roles: ['chw'] + })); + const dataContext = getRemoteDataContext(utils.getOrigin()); + const expectedPlaces = [place0, clinic1, clinic2]; + + before(async () => { + setAuth(); + await utils.saveDocs([contact0, contact1, contact2, place0, place1, place2, clinic1, clinic2]); + await utils.createUsers([userNoPerms, offlineUser]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([userNoPerms, offlineUser]); + removeAuth(); + }); + + describe('v1', () => { + describe('get', async () => { + const getPlace = Place.v1.get(dataContext); + const getPlaceWithLineage = Place.v1.getWithLineage(dataContext); + + it('returns the place matching the provided UUID', async () => { + const place = await getPlace(Qualifier.byUuid(place0._id)); + expect(place).excluding([ '_rev', 'reported_date' ]).to.deep.equal(place0); + }); + + it('returns the place with lineage when the withLineage query parameter is provided', async () => { + const place = await getPlaceWithLineage(Qualifier.byUuid(place0._id)); + expect(place).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equal({ + ...place0, + contact: contact0, + parent: { + ...place1, + contact: contact1, + parent: { + ...place2, + contact: contact2 + } + } + }); + }); + + it('returns null when no place is found for the UUID', async () => { + const place = await getPlace(Qualifier.byUuid('invalid-uuid')); + expect(place).to.be.null; + }); + }); + + describe('getPage', async () => { + const getPage = Place.v1.getPage(dataContext); + const limit = 2; + const stringifiedLimit = '3'; + const cursor = null; + const invalidLimit = 'invalidLimit'; + const invalidCursor = 'invalidCursor'; + + it('returns a page of places for no limit and cursor passed', async () => { + const responsePage = await getPage(Qualifier.byContactType(placeType)); + const responsePlaces = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePlaces).excludingEvery([ '_rev', 'reported_date' ]) + .to.deep.equalInAnyOrder(expectedPlaces); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of places for stringified limit and null cursor passed', async () => { + const responsePage = await getPage(Qualifier.byContactType(placeType), null, stringifiedLimit); + const responsePlaces = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePlaces).excludingEvery([ '_rev', 'reported_date' ]) + .to.deep.equalInAnyOrder(expectedPlaces); + expect(responseCursor).to.be.equal('3'); + }); + + it('returns a page of places when limit and cursor is passed and cursor can be reused', async () => { + const firstPage = await getPage(Qualifier.byContactType(placeType), cursor, limit); + const secondPage = await getPage(Qualifier.byContactType(placeType), firstPage.cursor, limit); + + const allPeople = [ ...firstPage.data, ...secondPage.data ]; + + expect(allPeople).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPlaces); + expect(firstPage.data.length).to.be.equal(2); + expect(secondPage.data.length).to.be.equal(1); + expect(firstPage.cursor).to.be.equal('2'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('throws error when limit is invalid', async () => { + await expect( + getPage(Qualifier.byContactType(placeType), cursor, invalidLimit) + ).to.be.rejectedWith( + `The limit must be a positive number: [${invalidLimit}].` + ); + }); + + it('throws error when cursor is invalid', async () => { + await expect( + getPage({ + ...Qualifier.byContactType(placeType), + }, invalidCursor, limit) + ).to.be.rejectedWith( + `Invalid cursor token: [${invalidCursor}].` + ); + }); + }); + + describe('getAll', async () => { + it('fetches all data by iterating through generator', async () => { + const docs = []; + + const generator = Place.v1.getAll(dataContext)(Qualifier.byContactType(placeType)); + + for await (const doc of generator) { + docs.push(doc); + } + + expect(docs).excluding([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(expectedPlaces); + }); + }); + }); +}); diff --git a/tests/integration/shared-libs/cht-datasource/report.spec.js b/tests/integration/shared-libs/cht-datasource/report.spec.js new file mode 100644 index 00000000000..79ec61844be --- /dev/null +++ b/tests/integration/shared-libs/cht-datasource/report.spec.js @@ -0,0 +1,170 @@ +const reportFactory = require('@factories/cht/reports/generic-report'); +const utils = require('@utils'); +const userFactory = require('@factories/cht/users/users'); +const {getRemoteDataContext, Report, Qualifier} = require('@medic/cht-datasource'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const {expect} = require('chai'); +const {setAuth, removeAuth} = require('./auth'); + +describe('cht-datasource Report', () => { + const contact0 = utils.deepFreeze(personFactory.build({name: 'contact0', role: 'chw'})); + const contact1 = utils.deepFreeze(personFactory.build({name: 'contact1', role: 'chw_supervisor'})); + const contact2 = utils.deepFreeze(personFactory.build({name: 'contact2', role: 'program_officer'})); + const placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const place1 = utils.deepFreeze({...placeMap.get('health_center'), contact: {_id: contact1._id}}); + const place2 = utils.deepFreeze({...placeMap.get('district_hospital'), contact: {_id: contact2._id}}); + const place0 = utils.deepFreeze({ + ...placeMap.get('clinic'), contact: {_id: contact0._id}, parent: { + _id: place1._id, parent: { + _id: place2._id + } + }, + }); + const patient = utils.deepFreeze(personFactory.build({ + parent: { + _id: place0._id, parent: { + _id: place1._id, parent: { + _id: place2._id + } + }, + }, phone: '1234567890', role: 'patient', short_name: 'Mary' + })); + const report0 = utils.deepFreeze(reportFactory.report().build({ + form: 'report0' + }, { + patient, submitter: contact0 + })); + const report1 = utils.deepFreeze(reportFactory.report().build({ + form: 'report1' + }, { + patient, submitter: contact0 + })); + const report2 = utils.deepFreeze(reportFactory.report().build({ + form: 'report2' + }, { + patient, submitter: contact0 + })); + const report3 = utils.deepFreeze(reportFactory.report().build({ + form: 'report3' + }, { + patient, submitter: contact0 + })); + const report4 = utils.deepFreeze(reportFactory.report().build({ + form: 'report4' + }, { + patient, submitter: contact0 + })); + const report5 = utils.deepFreeze(reportFactory.report().build({ + form: 'report5' + }, { + patient, submitter: contact0 + })); + + const userNoPerms = utils.deepFreeze(userFactory.build({ + username: 'online-no-perms', place: place1._id, contact: { + _id: 'fixture:user:online-no-perms', name: 'Online User', + }, roles: [ 'mm-online' ] + })); + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-has-perms', place: place0._id, contact: { + _id: 'fixture:user:offline-has-perms', name: 'Offline User', + }, roles: [ 'chw' ] + })); + + const allDocItems = [ contact0, contact1, contact2, place0, place1, place2, patient ]; + const allReports = [ report0, report1, report2, report3, report4, report5 ]; + const allReportsIds = allReports.map(report => report._id); + const dataContext = getRemoteDataContext(utils.getOrigin()); + + before(async () => { + setAuth(); + await utils.saveDocs(allDocItems); + await utils.saveDocs(allReports); + await utils.createUsers([ userNoPerms, offlineUser ]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([ userNoPerms, offlineUser ]); + removeAuth(); + }); + + describe('v1', () => { + describe('get', async () => { + const getReport = Report.v1.get(dataContext); + + it('should return the report matching the provided UUID', async () => { + const resReport = await getReport(Qualifier.byUuid(report0._id)); + expect(resReport).excluding([ '_rev', 'reported_date' ]).to.deep.equal(report0); + }); + + it('returns null when no report is found for the UUID', async () => { + const report = await getReport(Qualifier.byUuid('invalid-uuid')); + expect(report).to.be.null; + }); + }); + + describe('getUuidsPage', async () => { + const getUuidsPage = Report.v1.getUuidsPage(dataContext); + const freetext = 'report'; + const limit = 4; + const cursor = null; + const invalidLimit = 'invalidLimit'; + const invalidCursor = 'invalidCursor'; + + it('returns a page of report ids for no limit and cursor passed', async () => { + const responsePage = await getUuidsPage(Qualifier.byFreetext(freetext)); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(allReportsIds); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of report ids when limit and cursor is passed and cursor can be reused', async () => { + const firstPage = await getUuidsPage(Qualifier.byFreetext(freetext), cursor, limit); + const secondPage = await getUuidsPage(Qualifier.byFreetext(freetext), firstPage.cursor, limit); + + const allReports = [ ...firstPage.data, ...secondPage.data ]; + + expect(allReports).excludingEvery([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(allReportsIds); + expect(firstPage.data.length).to.be.equal(4); + expect(secondPage.data.length).to.be.equal(2); + expect(firstPage.cursor).to.be.equal('4'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it('throws error when limit is invalid', async () => { + await expect( + getUuidsPage(Qualifier.byFreetext(freetext), cursor, invalidLimit) + ).to.be.rejectedWith( + `The limit must be a positive number: [${invalidLimit}].` + ); + }); + + it('throws error when cursor is invalid', async () => { + await expect( + getUuidsPage(Qualifier.byFreetext(freetext), invalidCursor, limit) + ).to.be.rejectedWith( + `Invalid cursor token: [${invalidCursor}].` + ); + }); + }); + + describe('getUuids', async () => { + it('fetches all data by iterating through generator', async () => { + const freetext = 'report'; + const docs = []; + + const generator = Report.v1.getUuids(dataContext)(Qualifier.byFreetext(freetext)); + + for await (const doc of generator) { + docs.push(doc); + } + + expect(docs).excluding([ '_rev', 'reported_date' ]).to.deep.equalInAnyOrder(allReportsIds); + }); + }); + }); +});