From df9bbc65b1eb403310a1aac806324565b3ea6cb3 Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Mon, 13 Jul 2026 22:30:39 -0600 Subject: [PATCH 1/3] feat(#10973): add phone qualifer --- admin/src/js/services/message-queue.js | 30 ++- .../tests/unit/services/message-queue.spec.js | 64 +++--- api/src/controllers/contact.js | 75 ++++-- api/tests/mocha/controllers/contact.spec.js | 68 +++++- shared-libs/cht-datasource/src/contact.ts | 85 ++++++- .../src/libs/parameter-validators.ts | 22 +- .../cht-datasource/src/local/contact.ts | 35 ++- shared-libs/cht-datasource/src/qualifier.ts | 28 +++ .../cht-datasource/src/remote/contact.ts | 14 +- .../cht-datasource/test/contact.spec.ts | 168 +++++++++++++- .../test/libs/parameter-validators.spec.ts | 8 +- .../cht-datasource/test/local/contact.spec.ts | 84 ++++++- .../cht-datasource/test/qualifier.spec.ts | 41 ++++ .../test/remote/contact.spec.ts | 32 +++ .../src/transitions/registration.js | 8 +- .../src/transitions/self_report.js | 11 +- .../src/transitions/update_clinics.js | 16 +- .../src/transitions/update_sent_by.js | 14 +- .../test/integration/transitions.js | 43 ++-- .../test/unit/transitions/registration.js | 214 ++++++------------ .../test/unit/transitions/self_report.js | 105 ++++----- .../test/unit/transitions/update_clinics.js | 44 ++-- .../test/unit/transitions/update_sent_by.js | 14 +- .../validation/src/validation_utils.js | 13 +- shared-libs/validation/test/validations.js | 28 +-- webapp/src/js/enketo/widgets/phone-widget.js | 12 +- .../ts/services/integration-api.service.ts | 4 + .../js/enketo/widgets/phone-widget.spec.ts | 42 ++-- 28 files changed, 903 insertions(+), 419 deletions(-) diff --git a/admin/src/js/services/message-queue.js b/admin/src/js/services/message-queue.js index dd4b2c37353..676232e421d 100644 --- a/admin/src/js/services/message-queue.js +++ b/admin/src/js/services/message-queue.js @@ -26,6 +26,7 @@ angular.module('services').factory('MessageQueue', $q, $translate, DB, + DataContext, GetSummaries, Languages, MessageQueueUtils, @@ -35,6 +36,20 @@ angular.module('services').factory('MessageQueue', 'use strict'; 'ngInject'; + const collect = function(generator) { + const results = []; + const iterate = function() { + return generator.next().then(function(result) { + if (result.done) { + return results; + } + results.push(result.value); + return iterate(); + }); + }; + return iterate(); + }; + const findSummary = function(summaries, message) { if (!message.sms || !message.sms.to) { return; @@ -93,12 +108,15 @@ angular.module('services').factory('MessageQueue', return messages; } - return DB({ remote: true }) - .query('medic-client/contacts_by_phone', { keys: phoneNumbers }) - .then(function(contactsByPhone) { - const ids = contactsByPhone.rows.map(function(row) { - return row.id; - }); + return DataContext + .then(function(dataContext) { + const contact = dataContext.getDatasource().v1.contact; + return $q.all(phoneNumbers.map(function(phone) { + return collect(contact.getUuidsByPhone(String(phone))); + })); + }) + .then(function(idsPerPhone) { + const ids = compactUnique([].concat.apply([], idsPerPhone)); return GetSummaries.getContacts(ids); }) diff --git a/admin/tests/unit/services/message-queue.spec.js b/admin/tests/unit/services/message-queue.spec.js index 93dc955bff2..40b6d79ec3d 100644 --- a/admin/tests/unit/services/message-queue.spec.js +++ b/admin/tests/unit/services/message-queue.spec.js @@ -10,14 +10,22 @@ describe('MessageQueue service', function() { let utils; let query; let GetSummaries; + let getUuidsByPhone; let translate; let clock; + const asyncGeneratorOf = (ids) => (async function* () { + for (const id of ids) { + yield id; + } + })(); + beforeEach(() => { Settings = sinon.stub(); Languages = sinon.stub(); query = sinon.stub(); GetSummaries = sinon.stub(); + getUuidsByPhone = sinon.stub().returns(asyncGeneratorOf([])); translate = sinon.stub(); translate.instant = sinon.stub(); translate.storageKey = sinon.stub(); @@ -46,6 +54,9 @@ describe('MessageQueue service', function() { $provide.value('Languages', Languages); $provide.value('MessageQueueUtils', utils); $provide.value('GetSummaries', { getContacts: GetSummaries }); + $provide.value('DataContext', Q.resolve({ + getDatasource: () => ({ v1: { contact: { getUuidsByPhone } } }) + })); $provide.factory('DB', KarmaUtils.mockDB({ query: query })); }); @@ -288,9 +299,8 @@ describe('MessageQueue service', function() { .withArgs('medic-admin/message_queue', sinon.match({ reduce: false })) .resolves({ rows: messages }); - query.withArgs('medic-client/contacts_by_phone').resolves({ - rows: [{ id: 'contact1', value: 'contact1', key: 'phone1' }] - }); + getUuidsByPhone.withArgs('phone1').returns(asyncGeneratorOf(['contact1'])); + getUuidsByPhone.withArgs('phone2').returns(asyncGeneratorOf([])); GetSummaries .withArgs(['contact1']) @@ -306,12 +316,9 @@ describe('MessageQueue service', function() { return service.query('due').then(result => { chai.expect(result.total).to.equal(2); - chai.expect(query.callCount).to.equal(3); + chai.expect(query.callCount).to.equal(2); - chai.expect(query.args[2]).to.deep.equal([ - 'medic-client/contacts_by_phone', - { keys: ['phone1', 'phone2'] } - ]); + chai.expect(getUuidsByPhone.args).to.deep.equal([['phone1'], ['phone2']]); chai.expect(GetSummaries.callCount).to.equal(1); chai.expect(GetSummaries.args[0][0]).to.deep.equal(['contact1']); @@ -409,9 +416,8 @@ describe('MessageQueue service', function() { .withArgs('medic-admin/message_queue', sinon.match({ reduce: false })) .resolves({ rows: messages }); - query - .withArgs('medic-client/contacts_by_phone') - .resolves({ rows: [ { value: 'contact1', key: 'phone1' }, { value: 'contact2', key: 'phone2' } ] }); + getUuidsByPhone.withArgs('phone1').returns(asyncGeneratorOf(['contact1'])); + getUuidsByPhone.withArgs('phone2').returns(asyncGeneratorOf(['contact2'])); GetSummaries.resolves([ { _id: 'contact1', type: 'person', name: 'contact one', phone: 'phone1' }, @@ -419,10 +425,8 @@ describe('MessageQueue service', function() { ]); return service.query('due').then(result => { - chai.expect(query.callCount).to.equal(3); - chai.expect(query.args[2]).to.deep.equal([ - 'medic-client/contacts_by_phone', { keys: [ 'phone1', 'phone2' ]} - ]); + chai.expect(query.callCount).to.equal(2); + chai.expect(getUuidsByPhone.args).to.deep.equal([['phone1'], ['phone2']]); chai.expect(result.messages[0].recipient).to.equal('contact one'); chai.expect(result.messages[1].recipient).to.equal('contact one'); @@ -584,14 +588,12 @@ describe('MessageQueue service', function() { to: recipient }])); - query - .withArgs('medic-client/contacts_by_phone') - .resolves({ rows: [{ key: 'recipient_id', value: 'recipient' }]}); + getUuidsByPhone.withArgs('recipient').returns(asyncGeneratorOf(['recipient_id'])); GetSummaries.resolves([{ _id: 'recipient_id', type: 'person', phone: 'recipient' }]); return service.query('tab').then(result => { chai.expect(result.messages.length).to.equal(15); - chai.expect(query.callCount).to.equal(5); + chai.expect(query.callCount).to.equal(4); chai.expect(query.args[2]).to.deep.equal([ 'medic-client/contacts_by_reference', { @@ -615,10 +617,7 @@ describe('MessageQueue service', function() { [ 'patient1', 'patient2', 'patient3', 'place1', 'place2', 'place3' ], ]); - chai.expect(query.args[4]).to.deep.equal([ - 'medic-client/contacts_by_phone', - { keys: [ 'recipient' ]} - ]); + chai.expect(getUuidsByPhone.args).to.deep.equal([['recipient']]); }); }); @@ -749,13 +748,9 @@ describe('MessageQueue service', function() { .withArgs(sinon.match({ type: 'valid' })).returns(true) .withArgs(sinon.match({ type: 'invalid' })).returns(false); - query - .withArgs('medic-client/contacts_by_phone') - .resolves({ rows: [ - { key: 'recipient1', id: 'recipient1_id' }, - { key: 'recipient2', id: 'recipient2_id' }, - { key: 'recipient3', id: 'recipient3_id' } - ]}); + getUuidsByPhone.withArgs('recipient1').returns(asyncGeneratorOf(['recipient1_id'])); + getUuidsByPhone.withArgs('recipient2').returns(asyncGeneratorOf(['recipient2_id'])); + getUuidsByPhone.withArgs('recipient3').returns(asyncGeneratorOf(['recipient3_id'])); GetSummaries.resolves([ { _id: 'recipient1_id', type: 'person', phone: 'recipient1', name: 'recipient 1' }, { _id: 'recipient2_id', type: 'person', phone: 'recipient2', name: 'recipient 2' }, @@ -924,10 +919,7 @@ describe('MessageQueue service', function() { } ]); - chai.expect(query.withArgs('medic-client/contacts_by_phone').callCount).to.equal(1); - chai.expect(query.withArgs('medic-client/contacts_by_phone').args[0][1]).to.deep.equal({ - keys: ['recipient1', 'recipient2', 'recipient3'] - }); + chai.expect(getUuidsByPhone.args).to.deep.equal([['recipient1'], ['recipient2'], ['recipient3']]); chai.expect(result.messages).to.deep.equal([ { @@ -1069,9 +1061,7 @@ describe('MessageQueue service', function() { .withArgs(sinon.match({ type: 'valid' })).returns(true) .withArgs(sinon.match({ type: 'invalid' })).returns(false); - query - .withArgs('medic-client/contacts_by_phone') - .resolves({ rows: [{ key: 'recipient1', id: 'recipien_id' }]}); + getUuidsByPhone.withArgs('recipient1').returns(asyncGeneratorOf(['recipien_id'])); GetSummaries.resolves([ { _id: 'recipien_id', type: 'person', phone: 'recipient1', name: 'recipient' }, ]); diff --git a/api/src/controllers/contact.js b/api/src/controllers/contact.js index f39ad127373..53a3573bfba 100644 --- a/api/src/controllers/contact.js +++ b/api/src/controllers/contact.js @@ -81,7 +81,8 @@ module.exports = { * operationId: v1ContactUuidGet * description: > * Returns a paginated array of contact identifier strings matching the given filter criteria. - * At least one of `type` or `freetext` must be provided. + * At least one of `type`, `freetext`, or `phone` must be provided. When `phone` is provided it is used + * on its own (it cannot be combined with `type` or `freetext`) and takes precedence over them. * tags: [Contact] * x-since: 4.18.0 * x-permissions: @@ -92,8 +93,8 @@ module.exports = { * schema: * type: string * description: > - * The contact_type id for the type of contacts to fetch. Required if `freetext` is not provided - * and may be combined with `freetext`. + * The contact_type id for the type of contacts to fetch. Required if `freetext` and `phone` are not + * provided and may be combined with `freetext`. * - in: query * name: freetext * schema: @@ -101,7 +102,14 @@ module.exports = { * minLength: 3 * description: > * A search term for filtering contacts. Must be at least 3 characters and not contain whitespace. - * Required if `type` is not provided and may be combined with `type`. + * Required if `type` and `phone` are not provided and may be combined with `type`. + * - in: query + * name: phone + * schema: + * type: string + * description: > + * A phone number to fetch matching contacts by. Matched verbatim (no normalisation). Required if + * `type` and `freetext` are not provided. Takes precedence over `type` and `freetext`. * - $ref: '#/components/parameters/cursor' * - $ref: '#/components/parameters/limitId' * responses: @@ -129,15 +137,22 @@ module.exports = { */ getUuids: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); - 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.freetext && !req.query.type && !req.query.phone) { + return serverUtils.error( + { status: 400, message: 'Either query param freetext, type or phone is required' }, req, res + ); } - if (req.query.type) { - Object.assign(qualifier, Qualifier.byContactType(req.query.type)); + let qualifier; + if (req.query.phone) { + qualifier = Qualifier.byPhone(req.query.phone); + } else { + 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); @@ -151,8 +166,9 @@ module.exports = { * operationId: v1ContactGet * description: > * Returns a paginated array of contact records (persons and places) matching the given filter criteria. - * At least one of `ids` or `type` must be provided. If both are provided, `ids` takes precedence over - * `type`. Use the `cursor` returned in each response to retrieve subsequent pages. + * At least one of `ids`, `type`, or `phone` must be provided. When more than one is provided the + * precedence is `ids`, then `phone`, then `type`. Use the `cursor` returned in each response to retrieve + * subsequent pages. * tags: [Contact] * x-since: 5.3.0 * x-permissions: @@ -163,14 +179,22 @@ module.exports = { * schema: * type: string * description: > - * A comma-separated list of contact ids to fetch. Required if `type` is not provided. Takes - * precedence over `type` when both are provided. + * A comma-separated list of contact ids to fetch. Required if `type` and `phone` are not provided. + * Takes precedence over `phone` and `type` when more than one is provided. * - in: query * name: type * schema: * type: string * description: > - * The contact_type id for the type of contacts to fetch. Required if `ids` is not provided. + * The contact_type id for the type of contacts to fetch. Required if `ids` and `phone` are not + * provided. + * - in: query + * name: phone + * schema: + * type: string + * description: > + * A phone number to fetch matching contacts by. Matched verbatim (no normalisation). Required if + * `ids` and `type` are not provided. Takes precedence over `type`. * - $ref: '#/components/parameters/cursor' * - $ref: '#/components/parameters/limitEntity' * responses: @@ -198,12 +222,19 @@ module.exports = { */ getAll: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); - if (!req.query.ids && !req.query.type) { - return serverUtils.error({ status: 400, message: 'Either query param ids or type is required' }, req, res); + if (!req.query.ids && !req.query.type && !req.query.phone) { + return serverUtils.error( + { status: 400, message: 'Either query param ids, type or phone is required' }, req, res + ); + } + let qualifier; + if (req.query.ids) { + qualifier = buildIdsQualifier(req.query.ids); + } else if (req.query.phone) { + qualifier = Qualifier.byPhone(req.query.phone); + } else { + qualifier = Qualifier.byContactType(req.query.type); } - const qualifier = req.query.ids - ? buildIdsQualifier(req.query.ids) - : Qualifier.byContactType(req.query.type); const docs = await getContactDocs(qualifier, req.query.cursor, req.query.limit); return res.json(docs); }), diff --git a/api/tests/mocha/controllers/contact.spec.js b/api/tests/mocha/controllers/contact.spec.js index 3f5133ae78f..4d13e748b4a 100644 --- a/api/tests/mocha/controllers/contact.spec.js +++ b/api/tests/mocha/controllers/contact.spec.js @@ -241,7 +241,7 @@ describe('Contact Controller', () => { limit, } }; - const err = { status: 400, message: 'Either query param freetext or type is required' }; + const err = { status: 400, message: 'Either query param freetext, type or phone is required' }; contactGetUuidsPage.throws(err); await controller.v1.getUuids(req, res); @@ -256,6 +256,38 @@ describe('Contact Controller', () => { expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; }); + + it('returns a page of contact ids for the phone param', async () => { + const phone = '+254712345678'; + const phoneQualifier = { phone }; + const qualifierByPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + req = { query: { phone, cursor, limit } }; + contactGetUuidsPage.resolves(contacts); + + await controller.v1.getUuids(req, res); + + expect(qualifierByPhone.calledOnceWithExactly(phone)).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(phoneQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(contacts)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('uses the phone param on its own, ignoring type and freetext', async () => { + const phone = '+254712345678'; + const phoneQualifier = { phone }; + const qualifierByPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + req = { query: { phone, type: contactType, freetext, cursor, limit } }; + contactGetUuidsPage.resolves(contacts); + + await controller.v1.getUuids(req, res); + + expect(qualifierByPhone.calledOnceWithExactly(phone)).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(phoneQualifier, cursor, limit)).to.be.true; + }); }); describe('getAll', () => { @@ -309,7 +341,37 @@ describe('Contact Controller', () => { expect(contactGetPage.calledOnceWithExactly(idsQualifier, cursor, limit)).to.be.true; }); - it('returns a 400 error when neither ids nor type is provided', async () => { + it('returns a page of contacts for the given phone', async () => { + const phone = '+254712345678'; + const phoneQualifier = { phone }; + const qualifierByPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + req = { query: { phone, cursor, limit } }; + contactGetPage.resolves(contacts); + + await controller.v1.getAll(req, res); + + expect(qualifierByPhone.calledOnceWithExactly(phone)).to.be.true; + expect(contactGetPage.calledOnceWithExactly(phoneQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(contacts)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('prefers phone over type when both are provided', async () => { + const phone = '+254712345678'; + const phoneQualifier = { phone }; + const qualifierByPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + const qualifierByContactType = sinon.stub(Qualifier, 'byContactType'); + req = { query: { phone, type: 'person', cursor, limit } }; + contactGetPage.resolves(contacts); + + await controller.v1.getAll(req, res); + + expect(qualifierByPhone.calledOnceWithExactly(phone)).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(contactGetPage.calledOnceWithExactly(phoneQualifier, cursor, limit)).to.be.true; + }); + + it('returns a 400 error when neither ids, type nor phone is provided', async () => { req = { query: { cursor, limit } }; await controller.v1.getAll(req, res); @@ -317,7 +379,7 @@ describe('Contact Controller', () => { expect(contactGetPage.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; expect(serverUtilsError.calledOnceWithExactly( - { status: 400, message: 'Either query param ids or type is required' }, + { status: 400, message: 'Either query param ids, type or phone is required' }, req, res )).to.be.true; diff --git a/shared-libs/cht-datasource/src/contact.ts b/shared-libs/cht-datasource/src/contact.ts index 3ddbaed7915..63073fe2b71 100644 --- a/shared-libs/cht-datasource/src/contact.ts +++ b/shared-libs/cht-datasource/src/contact.ts @@ -9,10 +9,12 @@ import { byContactType, byFreetext, byIds, + byPhone, byUuid, ContactTypeQualifier, FreetextQualifier, IdsQualifier, + PhoneQualifier, UuidQualifier } from './qualifier'; import { adapt, assertDataContext, DataContext } from './libs/data-context'; @@ -175,7 +177,8 @@ export namespace v1 { /** * Returns an array of contact identifiers for the provided page specifications. - * @param qualifier the limiter defining which identifiers to return + * @param qualifier the limiter defining which identifiers to return (a contact type and/or freetext qualifier, + * or a phone qualifier - e.g. `{ phone: '+1234' }`) * @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. @@ -185,7 +188,7 @@ export namespace v1 { * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` */ const curriedFn = async ( - qualifier: ContactTypeQualifier | FreetextQualifier, + qualifier: ContactTypeQualifier | FreetextQualifier | PhoneQualifier, cursor: Nullable = null, limit: number | `${number}` = DEFAULT_IDS_PAGE_LIMIT ): Promise> => { @@ -215,7 +218,7 @@ export namespace v1 { * @throws InvalidArgumentError if no qualifier is provided or if the qualifier is invalid */ const curriedGen = ( - qualifier: ContactTypeQualifier | FreetextQualifier + qualifier: ContactTypeQualifier | FreetextQualifier | PhoneQualifier ): AsyncGenerator => { assertContactTypeFreetextQualifier(qualifier); @@ -237,7 +240,8 @@ export namespace v1 { /** * Returns an array of contacts for the provided page specifications. - * @param qualifier the limiter defining which contacts to return (a contact type or a set of UUIDs) + * @param qualifier the limiter defining which contacts to return (a contact type, a set of UUIDs, or a phone + * qualifier - e.g. `{ phone: '+1234' }`) * @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 contacts to return. Default is 100. @@ -247,7 +251,7 @@ export namespace v1 { * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` */ const curriedFn = async ( - qualifier: ContactTypeQualifier | IdsQualifier, + qualifier: ContactTypeQualifier | IdsQualifier | PhoneQualifier, cursor: Nullable = null, limit: number | `${number}` = DEFAULT_DOCS_PAGE_LIMIT ): Promise> => { @@ -272,12 +276,13 @@ export namespace v1 { /** * Returns a generator for fetching all contacts that match the given qualifier. - * @param qualifier the limiter defining which contacts to return (a contact type or a set of UUIDs) + * @param qualifier the limiter defining which contacts to return (a contact type, a set of UUIDs, or a phone + * qualifier - e.g. `{ phone: '+1234' }`) * @returns a generator for fetching all contacts that match the given qualifier * @throws InvalidArgumentError if no qualifier is provided or if the qualifier is invalid */ const curriedGen = ( - qualifier: ContactTypeQualifier | IdsQualifier + qualifier: ContactTypeQualifier | IdsQualifier | PhoneQualifier ): AsyncGenerator => { assertContactTypeIdsQualifier(qualifier); @@ -417,6 +422,60 @@ export namespace v1 { */ getUuidsByFreetext: (freetext: string) => AsyncGenerator; + /** + * Returns a page of contact identifiers for the given phone number. The phone number is matched verbatim (no + * normalisation is performed). + * @param phone the phone number of the contacts to fetch + * @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 phone number + * @throws InvalidArgumentError if `phone` is not a non-empty string + * @throws InvalidArgumentError if the provided limit is `<= 0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + getUuidsPageByPhone: ( + phone: string, + cursor?: Nullable, + limit?: number | `${number}` + ) => Promise>; + + /** + * Returns a generator for fetching all the contact identifiers for the given phone number. The phone number is + * matched verbatim (no normalisation is performed). + * @param phone the phone number of the contacts to fetch + * @returns a generator for fetching all the contact identifiers matching the given phone number + * @throws InvalidArgumentError if `phone` is not a non-empty string + */ + getUuidsByPhone: (phone: string) => AsyncGenerator; + + /** + * Returns a page of contacts for the given phone number. The phone number is matched verbatim (no normalisation + * is performed). + * @param phone the phone number of the contacts to fetch + * @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 contacts to return. Default is 100. + * @returns a page of contacts for the provided phone number + * @throws InvalidArgumentError if `phone` is not a non-empty string + * @throws InvalidArgumentError if the provided limit is `<= 0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + getPageByPhone: ( + phone: string, + cursor?: Nullable, + limit?: number | `${number}` + ) => Promise>; + + /** + * Returns a generator for fetching all contacts with the given phone number. The phone number is matched + * verbatim (no normalisation is performed). + * @param phone the phone number of the contacts to fetch + * @returns a generator for fetching all contacts matching the given phone number + * @throws InvalidArgumentError if `phone` is not a non-empty string + */ + getByPhone: (phone: string) => AsyncGenerator; + /** * Returns a page of contacts for the given type. * @param type the type of contacts to return @@ -500,6 +559,18 @@ export namespace v1 { ), getUuidsByType: (type) => ctx.bind(v1.getUuids)(byContactType(type)), getUuidsByFreetext: (freetext) => ctx.bind(v1.getUuids)(byFreetext(freetext)), + getUuidsPageByPhone: ( + phone, + cursor = null, + limit = DEFAULT_IDS_PAGE_LIMIT + ) => ctx.bind(v1.getUuidsPage)(byPhone(phone), cursor, limit), + getUuidsByPhone: (phone) => ctx.bind(v1.getUuids)(byPhone(phone)), + getPageByPhone: ( + phone, + cursor = null, + limit = DEFAULT_DOCS_PAGE_LIMIT + ) => ctx.bind(v1.getPage)(byPhone(phone), cursor, limit), + getByPhone: (phone) => ctx.bind(v1.getAll)(byPhone(phone)), getPageByType: ( type, cursor = null, diff --git a/shared-libs/cht-datasource/src/libs/parameter-validators.ts b/shared-libs/cht-datasource/src/libs/parameter-validators.ts index ccf47cc710e..883030325a2 100644 --- a/shared-libs/cht-datasource/src/libs/parameter-validators.ts +++ b/shared-libs/cht-datasource/src/libs/parameter-validators.ts @@ -6,7 +6,9 @@ import { isContactTypeQualifier, isFreetextQualifier, isIdsQualifier, + isPhoneQualifier, isUuidQualifier, + PhoneQualifier, UuidQualifier, } from '../qualifier'; import { @@ -125,12 +127,12 @@ export const assertFreetextQualifier: (qualifier: unknown) => asserts qualifier /** @internal */ export const assertContactTypeFreetextQualifier: ( qualifier: unknown -) => asserts qualifier is ContactTypeQualifier | FreetextQualifier = ( +) => asserts qualifier is ContactTypeQualifier | FreetextQualifier | PhoneQualifier = ( qualifier: unknown ) => { - if (!(isContactTypeQualifier(qualifier) || isFreetextQualifier(qualifier))) { + if (!(isContactTypeQualifier(qualifier) || isFreetextQualifier(qualifier) || isPhoneQualifier(qualifier))) { throw new InvalidArgumentError( - `Invalid qualifier [${JSON.stringify(qualifier)}]. Must be a contact type and/or freetext qualifier.` + `Invalid qualifier [${JSON.stringify(qualifier)}]. Must be a contact type, freetext, and/or phone qualifier.` ); } }; @@ -138,10 +140,10 @@ export const assertContactTypeFreetextQualifier: ( /** @internal */ export const assertContactTypeIdsQualifier: ( qualifier: unknown -) => asserts qualifier is ContactTypeQualifier | IdsQualifier = (qualifier: unknown) => { - if (!(isContactTypeQualifier(qualifier) || isIdsQualifier(qualifier))) { +) => asserts qualifier is ContactTypeQualifier | IdsQualifier | PhoneQualifier = (qualifier: unknown) => { + if (!(isContactTypeQualifier(qualifier) || isIdsQualifier(qualifier) || isPhoneQualifier(qualifier))) { throw new InvalidArgumentError( - `Invalid qualifier [${JSON.stringify(qualifier)}]. Must be a contact type or ids qualifier.` + `Invalid qualifier [${JSON.stringify(qualifier)}]. Must be a contact type, ids, or phone qualifier.` ); } }; @@ -163,12 +165,16 @@ export const assertIdsQualifier: ( }; /** @ignore */ -export const isContactType = (value: ContactTypeQualifier | FreetextQualifier): value is ContactTypeQualifier => { +export const isContactType = ( + value: ContactTypeQualifier | FreetextQualifier | PhoneQualifier +): value is ContactTypeQualifier => { return 'contactType' in value; }; /** @ignore */ -export const isFreetextType = (value: ContactTypeQualifier | FreetextQualifier): value is FreetextQualifier => { +export const isFreetextType = ( + value: ContactTypeQualifier | FreetextQualifier | PhoneQualifier +): value is FreetextQualifier => { return 'freetext' in value; }; diff --git a/shared-libs/cht-datasource/src/local/contact.ts b/shared-libs/cht-datasource/src/local/contact.ts index e1f2a01a590..c23e8ec8ab4 100644 --- a/shared-libs/cht-datasource/src/local/contact.ts +++ b/shared-libs/cht-datasource/src/local/contact.ts @@ -16,6 +16,8 @@ import { isFreetextQualifier, isIdsQualifier, isKeyedFreetextQualifier, + isPhoneQualifier, + PhoneQualifier, UuidQualifier } from '../qualifier'; import * as Contact from '../contact'; @@ -71,6 +73,21 @@ const getOfflineFreetextQueryFn = (medicDb: PouchDB.Database) => { }; }; +const getContactDocsPageFn = ( + qualifier: ContactTypeQualifier | IdsQualifier | PhoneQualifier, + getMedicDocsByIds: ReturnType, + queryDocsByType: ReturnType, + queryDocsByPhone: ReturnType, +): ((limit: number, skip: number) => Promise[]>) => { + if (isIdsQualifier(qualifier)) { + return (limit: number, skip: number) => getMedicDocsByIds(qualifier.ids.slice(skip, skip + limit)); + } + if (isPhoneQualifier(qualifier)) { + return (limit: number, skip: number) => queryDocsByPhone(qualifier.phone, limit, skip); + } + return (limit: number, skip: number) => queryDocsByType([qualifier.contactType], limit, skip); +}; + /** @internal */ export namespace v1 { /** @internal */ @@ -127,14 +144,21 @@ export namespace v1 { export const getUuidsPage = ({ medicDb, settings }: LocalDataContext) => { const queryNouveauFreetext = queryByFreetext(medicDb, 'contacts_by_freetext'); const queryViewByType = queryDocIdsByKey(medicDb, 'medic-client/contacts_by_type'); + const queryViewByPhone = queryDocIdsByKey(medicDb, 'medic-client/contacts_by_phone'); const getOfflineFreetextQueryPageFn = getOfflineFreetextQueryFn(medicDb); const promisedUseNouveau = useNouveauIndexes(medicDb); return async ( - qualifier: ContactTypeQualifier | FreetextQualifier, + qualifier: ContactTypeQualifier | FreetextQualifier | PhoneQualifier, cursor: Nullable, limit: number ): Promise> => { + if (isPhoneQualifier(qualifier)) { + const skip = validateCursor(cursor); + const getPageFn = (limit: number, skip: number) => queryViewByPhone(qualifier.phone, limit, skip); + return await fetchAndFilterIds(getPageFn, limit)(limit, skip); + } + if (isContactTypeQualifier(qualifier)) { assertValidContactType(settings.getAll(), qualifier); } @@ -163,20 +187,19 @@ export namespace v1 { export const getPage = ({ medicDb, settings }: LocalDataContext) => { const getMedicDocsByIds = getDocsByIds(medicDb); const queryDocsByType = queryDocsByKey(medicDb, 'medic-client/contacts_by_type'); + const queryDocsByPhone = queryDocsByKey(medicDb, 'medic-client/contacts_by_phone'); return async ( - qualifier: ContactTypeQualifier | IdsQualifier, + qualifier: ContactTypeQualifier | IdsQualifier | PhoneQualifier, cursor: Nullable, limit: number, ): Promise> => { - if (!isIdsQualifier(qualifier)) { + if (isContactTypeQualifier(qualifier)) { assertValidContactType(settings.getAll(), qualifier); } const skip = validateCursor(cursor); - const getPageFn = isIdsQualifier(qualifier) - ? (limit: number, skip: number) => getMedicDocsByIds(qualifier.ids.slice(skip, skip + limit)) - : (limit: number, skip: number) => queryDocsByType([qualifier.contactType], limit, skip); + const getPageFn = getContactDocsPageFn(qualifier, getMedicDocsByIds, queryDocsByType, queryDocsByPhone); return await fetchAndFilter( getPageFn, diff --git a/shared-libs/cht-datasource/src/qualifier.ts b/shared-libs/cht-datasource/src/qualifier.ts index ae9b622dd81..5ff6058b981 100644 --- a/shared-libs/cht-datasource/src/qualifier.ts +++ b/shared-libs/cht-datasource/src/qualifier.ts @@ -116,6 +116,34 @@ export const isContactTypeQualifier = (contactType: unknown): contactType is Con return isRecord(contactType) && hasField(contactType, { name: 'contactType', type: 'string' }); }; +/** + * A qualifier that identifies contacts by their phone number. + */ +export type PhoneQualifier = Readonly<{ phone: string }>; + +/** + * Builds a qualifier that identifies contacts by their phone number. The phone number is used verbatim: no + * normalisation (trimming, country-code handling, etc.) is performed, to preserve parity with the underlying view. + * @param phone the phone number of the contacts + * @returns the qualifier + * @throws InvalidArgumentError if the phone number is not a non-empty string + */ +export const byPhone = (phone: string): PhoneQualifier => { + if (!isString(phone) || phone.length === 0) { + throw new InvalidArgumentError(`Invalid phone [${JSON.stringify(phone)}].`); + } + return { phone }; +}; + +/** + * Returns `true` if the given qualifier is a {@link PhoneQualifier}, otherwise `false`. + * @param qualifier the qualifier to check + * @returns `true` if the given qualifier is a {@link PhoneQualifier}, otherwise `false` + */ +export const isPhoneQualifier = (qualifier: unknown): qualifier is PhoneQualifier => { + return isRecord(qualifier) && hasField(qualifier, { name: 'phone', type: 'string' }); +}; + /** * A qualifier that identifies entities based on a freetext search string. */ diff --git a/shared-libs/cht-datasource/src/remote/contact.ts b/shared-libs/cht-datasource/src/remote/contact.ts index 7933f15ee63..42c78950384 100644 --- a/shared-libs/cht-datasource/src/remote/contact.ts +++ b/shared-libs/cht-datasource/src/remote/contact.ts @@ -5,6 +5,8 @@ import { IdsQualifier, isContactTypeQualifier, isIdsQualifier, + isPhoneQualifier, + PhoneQualifier, UuidQualifier } from '../qualifier'; import { Nullable, Page } from '../libs/core'; @@ -44,10 +46,13 @@ export namespace v1 { /** @internal */ export const getUuidsPage = (remoteContext: RemoteDataContext) => ( - qualifier: ContactTypeQualifier | FreetextQualifier, + qualifier: ContactTypeQualifier | FreetextQualifier | PhoneQualifier, cursor: Nullable, limit: number ): Promise> => { + const phoneParams: Record = isPhoneQualifier(qualifier) + ? { phone: qualifier.phone } + : {}; const freetextParams: Record = isFreetextType(qualifier) ? { freetext: qualifier.freetext } : {}; @@ -60,19 +65,23 @@ export namespace v1 { ...(cursor ? { cursor } : {}), ...typeParams, ...freetextParams, + ...phoneParams, }; return getContactUuids(remoteContext)(queryParams); }; /** @internal */ export const getPage = (remoteContext: RemoteDataContext) => ( - qualifier: ContactTypeQualifier | IdsQualifier, + qualifier: ContactTypeQualifier | IdsQualifier | PhoneQualifier, cursor: Nullable, limit: number ): Promise> => { const idsParams: Record = isIdsQualifier(qualifier) ? { ids: qualifier.ids.join(',') } : {}; + const phoneParams: Record = isPhoneQualifier(qualifier) + ? { phone: qualifier.phone } + : {}; const typeParams: Record = isContactTypeQualifier(qualifier) ? { type: qualifier.contactType } : {}; @@ -82,6 +91,7 @@ export namespace v1 { ...(cursor ? { cursor } : {}), ...typeParams, ...idsParams, + ...phoneParams, }; return getContacts(remoteContext)(queryParams); }; diff --git a/shared-libs/cht-datasource/test/contact.spec.ts b/shared-libs/cht-datasource/test/contact.spec.ts index b5f9be76285..dcdba0ecfa0 100644 --- a/shared-libs/cht-datasource/test/contact.spec.ts +++ b/shared-libs/cht-datasource/test/contact.spec.ts @@ -18,6 +18,7 @@ describe('contact', () => { let isContactTypeQualifier: SinonStub; let isFreetextQualifier: SinonStub; let isIdsQualifier: SinonStub; + let isPhoneQualifier: SinonStub; beforeEach(() => { dataContextBind = sinon.stub(dataContext, 'bind'); @@ -27,6 +28,7 @@ describe('contact', () => { isContactTypeQualifier = sinon.stub(Qualifier, 'isContactTypeQualifier'); isFreetextQualifier = sinon.stub(Qualifier, 'isFreetextQualifier'); isIdsQualifier = sinon.stub(Qualifier, 'isIdsQualifier'); + isPhoneQualifier = sinon.stub(Qualifier, 'isPhoneQualifier'); }); afterEach(() => sinon.restore()); @@ -371,6 +373,40 @@ describe('contact', () => { expect(isFreetextQualifier.notCalled).to.be.true; }); + it('retrieves a page of UUIDs for a phone qualifier', async () => { + isContactTypeQualifier.returns(false); + isFreetextQualifier.returns(false); + isPhoneQualifier.returns(true); + const phoneQualifier = { phone: '+254712345678' }; + getIdsPage.resolves(pageData); + + const result = await Contact.v1.getUuidsPage(dataContext)(phoneQualifier, cursor, limit); + + expect(result).to.equal(pageData); + expect(getIdsPage.calledOnceWithExactly(phoneQualifier, cursor, limit)).to.be.true; + expect(isPhoneQualifier.calledOnceWithExactly(phoneQualifier)).to.be.true; + }); + + it('walks two cursor pages with limit 5 for a phone qualifier', async () => { + isPhoneQualifier.returns(true); + const phoneQualifier = { phone: '+254712345678' }; + const pageLimit = 5; + const page1 = { data: ['c1', 'c2', 'c3', 'c4', 'c5'], cursor: '5' }; + const page2 = { data: ['c6', 'c7'], cursor: null }; + getIdsPage.withArgs(phoneQualifier, null, pageLimit).resolves(page1); + getIdsPage.withArgs(phoneQualifier, '5', pageLimit).resolves(page2); + + const getUuidsPage = Contact.v1.getUuidsPage(dataContext); + const firstPage = await getUuidsPage(phoneQualifier, null, pageLimit); + const secondPage = await getUuidsPage(phoneQualifier, firstPage.cursor, pageLimit); + + expect(firstPage).to.deep.equal(page1); + expect(secondPage).to.deep.equal(page2); + expect(getIdsPage.calledTwice).to.be.true; + expect(getIdsPage.firstCall.calledWithExactly(phoneQualifier, null, pageLimit)).to.be.true; + expect(getIdsPage.secondCall.calledWithExactly(phoneQualifier, '5', pageLimit)).to.be.true; + }); + it('throws an error if the data context is invalid', () => { isContactTypeQualifier.returns(true); isFreetextQualifier.returns(true); @@ -390,7 +426,7 @@ describe('contact', () => { await expect(Contact.v1.getUuidsPage(dataContext)(invalidContactTypeQualifier, cursor, limit)) .to.be.rejectedWith(`Invalid qualifier [${JSON.stringify(invalidContactTypeQualifier)}]. ` + - `Must be a contact type and/or freetext qualifier.`); + `Must be a contact type, freetext, and/or phone qualifier.`); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect( adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) @@ -405,7 +441,7 @@ describe('contact', () => { await expect(Contact.v1.getUuidsPage(dataContext)(invalidFreetextQualifier, cursor, limit)) .to.be.rejectedWith(`Invalid qualifier [${JSON.stringify(invalidFreetextQualifier)}]. ` + - `Must be a contact type and/or freetext qualifier.`); + `Must be a contact type, freetext, and/or phone qualifier.`); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect( adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) @@ -420,7 +456,8 @@ describe('contact', () => { 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.` + `Invalid qualifier [${JSON.stringify(invalidQualifier)}]. ` + + `Must be a contact type, freetext, and/or phone qualifier.` ); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; @@ -537,7 +574,7 @@ describe('contact', () => { expect(() => Contact.v1.getUuids(dataContext)(invalidContactTypeQualifier)) .to.throw(`Invalid qualifier [${JSON.stringify(invalidContactTypeQualifier)}]. ` + - `Must be a contact type and/or freetext qualifier.`); + `Must be a contact type, freetext, and/or phone qualifier.`); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect(contactGetIdsPage.notCalled).to.be.true; expect(isContactTypeQualifier.calledOnceWithExactly(invalidContactTypeQualifier)).to.be.true; @@ -549,7 +586,7 @@ describe('contact', () => { expect(() => Contact.v1.getUuids(dataContext)(invalidFreetextQualifier)) .to.throw(`Invalid qualifier [${JSON.stringify(invalidFreetextQualifier)}]. ` + - `Must be a contact type and/or freetext qualifier.`); + `Must be a contact type, freetext, and/or phone qualifier.`); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect(contactGetIdsPage.notCalled).to.be.true; expect(isContactTypeQualifier.calledOnceWithExactly(invalidFreetextQualifier)).to.be.true; @@ -561,7 +598,8 @@ describe('contact', () => { 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.` + `Invalid qualifier [${JSON.stringify(invalidQualifier)}]. ` + + `Must be a contact type, freetext, and/or phone qualifier.` ); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect(contactGetIdsPage.notCalled).to.be.true; @@ -613,6 +651,20 @@ describe('contact', () => { expect(isIdsQualifier.calledOnceWithExactly(idsQualifier)).to.be.true; }); + it('retrieves contacts for a phone qualifier', async () => { + isContactTypeQualifier.returns(false); + isIdsQualifier.returns(false); + isPhoneQualifier.returns(true); + const phoneQualifier = { phone: '+254712345678' }; + getPage.resolves(pageData); + + const result = await Contact.v1.getPage(dataContext)(phoneQualifier, cursor, limit); + + expect(result).to.equal(pageData); + expect(getPage.calledOnceWithExactly(phoneQualifier, cursor, limit)).to.be.true; + expect(isPhoneQualifier.calledOnceWithExactly(phoneQualifier)).to.be.true; + }); + it('uses default cursor and limit when not provided', async () => { isContactTypeQualifier.returns(true); getPage.resolves(pageData); @@ -648,7 +700,7 @@ describe('contact', () => { await expect(Contact.v1.getPage(dataContext)(invalidQualifier as never, cursor, limit)) .to.be.rejectedWith( - `Invalid qualifier [${JSON.stringify(invalidQualifier)}]. Must be a contact type or ids qualifier.` + `Invalid qualifier [${JSON.stringify(invalidQualifier)}]. Must be a contact type, ids, or phone qualifier.` ); expect(isContactTypeQualifier.calledOnceWithExactly(invalidQualifier)).to.be.true; @@ -731,7 +783,7 @@ describe('contact', () => { isIdsQualifier.returns(false); const invalidQualifier = { invalid: true }; const expectedMessage = - `Invalid qualifier [${JSON.stringify(invalidQualifier)}]. Must be a contact type or ids qualifier.`; + `Invalid qualifier [${JSON.stringify(invalidQualifier)}]. Must be a contact type, ids, or phone qualifier.`; expect(() => Contact.v1.getAll(dataContext)(invalidQualifier as never)).to.throw(expectedMessage); expect(contactGetPage.notCalled).to.be.true; @@ -756,10 +808,14 @@ describe('contact', () => { 'getUuidsByFreetext', 'getUuidsPageByType', 'getUuidsByType', + 'getUuidsPageByPhone', + 'getUuidsByPhone', 'getPageByType', 'getByType', 'getPageByIds', 'getByIds', + 'getPageByPhone', + 'getByPhone', ] ); }); @@ -1097,6 +1153,102 @@ describe('contact', () => { expect(contactGetAll.calledOnceWithExactly(idsQualifier)).to.be.true; expect(byIds.calledOnceWithExactly(ids)).to.be.true; }); + + it('getUuidsPageByPhone', async () => { + const expectedContactIds: Page = { data: [], cursor: null }; + const contactGetIdsPage = sinon.stub().resolves(expectedContactIds); + dataContextBind.returns(contactGetIdsPage); + const phone = '+254712345678'; + const phoneQualifier = { phone }; + const byPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + const limit = 2; + const cursor = '1'; + + const returnedContactIds = await contact.getUuidsPageByPhone(phone, cursor, limit); + + expect(returnedContactIds).to.equal(expectedContactIds); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuidsPage)).to.be.true; + expect(contactGetIdsPage.calledOnceWithExactly(phoneQualifier, cursor, limit)).to.be.true; + expect(byPhone.calledOnceWithExactly(phone)).to.be.true; + }); + + it('getUuidsPageByPhone uses default cursor and limit', async () => { + const expectedContactIds: Page = { data: [], cursor: null }; + const contactGetIdsPage = sinon.stub().resolves(expectedContactIds); + dataContextBind.returns(contactGetIdsPage); + const phone = '+254712345678'; + const phoneQualifier = { phone }; + sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + + const returnedContactIds = await contact.getUuidsPageByPhone(phone); + + expect(returnedContactIds).to.equal(expectedContactIds); + expect(contactGetIdsPage.calledOnceWithExactly(phoneQualifier, null, 10000)).to.be.true; + }); + + it('getUuidsByPhone', () => { + const mockAsyncGenerator = fakeGenerator(); + const contactGetIds = sinon.stub().returns(mockAsyncGenerator); + dataContextBind.returns(contactGetIds); + const phone = '+254712345678'; + const phoneQualifier = { phone }; + const byPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + + const res = contact.getUuidsByPhone(phone); + + expect(res).to.deep.equal(mockAsyncGenerator); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuids)).to.be.true; + expect(contactGetIds.calledOnceWithExactly(phoneQualifier)).to.be.true; + expect(byPhone.calledOnceWithExactly(phone)).to.be.true; + }); + + it('getPageByPhone', async () => { + const expectedContacts: Page = { data: [], cursor: null }; + const contactGetPage = sinon.stub().resolves(expectedContacts); + dataContextBind.returns(contactGetPage); + const phone = '+254712345678'; + const phoneQualifier = { phone }; + const byPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + const limit = 2; + const cursor = '1'; + + const returnedContacts = await contact.getPageByPhone(phone, cursor, limit); + + expect(returnedContacts).to.equal(expectedContacts); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getPage)).to.be.true; + expect(contactGetPage.calledOnceWithExactly(phoneQualifier, cursor, limit)).to.be.true; + expect(byPhone.calledOnceWithExactly(phone)).to.be.true; + }); + + it('getPageByPhone uses default cursor and limit', async () => { + const expectedContacts: Page = { data: [], cursor: null }; + const contactGetPage = sinon.stub().resolves(expectedContacts); + dataContextBind.returns(contactGetPage); + const phone = '+254712345678'; + const phoneQualifier = { phone }; + sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + + const returnedContacts = await contact.getPageByPhone(phone); + + expect(returnedContacts).to.equal(expectedContacts); + expect(contactGetPage.calledOnceWithExactly(phoneQualifier, null, 100)).to.be.true; + }); + + it('getByPhone', () => { + const mockAsyncGenerator = fakeGenerator(); + const contactGetAll = sinon.stub().returns(mockAsyncGenerator); + dataContextBind.returns(contactGetAll); + const phone = '+254712345678'; + const phoneQualifier = { phone }; + const byPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + + const res = contact.getByPhone(phone); + + expect(res).to.deep.equal(mockAsyncGenerator); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getAll)).to.be.true; + expect(contactGetAll.calledOnceWithExactly(phoneQualifier)).to.be.true; + expect(byPhone.calledOnceWithExactly(phone)).to.be.true; + }); }); }); }); diff --git a/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts b/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts index 3852f4cae59..10c9d3e5a3a 100644 --- a/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts +++ b/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts @@ -127,6 +127,12 @@ describe('libs parameter-validators', () => { expect(() => assertContactTypeFreetextQualifier(validFreetext)).to.not.throw(); }); + it('should pass when given a valid phone qualifier', () => { + const validPhone = { phone: '+254712345678' }; + + expect(() => assertContactTypeFreetextQualifier(validPhone)).to.not.throw(); + }); + it('should throw InvalidArgumentError when given an invalid qualifier', () => { const invalidQualifier = { invalid: 'data' }; @@ -138,7 +144,7 @@ describe('libs parameter-validators', () => { expect(() => assertContactTypeFreetextQualifier(invalidQualifier)).to.throw( InvalidArgumentError, - 'Invalid qualifier [{"invalid":"data"}]. Must be a contact type and/or freetext qualifier.' + 'Invalid qualifier [{"invalid":"data"}]. Must be a contact type, freetext, and/or phone qualifier.' ); }); diff --git a/shared-libs/cht-datasource/test/local/contact.spec.ts b/shared-libs/cht-datasource/test/local/contact.spec.ts index 44989bf210c..64ea60f6a8e 100644 --- a/shared-libs/cht-datasource/test/local/contact.spec.ts +++ b/shared-libs/cht-datasource/test/local/contact.spec.ts @@ -263,6 +263,7 @@ describe('local contact', () => { const contactType = 'person'; const expectedResult = { cursor: 'bookmark', data: ['1', '2', '3'] }; let queryViewByType: SinonStub; + let queryViewByPhone: SinonStub; let queryViewFreetextByKey: SinonStub; let queryViewFreetextByRange: SinonStub; let queryViewTypeFreetextByKey: SinonStub; @@ -277,12 +278,16 @@ describe('local contact', () => { getContactTypeIds = sinon.stub(contactTypeUtils, 'getContactTypeIds').returns([contactType]); queryViewByType = sinon.stub(); + queryViewByPhone = sinon.stub(); queryViewFreetextByKey = sinon.stub(); queryViewTypeFreetextByKey = sinon.stub(); const queryDocIdsByKeyStub = sinon.stub(LocalDoc, 'queryDocIdsByKey'); queryDocIdsByKeyStub .withArgs(localContext.medicDb, 'medic-client/contacts_by_type') .returns(queryViewByType); + queryDocIdsByKeyStub + .withArgs(localContext.medicDb, 'medic-client/contacts_by_phone') + .returns(queryViewByPhone); queryDocIdsByKeyStub .withArgs(localContext.medicDb, 'medic-offline-freetext/contacts_by_freetext') .returns(queryViewFreetextByKey); @@ -388,6 +393,58 @@ describe('local contact', () => { }); }); + describe('phone qualifier', () => { + beforeEach(() => { + useNouveauIndexes.resolves(false); + fetchAndFilterIdsInner.resolves(expectedResult); + }); + + ([ + [null, 0], + ['1', 1] + ] as [string | null, number][]).forEach(([cursor, skip]) => { + it(`returns page of UUIDs for phone qualifier with cursor [${cursor}]`, async () => { + const phone = '+254712345678'; + const qualifier = Qualifier.byPhone(phone); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + + expect(res).to.deep.equal(expectedResult); + // the phone dispatch does not validate the contact type nor consult freetext/nouveau views + expect(getContactTypeIds.notCalled).to.be.true; + expect(queryNouveauFreetext.notCalled).to.be.true; + expect(queryViewByType.notCalled).to.be.true; + expect(queryViewFreetextByKey.notCalled).to.be.true; + expect(queryViewFreetextByRange.notCalled).to.be.true; + expect(queryViewTypeFreetextByKey.notCalled).to.be.true; + expect(queryViewTypeFreetextByRange.notCalled).to.be.true; + expect(fetchAndFilterIdsOuter.calledOnce).to.be.true; + expect(fetchAndFilterIdsOuter.args[0][1]).to.equal(limit); + expect(fetchAndFilterIdsInner.calledOnceWithExactly(limit, skip)).to.be.true; + // Verify the page function uses the contacts_by_phone view keyed by the phone (verbatim, no array wrap) + const pageFn = fetchAndFilterIdsOuter.firstCall.args[0] as (l: number, s: number) => unknown; + pageFn(limit, skip); + + expect(queryViewByPhone.calledWithExactly(phone, limit, skip)).to.be.true; + }); + }); + + it('throws for invalid cursor', async () => { + const qualifier = Qualifier.byPhone('+254712345678'); + const cursor = 'not a number'; + + await expect(Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit)) + .to.be.rejectedWith( + InvalidArgumentError, + `The cursor must be a string or null for first page: [${JSON.stringify(cursor)}]` + ); + + expect(queryViewByPhone.notCalled).to.be.true; + expect(fetchAndFilterIdsOuter.notCalled).to.be.true; + expect(fetchAndFilterIdsInner.notCalled).to.be.true; + }); + }); + describe('freetext qualifier', () => { describe('when useNouveauIndexes is true', () => { beforeEach(() => { @@ -694,7 +751,7 @@ describe('local contact', () => { expect(res).to.deep.equal(expectedResult); expect(getContactTypeIds.calledOnceWithExactly(settings)).to.be.true; - expect(queryDocsByKeyOuter.calledOnceWithExactly( + expect(queryDocsByKeyOuter.calledWithExactly( localContext.medicDb, 'medic-client/contacts_by_type' )).to.be.true; expect(fetchAndFilterOuter.calledOnce).to.be.true; @@ -710,6 +767,31 @@ describe('local contact', () => { expect(getDocsByIdsInner.notCalled).to.be.true; }); + it('returns a page of contacts for the given phone number', async () => { + const phone = '+254712345678'; + const qualifier = Qualifier.byPhone(phone); + const expectedResult = { cursor: '2', data: [{ type: 'person' }] }; + fetchAndFilterInner.resolves(expectedResult); + + const res = await Contact.v1.getPage(localContext)(qualifier, cursor, limit); + + expect(res).to.deep.equal(expectedResult); + // the phone arm does not validate the contact type + expect(getContactTypeIds.notCalled).to.be.true; + expect(queryDocsByKeyOuter.calledWithExactly( + localContext.medicDb, 'medic-client/contacts_by_phone' + )).to.be.true; + expect(fetchAndFilterOuter.calledOnce).to.be.true; + const getPageFn = fetchAndFilterOuter.firstCall.args[0]; + expect(getPageFn).to.be.a('function'); + expect(fetchAndFilterInner.calledOnceWithExactly(limit, 0)).to.be.true; + + // The page function queries the contacts_by_phone view keyed by the phone (verbatim, no array wrap). + await getPageFn(limit, 0); + expect(queryDocsByKeyInner.calledOnceWithExactly(phone, limit, 0)).to.be.true; + expect(getDocsByIdsInner.notCalled).to.be.true; + }); + it('throws an error if the contact type is invalid', async () => { getContactTypeIds.returns(['not-person']); diff --git a/shared-libs/cht-datasource/test/qualifier.spec.ts b/shared-libs/cht-datasource/test/qualifier.spec.ts index 9cdc349a25c..a7487f2cdde 100644 --- a/shared-libs/cht-datasource/test/qualifier.spec.ts +++ b/shared-libs/cht-datasource/test/qualifier.spec.ts @@ -4,6 +4,7 @@ import { byContactId, byContactIds, byFreetext, + byPhone, byReportingPeriod, byUsername, byUuid, @@ -13,6 +14,7 @@ import { isContactIdsQualifier, isFreetextQualifier, isKeyedFreetextQualifier, + isPhoneQualifier, isReportingPeriodQualifier, isUsernameQualifier, isUuidQualifier, @@ -156,6 +158,45 @@ describe('qualifier', () => { }); }); + describe('byPhone', () => { + it('builds a qualifier that identifies contacts by their phone number', () => { + expect(byPhone('+1234')).to.deep.equal({ phone: '+1234' }); + }); + + it('preserves the phone number verbatim without normalisation', () => { + // whitespace and leading "+" are non-empty strings and must be kept as-is (parity with the view) + expect(byPhone(' +1 234 ')).to.deep.equal({ phone: ' +1 234 ' }); + }); + + [ + null, + undefined, + '', + 123, + { }, + ].forEach(phone => { + it(`throws an error for ${JSON.stringify(phone)}`, () => { + expect(() => byPhone(phone as string)).to.throw(`Invalid phone [${JSON.stringify(phone)}].`); + }); + }); + }); + + describe('isPhoneQualifier', () => { + [ + [ null, false ], + [ '+1234', false ], + [ { phone: { } }, false ], + [ { phone: 123 }, false ], + [ { phone: '+1234' }, true ], + [ { phone: ' ' }, true ], + [ { phone: '+1234', other: 'other' }, true ] + ].forEach(([ qualifier, expected ]) => { + it(`evaluates ${JSON.stringify(qualifier)}`, () => { + expect(isPhoneQualifier(qualifier)).to.equal(expected); + }); + }); + }); + describe('byFreetext', () => { it('builds a qualifier for searching an entity by freetext with colon : delimiter', () => { expect(byFreetext('key:some value')).to.deep.equal({ freetext: 'key:some value' }); diff --git a/shared-libs/cht-datasource/test/remote/contact.spec.ts b/shared-libs/cht-datasource/test/remote/contact.spec.ts index ca4c3711b0a..f672e4ccf6f 100644 --- a/shared-libs/cht-datasource/test/remote/contact.spec.ts +++ b/shared-libs/cht-datasource/test/remote/contact.spec.ts @@ -192,6 +192,22 @@ describe('remote contact', () => { type: contactType, })).to.be.true; }); + + it('serializes the phone qualifier over the uuid endpoint', async () => { + const phone = '+254712345678'; + const expectedResponse = { data: ['uuid-1'], cursor }; + getResourcesInner.resolves(expectedResponse); + + const result = await Contact.v1.getUuidsPage(remoteContext)({ phone }, cursor, limit); + + expect(result).to.equal(expectedResponse); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/contact/uuid')).to.be.true; + expect(getResourcesInner.calledOnceWithExactly({ + limit: limit.toString(), + cursor, + phone, + })).to.be.true; + }); }); describe('getPage', () => { @@ -237,6 +253,22 @@ describe('remote contact', () => { expect(result).to.equal(expectedResponse); expect(getResourcesInner.calledOnceWithExactly({ limit: limit.toString(), type: 'person' })).to.be.true; }); + + it('serializes the phone qualifier over the contact endpoint', async () => { + const phone = '+254712345678'; + const expectedResponse = { data: [{ type: 'person' }], cursor }; + getResourcesInner.resolves(expectedResponse); + + const result = await Contact.v1.getPage(remoteContext)({ phone }, cursor, limit); + + expect(result).to.equal(expectedResponse); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/contact')).to.be.true; + expect(getResourcesInner.calledOnceWithExactly({ + limit: limit.toString(), + cursor, + phone, + })).to.be.true; + }); }); }); }); diff --git a/shared-libs/transitions/src/transitions/registration.js b/shared-libs/transitions/src/transitions/registration.js index 1eabd7d56c6..d91b011b01b 100644 --- a/shared-libs/transitions/src/transitions/registration.js +++ b/shared-libs/transitions/src/transitions/registration.js @@ -4,7 +4,7 @@ const transitionUtils = require('./utils'); const logger = require('@medic/logger'); const db = require('../db'); const dataContext = require('../data-context'); -const { Place, Qualifier } = require('@medic/cht-datasource'); +const { Contact, Place, Qualifier } = require('@medic/cht-datasource'); const lineage = require('@medic/lineage')(Promise, db.medic); const messages = require('../lib/messages'); const schedules = require('../lib/schedules'); @@ -415,9 +415,9 @@ const setPatientId = (options) => { }; const getParentByPhone = options => { - return db.medic - .query('medic-client/contacts_by_phone', { key: options.doc.from, include_docs: true }) - .then(result => result && result.rows && result.rows.length && result.rows[0].doc) + const getContactDocs = dataContext.bind(Contact.v1.getPage); + return getContactDocs(Qualifier.byPhone(String(options.doc.from)), null, 1) + .then(page => page.data.length && page.data[0]) .then(contact => { if (!contact) { return; diff --git a/shared-libs/transitions/src/transitions/self_report.js b/shared-libs/transitions/src/transitions/self_report.js index 658b82df69d..592685ff145 100644 --- a/shared-libs/transitions/src/transitions/self_report.js +++ b/shared-libs/transitions/src/transitions/self_report.js @@ -1,6 +1,5 @@ const transitionUtils = require('./utils'); const config = require('../config'); -const db = require('../db'); const NAME = 'self_report'; const { Contact, Qualifier } = require('@medic/cht-datasource'); const dataContext = require('../data-context'); @@ -26,17 +25,17 @@ module.exports = { onMatch: change => { const doc = change.doc; const formConfig = getConfiguredForm(doc.form); + const getContactUuids = dataContext.bind(Contact.v1.getUuidsPage); const getContactWithLineage = dataContext.bind(Contact.v1.getWithLineage); - return db.medic - .query('medic-client/contacts_by_phone', { key: String(doc.from) }) - .then(result => { - if (!result.rows || !result.rows.length || !result.rows[0].id) { + return getContactUuids(Qualifier.byPhone(String(doc.from)), null, 1) + .then(page => { + if (!page.data.length) { transitionUtils.addRejectionMessage(doc, formConfig, 'sender_not_found'); return true; } - return getContactWithLineage(Qualifier.byUuid(result.rows[0].id)).then(patient => { + return getContactWithLineage(Qualifier.byUuid(page.data[0])).then(patient => { doc.patient = patient; if (!doc.fields) { diff --git a/shared-libs/transitions/src/transitions/update_clinics.js b/shared-libs/transitions/src/transitions/update_clinics.js index 23b53a703b0..e3a9c7ca8b8 100644 --- a/shared-libs/transitions/src/transitions/update_clinics.js +++ b/shared-libs/transitions/src/transitions/update_clinics.js @@ -53,21 +53,15 @@ const getContactByRefid = doc => { }; const getContactByPhone = doc => { - const params = { - key: String(doc.from), - include_docs: false, - limit: 1, - }; - + const getContactUuids = dataContext.bind(Contact.v1.getUuidsPage); const getContactWithLineage = dataContext.bind(Contact.v1.getWithLineage); - return db.medic - .query('medic-client/contacts_by_phone', params) - .then(data => { - if (!data.rows.length || !data.rows[0].id) { + return getContactUuids(Qualifier.byPhone(String(doc.from)), null, 1) + .then(page => { + if (!page.data.length) { return; } - return getContactWithLineage(Qualifier.byUuid(data.rows[0].id)); + return getContactWithLineage(Qualifier.byUuid(page.data[0])); }); }; diff --git a/shared-libs/transitions/src/transitions/update_sent_by.js b/shared-libs/transitions/src/transitions/update_sent_by.js index 728460f4374..6a94354abf0 100644 --- a/shared-libs/transitions/src/transitions/update_sent_by.js +++ b/shared-libs/transitions/src/transitions/update_sent_by.js @@ -1,6 +1,7 @@ const transitionUtils = require('./utils'); -const db = require('../db'); +const dataContext = require('../data-context'); const NAME = 'update_sent_by'; +const { Contact, Qualifier } = require('@medic/cht-datasource'); const { DOC_TYPES } = require('@medic/constants'); module.exports = { @@ -23,14 +24,11 @@ module.exports = { }, onMatch: change => { const doc = change.doc; + const getContactDocs = dataContext.bind(Contact.v1.getPage); - return db.medic - .query('medic-client/contacts_by_phone', { key: doc.from, include_docs: true }) - .then(result => { - const sentBy = result.rows && - result.rows.length && - result.rows[0].doc && - result.rows[0].doc.name; + return getContactDocs(Qualifier.byPhone(String(doc.from)), null, 1) + .then(page => { + const sentBy = page.data.length && page.data[0].name; if (sentBy) { doc.sent_by = sentBy; diff --git a/shared-libs/transitions/test/integration/transitions.js b/shared-libs/transitions/test/integration/transitions.js index 30e4ed31366..5ae44623343 100644 --- a/shared-libs/transitions/test/integration/transitions.js +++ b/shared-libs/transitions/test/integration/transitions.js @@ -6,7 +6,7 @@ const db = require('../../src/db'); const config = require('../../src/config'); const infodoc = require('@medic/infodoc'); const dataContext = require('../../src/data-context'); -const { Contact } = require('@medic/cht-datasource'); +const { Contact, Qualifier } = require('@medic/cht-datasource'); const { DOC_TYPES, CONTACT_TYPES } = require('@medic/constants'); chai.use(chaiExclude); @@ -382,12 +382,18 @@ describe('functional transitions', () => { describe('processDocs', () => { let getContactWithLineage; + let getContactUuids; + let getContactDocs; beforeEach(() => { getContactWithLineage = sinon.stub(); - dataContext.init({ - bind: sinon.stub().withArgs(Contact.v1.getWithLineage).returns(getContactWithLineage), - }); + getContactUuids = sinon.stub(); + getContactDocs = sinon.stub(); + const bind = sinon.stub(); + bind.withArgs(Contact.v1.getWithLineage).returns(getContactWithLineage); + bind.withArgs(Contact.v1.getUuidsPage).returns(getContactUuids); + bind.withArgs(Contact.v1.getPage).returns(getContactDocs); + dataContext.init({ bind }); }); it('should run all async transitions over docs and save all docs', () => { @@ -524,21 +530,20 @@ describe('functional transitions', () => { sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); - sinon.stub(db.medic, 'query') - // update_clinics - .withArgs('medic-client/contacts_by_phone', { key: 'phone1', include_docs: false, limit: 1 }) - .resolves({ rows: [{ id: 'contact1', key: 'phone1' }] }) - .withArgs('medic-client/contacts_by_phone', { key: 'phone2', include_docs: false, limit: 1 }) - .resolves({ rows: [{ key: 'phone2' }] }) - .withArgs('medic-client/contacts_by_phone', { key: 'phone3', include_docs: false, limit: 1 }) - .resolves({ rows: [{ id: 'contact3', key: 'phone3' }] }) - //update_sent_by - .withArgs('medic-client/contacts_by_phone', { key: 'phone1', include_docs: true }) - .resolves({ rows: [{ id: 'contact1', doc: contact1 }] }) - .withArgs('medic-client/contacts_by_phone', { key: 'phone2', include_docs: true }) - .resolves({ rows: [{ key: 'phone2' }] }) - .withArgs('medic-client/contacts_by_phone', { key: 'phone3', include_docs: true }) - .resolves({ rows: [{ id: 'contact3', doc: contact3 }] }); + // Any other view queries default to empty. + sinon.stub(db.medic, 'query').resolves({ rows: [] }); + + // update_clinics resolves the contact UUID by phone (getUuidsPage) + getContactUuids + .withArgs(Qualifier.byPhone('phone1'), null, 1).resolves({ data: ['contact1'], cursor: null }) + .withArgs(Qualifier.byPhone('phone2'), null, 1).resolves({ data: [], cursor: null }) + .withArgs(Qualifier.byPhone('phone3'), null, 1).resolves({ data: ['contact3'], cursor: null }); + + // update_sent_by resolves the contact doc by phone (getPage) + getContactDocs + .withArgs(Qualifier.byPhone('phone1'), null, 1).resolves({ data: [contact1], cursor: null }) + .withArgs(Qualifier.byPhone('phone2'), null, 1).resolves({ data: [], cursor: null }) + .withArgs(Qualifier.byPhone('phone3'), null, 1).resolves({ data: [contact3], cursor: null }); getContactWithLineage.resolves(contact1); diff --git a/shared-libs/transitions/test/unit/transitions/registration.js b/shared-libs/transitions/test/unit/transitions/registration.js index 02787155f03..8ff416f69bc 100644 --- a/shared-libs/transitions/test/unit/transitions/registration.js +++ b/shared-libs/transitions/test/unit/transitions/registration.js @@ -7,7 +7,7 @@ const messages = require('../../../src/lib/messages'); const utils = require('../../../src/lib/utils'); const config = require('../../../src/config'); const validation = require('@medic/validation'); -const { Place, Qualifier } = require('@medic/cht-datasource'); +const { Contact, Place, Qualifier } = require('@medic/cht-datasource'); const contactTypeUtils = require('@medic/contact-types-utils'); const phoneNumberParser = require('@medic/phone-number'); const { CONTACT_TYPES, DOC_TYPES } = require('@medic/constants'); @@ -18,6 +18,7 @@ let acceptPatientReports; let transition; let settings; let getPlace; +let getContactDocs; describe('registration', () => { beforeEach(() => { @@ -31,11 +32,10 @@ describe('registration', () => { .returns({}) }); getPlace = sinon.stub(); - dataContext.init({ - bind: sinon - .stub() - .returns(getPlace) - }); + getContactDocs = sinon.stub(); + const bind = sinon.stub().returns(getPlace); + bind.withArgs(Contact.v1.getPage).returns(getContactDocs); + dataContext.init({ bind }); schedules = require('../../../src/lib/schedules'); transitionUtils = require('../../../src/transitions/utils'); @@ -164,17 +164,8 @@ describe('registration', () => { }, }; const getContactUuid = sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - const view = sinon.stub(db.medic, 'query').resolves({ - rows: [ - { - doc: { - _id: submitterId, - parent: { _id: parentId }, - }, - }, - ], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ _id: submitterId, parent: { _id: parentId } }], cursor: null }); getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -190,11 +181,9 @@ describe('registration', () => { await transition.onMatch(change); getContactUuid.callCount.should.equal(1); - view.callCount.should.equal(1); - view.args[0][0].should.equal('medic-client/contacts_by_phone'); - view.args[0][1].key.should.equal(senderPhoneNumber); - view.args[0][1].include_docs.should.equal(true); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getContactDocs.callCount.should.equal(1); + getContactDocs.args[0].should.deep.equal([Qualifier.byPhone(senderPhoneNumber), null, 1]); + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid(parentId)).should.be.true; saveDoc.callCount.should.equal(1); saveDoc.args[0][0].name.should.equal(patientName); @@ -241,17 +230,8 @@ describe('registration', () => { }, }; const getContactUuid = sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - const view = sinon.stub(db.medic, 'query').resolves({ - rows: [ - { - doc: { - _id: submitterId, - parent: { _id: parentId }, - }, - }, - ], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ _id: submitterId, parent: { _id: parentId } }], cursor: null }); getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); @@ -269,11 +249,9 @@ describe('registration', () => { await transition.onMatch(change); getContactUuid.callCount.should.equal(1); - view.callCount.should.equal(1); - view.args[0][0].should.equal('medic-client/contacts_by_phone'); - view.args[0][1].key.should.equal(senderPhoneNumber); - view.args[0][1].include_docs.should.equal(true); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getContactDocs.callCount.should.equal(1); + getContactDocs.args[0].should.deep.equal([Qualifier.byPhone(senderPhoneNumber), null, 1]); + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid(parentId)).should.be.true; saveDoc.callCount.should.equal(1); saveDoc.args[0][0].name.should.equal(patientName); @@ -323,16 +301,7 @@ describe('registration', () => { }, }; sinon.stub(utils, 'getContactUuid').resolves(); - sinon.stub(db.medic, 'query').resolves({ - rows: [ - { - doc: { - _id: submitterId, - parent: { _id: parentId }, - }, - }, - ], - }); + getContactDocs.resolves({ data: [{ _id: submitterId, parent: { _id: parentId } }], cursor: null }); getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); @@ -350,7 +319,7 @@ describe('registration', () => { await transition.onMatch(change); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid(parentId)).should.be.true; saveDoc.callCount.should.equal(0); }); @@ -384,17 +353,8 @@ describe('registration', () => { }, }; const getContactUuid = sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - const view = sinon.stub(db.medic, 'query').resolves({ - rows: [ - { - doc: { - _id: submitterId, - parent: { _id: parentId }, - }, - }, - ], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ _id: submitterId, parent: { _id: parentId } }], cursor: null }); getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); @@ -412,11 +372,9 @@ describe('registration', () => { await transition.onMatch(change); getContactUuid.callCount.should.equal(1); - view.callCount.should.equal(1); - view.args[0][0].should.equal('medic-client/contacts_by_phone'); - view.args[0][1].key.should.equal(senderPhoneNumber); - view.args[0][1].include_docs.should.equal(true); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getContactDocs.callCount.should.equal(1); + getContactDocs.args[0].should.deep.equal([Qualifier.byPhone(senderPhoneNumber), null, 1]); + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid(parentId)).should.be.true; saveDoc.callCount.should.equal(1); saveDoc.args[0][0].name.should.equal(patientName); @@ -442,11 +400,7 @@ describe('registration', () => { fields: { patient_name: 'jack' }, }, }; - sinon - .stub(db.medic, 'query') - .resolves({ - rows: [{ doc: { parent: { _id: 'papa' } } }], - }); + getContactDocs.resolves({ data: [{ parent: { _id: 'papa' } }], cursor: null }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { form: 'R', @@ -472,12 +426,8 @@ describe('registration', () => { }; const change = { doc: doc }; sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - sinon - .stub(db.medic, 'query') - .resolves({ - rows: [{ doc: { parent: { _id: 'papa' } } }], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ parent: { _id: 'papa' } }], cursor: null }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(1); const eventConfig = { @@ -497,7 +447,7 @@ describe('registration', () => { await transition.onMatch(change); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; saveDoc.args[0][0].patient_id.should.equal(patientId); doc.patient_id.should.equal(patientId); @@ -517,17 +467,8 @@ describe('registration', () => { }, }; sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - sinon.stub(db.medic, 'query').resolves({ - rows: [ - { - doc: { - _id: 'abc', - parent: { _id: 'papa' }, - }, - }, - ], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ _id: 'abc', parent: { _id: 'papa' } }], cursor: null }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -551,7 +492,7 @@ describe('registration', () => { await transition.onMatch(change); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; saveDoc.callCount.should.equal(1); saveDoc.args[0][0].type.should.equal('contact'); @@ -570,12 +511,8 @@ describe('registration', () => { }; const change = { doc: doc }; sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - sinon - .stub(db.medic, 'query') - .resolves({ - rows: [{ doc: { parent: { _id: 'papa' } } }], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ parent: { _id: 'papa' } }], cursor: null }); getPlace.resolves({ _id: 'papa', type: 'place' }); sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -597,7 +534,7 @@ describe('registration', () => { await transition.onMatch(change); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; (typeof doc.patient_id).should.equal('undefined'); doc.errors.should.deep.equal([ @@ -620,12 +557,8 @@ describe('registration', () => { }; const change = { doc: doc }; sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - sinon - .stub(db.medic, 'query') - .resolves({ - rows: [{ doc: { parent: { _id: 'papa' } } }], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ parent: { _id: 'papa' } }], cursor: null }); getPlace.resolves({ _id: 'papa', type: 'place' }); sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -649,7 +582,7 @@ describe('registration', () => { await transition.onMatch(change); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; (typeof doc.patient_id).should.be.equal('undefined'); doc.errors.should.deep.equal([ @@ -677,12 +610,8 @@ describe('registration', () => { }, }; sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - sinon - .stub(db.medic, 'query') - .resolves({ - rows: [{ doc: { parent: { _id: submitterId } } }], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ parent: { _id: submitterId } }], cursor: null }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -698,7 +627,7 @@ describe('registration', () => { await transition.onMatch(change); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; saveDoc.callCount.should.equal(1); saveDoc.args[0][0].name.should.equal(patientName); @@ -721,12 +650,8 @@ describe('registration', () => { }, }; sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - sinon - .stub(db.medic, 'query') - .resolves({ - rows: [{ doc: { parent: { _id: submitterId } } }], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ parent: { _id: submitterId } }], cursor: null }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -748,7 +673,7 @@ describe('registration', () => { await transition.onMatch(change); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; saveDoc.callCount.should.equal(1); saveDoc.args[0][0].name.should.equal(patientName); @@ -771,12 +696,8 @@ describe('registration', () => { }, }; sinon.stub(utils, 'getContactUuid').resolves(); - // return expected view results when searching for contacts_by_phone - sinon - .stub(db.medic, 'query') - .resolves({ - rows: [{ doc: { parent: { _id: submitterId } } }], - }); + // return expected results when searching for contacts by phone + getContactDocs.resolves({ data: [{ parent: { _id: submitterId } }], cursor: null }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -795,7 +716,7 @@ describe('registration', () => { await transition.onMatch(change); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; saveDoc.callCount.should.equal(1); saveDoc.args[0][0].name.should.equal(patientName); @@ -1472,22 +1393,19 @@ describe('registration', () => { ] }] }; - sinon.stub(db.medic, 'query') - .withArgs('medic-client/contacts_by_phone') - .resolves({ - rows: [ - { - doc: { - _id: 'supervisor', - name: 'Frank', - contact_type: 'supervisor', - type: 'contact', - phone: '+111222', - parent: { _id: 'west_hc' } - } - } - ] - }); + getContactDocs.resolves({ + data: [ + { + _id: 'supervisor', + name: 'Frank', + contact_type: 'supervisor', + type: 'contact', + phone: '+111222', + parent: { _id: 'west_hc' } + } + ], + cursor: null + }); getPlace.resolves({ _id: 'west_hc', name: 'west hc', @@ -1514,10 +1432,9 @@ describe('registration', () => { utils.getContactUuid.callCount.should.equal(1); utils.getContactUuid.args[0].should.deep.equal([placeId]); utils.getContact.callCount.should.equal(0); - db.medic.query.callCount.should.equal(1); - db.medic.query.args[0] - .should.deep.equal(['medic-client/contacts_by_phone', { key: '+111222', include_docs: true }]); - dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getContactDocs.callCount.should.equal(1); + getContactDocs.args[0].should.deep.equal([Qualifier.byPhone('+111222'), null, 1]); + dataContext.bind.calledWith(Place.v1.get).should.be.true; getPlace.calledOnceWithExactly(Qualifier.byUuid('west_hc')).should.be.true; db.medic.post.callCount.should.equal(1); db.medic.post.args[0].should.deep.equal([{ @@ -1763,7 +1680,7 @@ describe('registration', () => { }] }; config.get.withArgs('registrations').returns([eventConfig]); - sinon.stub(db.medic, 'query').withArgs('medic-client/contacts_by_phone').resolves({ rows: [] }); + getContactDocs.resolves({ data: [], cursor: null }); sinon.stub(validation, 'validate').resolves(); sinon.stub(utils, 'getRegistrations').resolves([]); @@ -1775,10 +1692,9 @@ describe('registration', () => { utils.getContactUuid.args[0].should.deep.equal([placeId]); utils.getContact.callCount.should.equal(0); db.medic.post.callCount.should.equal(0); - db.medic.query.callCount.should.equal(1); - db.medic.query.args[0] - .should.deep.equal(['medic-client/contacts_by_phone', { key: '+111222', include_docs: true }]); - dataContext.bind.notCalled.should.be.true; + getContactDocs.callCount.should.equal(1); + getContactDocs.args[0].should.deep.equal([Qualifier.byPhone('+111222'), null, 1]); + dataContext.bind.calledOnceWithExactly(Contact.v1.getPage).should.be.true; getPlace.notCalled.should.be.true; change.doc.errors.length.should.equal(1); diff --git a/shared-libs/transitions/test/unit/transitions/self_report.js b/shared-libs/transitions/test/unit/transitions/self_report.js index 190e6fc1bcb..9f3209abd39 100644 --- a/shared-libs/transitions/test/unit/transitions/self_report.js +++ b/shared-libs/transitions/test/unit/transitions/self_report.js @@ -2,7 +2,6 @@ const rewire = require('rewire'); const sinon = require('sinon'); const chai = require('chai'); const config = require('../../../src/config'); -const db = require('../../../src/db'); const dataContext = require('../../../src/data-context'); const { Contact, Qualifier } = require('@medic/cht-datasource'); const { DOC_TYPES } = require('@medic/constants'); @@ -17,7 +16,6 @@ describe('self_report transition', () => { getTranslations: sinon.stub() }); transition = rewire('../../../src/transitions/self_report'); - sinon.stub(db.medic, 'query'); }); afterEach(() => { @@ -91,28 +89,30 @@ describe('self_report transition', () => { }); describe('onMatch', () => { + let getContactUuids; let getContactWithLineage; + let bind; beforeEach(() => { + getContactUuids = sinon.stub(); getContactWithLineage = sinon.stub(); - dataContext.init({ - bind: sinon.stub().returns(getContactWithLineage), - }); + bind = sinon.stub(); + bind.withArgs(Contact.v1.getUuidsPage).returns(getContactUuids); + bind.withArgs(Contact.v1.getWithLineage).returns(getContactWithLineage); + dataContext.init({ bind }); }); it('should search for the sender and add error when sender not found', () => { config.get.returns([{ form: 'the_form' }]); config.getTranslations.returns({ en: { 'messages.generic.sender_not_found': 'Sender not found' }}); - db.medic.query.resolves({ rows: [] }); + getContactUuids.resolves({ data: [], cursor: null }); const doc = { from: '12345', form: 'the_form' }; return transition.onMatch({ doc }).then(result => { chai.expect(result).to.equal(true); - chai.expect(db.medic.query.callCount).to.equal(1); - chai.expect(db.medic.query.args[0]).to.deep.equal([ - 'medic-client/contacts_by_phone', - { key: '12345' } - ]); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(getContactUuids.callCount).to.equal(1); + chai.expect(getContactUuids.args[0]).to.deep.equal([Qualifier.byPhone('12345'), null, 1]); + chai.expect(bind.calledWith(Contact.v1.getUuidsPage)).to.be.true; + chai.expect(bind.calledWith(Contact.v1.getWithLineage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(0); chai.expect(doc).to.have.all.keys('from', 'errors', 'form', 'tasks'); chai.expect(doc.errors).to.deep.equal([ @@ -148,14 +148,14 @@ describe('self_report transition', () => { } ]); config.getTranslations.returns({ en: { the_message: 'translated message' }}); - db.medic.query.resolves({rows: []}); + getContactUuids.resolves({ data: [], cursor: null }); const doc = { from: '12345', form: 'the_form'}; return transition.onMatch({doc}) .then(result => { chai.expect(result).to.equal(true); - chai.expect(db.medic.query.callCount).to.equal(1); - chai.expect(db.medic.query.args[0]).to.deep.equal([ 'medic-client/contacts_by_phone', { key: '12345' } ]); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(getContactUuids.callCount).to.equal(1); + chai.expect(getContactUuids.args[0]).to.deep.equal([Qualifier.byPhone('12345'), null, 1]); + chai.expect(bind.calledWith(Contact.v1.getWithLineage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(0); chai.expect(doc).to.have.all.keys('from', 'errors', 'form', 'tasks'); chai.expect(doc.errors).to.deep.equal([ {message: 'translated message', code: 'sender_not_found'} ]); @@ -175,17 +175,14 @@ describe('self_report transition', () => { patient_id: 'martin_id' }; - db.medic.query.resolves({ rows: [{ id: 'the_contact' }] }); + getContactUuids.resolves({ data: ['the_contact'], cursor: null }); getContactWithLineage.resolves(patient); return transition.onMatch({ doc }).then(result => { chai.expect(result).to.equal(true); - chai.expect(db.medic.query.callCount).to.equal(1); - chai.expect(db.medic.query.args[0]).to.deep.equal([ - 'medic-client/contacts_by_phone', - { key: '654987' } - ]); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(getContactUuids.callCount).to.equal(1); + chai.expect(getContactUuids.args[0]).to.deep.equal([Qualifier.byPhone('654987'), null, 1]); + chai.expect(bind.calledWith(Contact.v1.getWithLineage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(1); chai.expect(getContactWithLineage.args[0]).to.deep.equal([Qualifier.byUuid('the_contact')]); chai.expect(doc).to.have.all.keys('from', 'patient', 'fields', 'form'); @@ -225,17 +222,14 @@ describe('self_report transition', () => { patient_id: 'martin_id' }; - db.medic.query.resolves({ rows: [{ id: 'the_contact' }] }); + getContactUuids.resolves({ data: ['the_contact'], cursor: null }); getContactWithLineage.resolves(patient); return transition.onMatch({ doc }).then(result => { chai.expect(result).to.equal(true); - chai.expect(db.medic.query.callCount).to.equal(1); - chai.expect(db.medic.query.args[0]).to.deep.equal([ - 'medic-client/contacts_by_phone', - { key: '999999' } - ]); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(getContactUuids.callCount).to.equal(1); + chai.expect(getContactUuids.args[0]).to.deep.equal([Qualifier.byPhone('999999'), null, 1]); + chai.expect(bind.calledWith(Contact.v1.getWithLineage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(1); chai.expect(getContactWithLineage.args[0]).to.deep.equal([Qualifier.byUuid('the_contact')]); chai.expect(doc).to.have.all.keys('from', 'patient', 'fields', 'tasks', 'form'); @@ -248,13 +242,13 @@ describe('self_report transition', () => { it('should throw db errors', () => { const doc = { from: 'aaa' }; - db.medic.query.rejects({ some: 'err' }); + getContactUuids.rejects({ some: 'err' }); return transition .onMatch({ doc }) .then(() => chai.assert.fail('Should have thrown')) .catch(err => { chai.expect(err).to.deep.equal({ some: 'err' }); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(bind.calledWith(Contact.v1.getUuidsPage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(0); chai.expect(doc).to.have.all.keys('from'); // no changes to the doc }); @@ -262,7 +256,7 @@ describe('self_report transition', () => { it('should throw lineage errors', () => { const doc = { from: '654987' }; - db.medic.query.resolves({ rows: [{ id: 'the_contact' }] }); + getContactUuids.resolves({ data: ['the_contact'], cursor: null }); getContactWithLineage.rejects({ other: 'err' }); return transition @@ -270,8 +264,8 @@ describe('self_report transition', () => { .then(() => chai.assert.fail('Should have thrown')) .catch(err => { chai.expect(err).to.deep.equal({ other: 'err' }); - chai.expect(db.medic.query.callCount).to.equal(1); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(getContactUuids.callCount).to.equal(1); + chai.expect(bind.calledWith(Contact.v1.getWithLineage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(1); chai.expect(doc).to.have.all.keys('from'); // no changes to the doc }); @@ -295,17 +289,14 @@ describe('self_report transition', () => { patient_id: 'stan' }; - db.medic.query.resolves({ rows: [{ id: 'contact_uuid' }] }); + getContactUuids.resolves({ data: ['contact_uuid'], cursor: null }); getContactWithLineage.resolves(patient); return transition.onMatch({ doc }).then(result => { chai.expect(result).to.equal(true); - chai.expect(db.medic.query.callCount).to.equal(1); - chai.expect(db.medic.query.args[0]).to.deep.equal([ - 'medic-client/contacts_by_phone', - { key: '111222333' } - ]); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(getContactUuids.callCount).to.equal(1); + chai.expect(getContactUuids.args[0]).to.deep.equal([Qualifier.byPhone('111222333'), null, 1]); + chai.expect(bind.calledWith(Contact.v1.getWithLineage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(1); chai.expect(getContactWithLineage.args[0]).to.deep.equal([Qualifier.byUuid('contact_uuid')]); chai.expect(doc).to.deep.equal({ @@ -333,14 +324,14 @@ describe('self_report transition', () => { patient_id: 'stan' }; - db.medic.query.resolves({ rows: [{ id: 'contact1' }, { id: 'contact2' }] }); + getContactUuids.resolves({ data: ['contact1', 'contact2'], cursor: null }); getContactWithLineage.resolves(patient); return transition.onMatch({ doc }).then(result => { chai.expect(result).to.equal(true); - chai.expect(db.medic.query.callCount).to.equal(1); - chai.expect(db.medic.query.args[0]).to.deep.equal([ 'medic-client/contacts_by_phone', { key: '98765' } ]); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(getContactUuids.callCount).to.equal(1); + chai.expect(getContactUuids.args[0]).to.deep.equal([Qualifier.byPhone('98765'), null, 1]); + chai.expect(bind.calledWith(Contact.v1.getWithLineage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(1); chai.expect(getContactWithLineage.args[0]).to.deep.equal([Qualifier.byUuid('contact1')]); chai.expect(doc).to.deep.equal({ @@ -390,17 +381,14 @@ describe('self_report transition', () => { patient_id: 'martin_id' }; - db.medic.query.resolves({ rows: [{ id: 'the_contact' }] }); + getContactUuids.resolves({ data: ['the_contact'], cursor: null }); getContactWithLineage.resolves(patient); return transition.onMatch({ doc }).then(result => { chai.expect(result).to.equal(true); - chai.expect(db.medic.query.callCount).to.equal(1); - chai.expect(db.medic.query.args[0]).to.deep.equal([ - 'medic-client/contacts_by_phone', - { key: '999999' } - ]); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(getContactUuids.callCount).to.equal(1); + chai.expect(getContactUuids.args[0]).to.deep.equal([Qualifier.byPhone('999999'), null, 1]); + chai.expect(bind.calledWith(Contact.v1.getWithLineage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(1); chai.expect(getContactWithLineage.args[0]).to.deep.equal([Qualifier.byUuid('the_contact')]); chai.expect(doc).to.have.all.keys('from', 'patient', 'fields', 'tasks', 'form', 'locale'); @@ -411,7 +399,7 @@ describe('self_report transition', () => { }); }); - it('should add task if a message is configured and sender not found', () => { + it('should add task if a message is configured and sender not found - sms locale', () => { config.get.returns([ { form: 'the_form', messages: [ @@ -435,14 +423,14 @@ describe('self_report transition', () => { sw: { the_message: 'not english', other_message: 'msg' }, }); - db.medic.query.resolves({rows: []}); + getContactUuids.resolves({ data: [], cursor: null }); const doc = { from: '12345', form: 'the_form', sms_message: { locale: 'sw' }}; return transition.onMatch({doc}) .then(result => { chai.expect(result).to.equal(true); - chai.expect(db.medic.query.callCount).to.equal(1); - chai.expect(db.medic.query.args[0]).to.deep.equal([ 'medic-client/contacts_by_phone', { key: '12345' } ]); - chai.expect(dataContext.bind.calledOnceWithExactly(Contact.v1.getWithLineage)).to.be.true; + chai.expect(getContactUuids.callCount).to.equal(1); + chai.expect(getContactUuids.args[0]).to.deep.equal([Qualifier.byPhone('12345'), null, 1]); + chai.expect(bind.calledWith(Contact.v1.getWithLineage)).to.be.true; chai.expect(getContactWithLineage.callCount).to.equal(0); chai.expect(doc.tasks.length).to.equal(1); chai.expect(doc.tasks[0].messages[0]).to.include({ to: '12345', message: 'not english' }); @@ -450,4 +438,3 @@ describe('self_report transition', () => { }); }); }); - diff --git a/shared-libs/transitions/test/unit/transitions/update_clinics.js b/shared-libs/transitions/test/unit/transitions/update_clinics.js index 599e36da5f0..4a4fe322137 100644 --- a/shared-libs/transitions/test/unit/transitions/update_clinics.js +++ b/shared-libs/transitions/test/unit/transitions/update_clinics.js @@ -1,6 +1,6 @@ const sinon = require('sinon'); const assert = require('chai').assert; -const { Person, Qualifier } = require('@medic/cht-datasource'); +const { Contact, Person, Qualifier } = require('@medic/cht-datasource'); const db = require('../../../src/db'); const config = require('../../../src/config'); const dataContext = require('../../../src/data-context'); @@ -9,6 +9,7 @@ const { CONTACT_TYPES, DOC_TYPES } = require('@medic/constants'); const phone = '+34567890123'; let transition; +let getContactUuids; let getContactWithLineage; describe('update clinic', () => { @@ -19,11 +20,12 @@ describe('update clinic', () => { getTranslations: sinon.stub().returns({}) }); transition = require('../../../src/transitions/update_clinics'); - dataContext.init({ bind: sinon.stub() }); + getContactUuids = sinon.stub(); getContactWithLineage = sinon.stub(); - dataContext.init({ - bind: sinon.stub().returns(getContactWithLineage), - }); + const bind = sinon.stub().returns(getContactWithLineage); + bind.withArgs(Contact.v1.getUuidsPage).returns(getContactUuids); + bind.withArgs(Contact.v1.getWithLineage).returns(getContactWithLineage); + dataContext.init({ bind }); }); afterEach(() => { @@ -89,13 +91,15 @@ describe('update clinic', () => { }, }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ id: contact._id }] }); + getContactUuids.resolves({ data: [contact._id], cursor: null }); getContactWithLineage.resolves(contact); return transition.onMatch({ doc: doc }).then(changed => { assert(changed); assert(doc.contact); assert(!doc.contact.phone); + assert.deepEqual(getContactUuids.args[0], [Qualifier.byPhone(phone), null, 1]); + assert.deepEqual(getContactWithLineage.args[0], [Qualifier.byUuid(contact._id)]); }); }); @@ -105,7 +109,7 @@ describe('update clinic', () => { from: 'WRONG', content_type: 'xml' }; - sinon.stub(db.medic, 'query').resolves({ rows: [] }); + getContactUuids.resolves({ data: [], cursor: null }); return transition.onMatch({ doc: doc }).then(changed => { assert(!changed); assert(!doc.contact); @@ -253,16 +257,16 @@ describe('update clinic', () => { }); }); - it('from field is cast to string in view query', () => { + it('from field is cast to string in phone qualifier', () => { const change = { doc: { from: 123, type: DOC_TYPES.DATA_RECORD, }, }; - const view = sinon.stub(db.medic, 'query').resolves({ rows: [] }); + getContactUuids.resolves({ data: [], cursor: null }); return transition.onMatch(change).then(() => { - assert.equal(view.args[0][1].key, '123'); + assert.deepEqual(getContactUuids.args[0], [Qualifier.byPhone('123'), null, 1]); }); }); @@ -272,8 +276,8 @@ describe('update clinic', () => { type: DOC_TYPES.DATA_RECORD, }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ id: 'someID' }] }); - getContactWithLineage.withArgs('someID').rejects('some error'); + getContactUuids.resolves({ data: ['someID'], cursor: null }); + getContactWithLineage.withArgs(Qualifier.byUuid('someID')).rejects('some error'); return transition.onMatch({ doc: doc }).catch(err => { assert.equal(err, 'some error'); @@ -286,7 +290,7 @@ describe('update clinic', () => { type: DOC_TYPES.DATA_RECORD, }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuids.resolves({ data: [], cursor: null }); return transition.onMatch({ doc }).then(changed => { assert(changed); assert(!doc.contact); @@ -302,7 +306,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuids.resolves({ data: [], cursor: null }); config.get.withArgs('forms').returns({ 'other': {} }); return transition.onMatch({ doc }).then(changed => { @@ -321,7 +325,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuids.resolves({ data: [], cursor: null }); const stubbedConfig = config.get; stubbedConfig.returns([ { form: 'someForm', @@ -361,7 +365,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuids.resolves({ data: [], cursor: null }); sinon.stub(utils, 'translate').returns('facility not found'); const stubbedConfig = config.get; stubbedConfig.returns([ { @@ -396,7 +400,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuids.resolves({ data: [], cursor: null }); config.get.withArgs('forms').returns({ 'someForm': {} }); sinon.stub(utils, 'translate').returns('facility not found'); @@ -420,7 +424,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuids.resolves({ data: [], cursor: null }); config.get.withArgs('forms').returns({ 'other': {} }); return transition.onMatch({ doc }).then(changed => { @@ -440,7 +444,7 @@ describe('update clinic', () => { content_type: 'xml' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuids.resolves({ data: [], cursor: null }); return transition.onMatch({ doc }).then(changed => { assert(!changed); @@ -456,7 +460,7 @@ describe('update clinic', () => { form: 'someForm', }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuids.resolves({ data: [], cursor: null }); config.get.withArgs('forms').returns({ 'someForm': { public_form: true } }); return transition.onMatch({ doc }).then(changed => { diff --git a/shared-libs/transitions/test/unit/transitions/update_sent_by.js b/shared-libs/transitions/test/unit/transitions/update_sent_by.js index 95d34b1ab7e..85d950e460a 100644 --- a/shared-libs/transitions/test/unit/transitions/update_sent_by.js +++ b/shared-libs/transitions/test/unit/transitions/update_sent_by.js @@ -1,13 +1,17 @@ const sinon = require('sinon'); const assert = require('chai').assert; -const db = require('../../../src/db'); const config = require('../../../src/config'); +const dataContext = require('../../../src/data-context'); +const { Contact, Qualifier } = require('@medic/cht-datasource'); describe('update sent by', () => { let transition; + let getContactDocs; beforeEach(() => { config.init({ getAll: sinon.stub().returns({}), }); + getContactDocs = sinon.stub(); + dataContext.init({ bind: sinon.stub().returns(getContactDocs) }); transition = require('../../../src/transitions/update_sent_by'); }); @@ -18,17 +22,19 @@ describe('update sent by', () => { it('updates sent_by to clinic name if contact name', () => { const doc = { from: '+34567890123' }; - const dbView = sinon.stub(db.medic, 'query').resolves({ rows: [ { doc: { name: 'Clinic' } } ] } ); + getContactDocs.resolves({ data: [ { name: 'Clinic' } ], cursor: null }); return transition.onMatch({ doc: doc }).then(changed => { assert(changed); assert.equal(doc.sent_by, 'Clinic'); - assert(dbView.calledOnce); + assert(dataContext.bind.calledOnceWithExactly(Contact.v1.getPage)); + assert(getContactDocs.calledOnce); + assert.deepEqual(getContactDocs.args[0], [Qualifier.byPhone('+34567890123'), null, 1]); }); }); it('sent_by untouched if nothing available', () => { const doc = { from: 'unknown number' }; - sinon.stub(db.medic, 'query').resolves({}); + getContactDocs.resolves({ data: [], cursor: null }); return transition.onMatch({ doc: doc }).then(changed => { assert(!changed); assert.strictEqual(doc.sent_by, undefined); diff --git a/shared-libs/validation/src/validation_utils.js b/shared-libs/validation/src/validation_utils.js index b7b36c8cb58..861fe8e1119 100644 --- a/shared-libs/validation/src/validation_utils.js +++ b/shared-libs/validation/src/validation_utils.js @@ -3,7 +3,7 @@ const moment = require('moment'); const logger = require('@medic/logger'); const phoneNumberParser = require('@medic/phone-number'); const config = require('../../transitions/src/config'); -const { Qualifier, Report } = require('@medic/cht-datasource'); +const { Contact, Qualifier, Report } = require('@medic/cht-datasource'); let db; let dataContext; @@ -143,8 +143,15 @@ const validPhone = (value) => { }; const uniquePhone = async (value) => { - const results = await db.medic.query('medic-client/contacts_by_phone', { key: value }); - return !results?.rows?.length; + if (!value) { + // No phone provided, so it cannot clash with an existing contact. This mirrors the previous behaviour of + // querying the view with an empty key (which returned no rows). + return true; + } + const getContactUuids = dataContext.bind(Contact.v1.getUuids); + const generator = getContactUuids(Qualifier.byPhone(String(value))); + const { done } = await generator.next(); + return done; }; module.exports = { diff --git a/shared-libs/validation/test/validations.js b/shared-libs/validation/test/validations.js index 72dca815e95..5e7a4068826 100644 --- a/shared-libs/validation/test/validations.js +++ b/shared-libs/validation/test/validations.js @@ -3,7 +3,7 @@ const sinon = require('sinon'); const assert = require('chai').assert; const validation = require('../src/validation'); const logger = require('@medic/logger'); -const { Qualifier, Report } = require('@medic/cht-datasource'); +const { Contact, Qualifier, Report } = require('@medic/cht-datasource'); moment.suppressDeprecationWarnings = true; @@ -303,15 +303,11 @@ describe('validations', () => { }); }); - it('unique phone validation should fail if db query for phone returns doc', () => { - sinon.stub(db.medic, 'query').resolves({ - rows: [ - { - id: 'original', - phone: '+9779841111111' - } - ] - }); + it('unique phone validation should fail if a contact with the phone exists', () => { + const contactGetUuids = sinon.stub().returns((async function* () { + yield 'original'; + })()); + dataContext.bind.withArgs(Contact.v1.getUuids).returns(contactGetUuids); const validations = [ { property: 'phone_number', @@ -326,10 +322,11 @@ describe('validations', () => { ]; const doc = { _id: 'duplicate', - xyz: '+9779841111111', + phone_number: '+9779841111111', }; return validation.validate(doc, validations).then(errors => { assert.equal(errors.length, 1); + assert.deepEqual(contactGetUuids.args[0], [Qualifier.byPhone('+9779841111111')]); }); }); @@ -458,8 +455,10 @@ describe('validations', () => { }); }); - it('unique phone validation should pass if db query for phone does not return any doc', () => { - sinon.stub(db.medic, 'query').resolves({ undefined }); + it('unique phone validation should pass if no contact with the phone exists', () => { + + const contactGetUuids = sinon.stub().returns((async function* () {})()); + dataContext.bind.withArgs(Contact.v1.getUuids).returns(contactGetUuids); const validations = [ { property: 'phone_number', @@ -474,10 +473,11 @@ describe('validations', () => { ]; const doc = { _id: 'unique', - xyz: '+9779841111111', + phone_number: '+9779841111111', }; return validation.validate(doc, validations).then(errors => { assert.equal(errors.length, 0); + assert.deepEqual(contactGetUuids.args[0], [Qualifier.byPhone('+9779841111111')]); }); }); diff --git a/webapp/src/js/enketo/widgets/phone-widget.js b/webapp/src/js/enketo/widgets/phone-widget.js index 145a6c8d20c..7235c2be262 100644 --- a/webapp/src/js/enketo/widgets/phone-widget.js +++ b/webapp/src/js/enketo/widgets/phone-widget.js @@ -15,10 +15,14 @@ const isContactPhoneValid = (settings, fieldValue) => { return false; }; -const getContactIdsForPhone = (phoneNumber) => window.CHTCore.DB - .get() - .query('medic-client/contacts_by_phone', { key: phoneNumber }) - .then(results => results.rows.map(row => row.id)); +const getContactIdsForPhone = async (phoneNumber) => { + const datasource = await window.CHTCore.CHTDatasource.get(); + const ids = []; + for await (const id of datasource.v1.contact.getUuidsByPhone(phoneNumber)) { + ids.push(id); + } + return ids; +}; const isContactPhoneUnique = async (settings, fieldValue) => { const normalizedNumber = phoneNumber.normalize(settings, fieldValue); diff --git a/webapp/src/ts/services/integration-api.service.ts b/webapp/src/ts/services/integration-api.service.ts index cbd18bc7ef0..b91fa53355d 100644 --- a/webapp/src/ts/services/integration-api.service.ts +++ b/webapp/src/ts/services/integration-api.service.ts @@ -10,6 +10,7 @@ import { DbService } from '@mm-services/db.service'; import { EnketoService } from '@mm-services/enketo.service'; import { TranslateService } from '@mm-services/translate.service'; import { InteractionTrackingService } from '@mm-services/interaction-tracking.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; @Injectable({ providedIn: 'root' @@ -26,6 +27,7 @@ export class IntegrationApiService { AndroidApi; DB; InteractionTracking; + CHTDatasource; constructor( private dbService:DbService, @@ -38,6 +40,7 @@ export class IntegrationApiService { private settingsService:SettingsService, private androidApiService:AndroidApiService, private readonly interactionTrackingService:InteractionTrackingService, + private readonly chtDatasourceService:CHTDatasourceService, ) { this.DB = dbService; this.AndroidAppLauncher = androidAppLauncherService; @@ -49,6 +52,7 @@ export class IntegrationApiService { this.AndroidApi = androidApiService; this.Translate = translateService; this.InteractionTracking = interactionTrackingService; + this.CHTDatasource = chtDatasourceService; } get(service) { diff --git a/webapp/tests/karma/js/enketo/widgets/phone-widget.spec.ts b/webapp/tests/karma/js/enketo/widgets/phone-widget.spec.ts index c98feefd618..074383630fc 100644 --- a/webapp/tests/karma/js/enketo/widgets/phone-widget.spec.ts +++ b/webapp/tests/karma/js/enketo/widgets/phone-widget.spec.ts @@ -16,13 +16,20 @@ describe('Enketo: Phone Widget', () => { let phoneNumberValidate; let phoneNumberNormalize; let settingsService; - let dbQuery; - let dbService; + let getUuidsByPhone; + let datasourceGet; + let chtDatasourceService; let originalCHTCore; const inputSelector = (name) => $('input[name="' + name + '"]'); const proxySelector = (name) => inputSelector(name).prev(); + const asyncGeneratorOf = (values) => (async function* () { + for (const value of values) { + yield value; + } + })(); + const buildHtml = (chtUniqueTel?: string) => { const chtUniqueTelData = chtUniqueTel === undefined ? '' : `data-cht-unique_tel="${chtUniqueTel}"`; @@ -62,11 +69,12 @@ describe('Enketo: Phone Widget', () => { phoneNumberValidate = sinon.stub(phoneNumber, 'validate'); phoneNumberNormalize = sinon.stub(phoneNumber, 'normalize').returns(NORMALIZED_NUMBER); settingsService = { get: sinon.stub().resolves(SETTINGS) }; - dbQuery = sinon.stub().resolves({ rows: [] }); - dbService = { get: sinon.stub().returns({ query: dbQuery }) }; + getUuidsByPhone = sinon.stub().returns(asyncGeneratorOf([])); + datasourceGet = sinon.stub().resolves({ v1: { contact: { getUuidsByPhone } } }); + chtDatasourceService = { get: datasourceGet }; window.CHTCore = { Settings: settingsService, - DB: dbService + CHTDatasource: chtDatasourceService }; }); @@ -225,8 +233,8 @@ describe('Enketo: Phone Widget', () => { expect(settingsService.get.calledOnceWithExactly()).to.be.true; expect(phoneNumberValidate.calledOnceWithExactly(SETTINGS, DENORMALIZED_NUMBER)).to.be.true; expect(phoneNumberNormalize.calledOnceWithExactly(SETTINGS, DENORMALIZED_NUMBER)).to.be.true; - expect(dbService.get.calledOnceWithExactly()).to.be.true; - expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_phone', { key: NORMALIZED_NUMBER })).to.be.true; + expect(datasourceGet.calledOnceWithExactly()).to.be.true; + expect(getUuidsByPhone.calledOnceWithExactly(NORMALIZED_NUMBER)).to.be.true; expect(consoleError.notCalled).to.be.true; }); @@ -239,15 +247,15 @@ describe('Enketo: Phone Widget', () => { expect(settingsService.get.calledOnceWithExactly()).to.be.true; expect(phoneNumberValidate.calledOnceWithExactly(SETTINGS, DENORMALIZED_NUMBER)).to.be.true; expect(phoneNumberNormalize.notCalled).to.be.true; - expect(dbService.get.notCalled).to.be.true; - expect(dbQuery.notCalled).to.be.true; + expect(datasourceGet.notCalled).to.be.true; + expect(getUuidsByPhone.notCalled).to.be.true; expect(consoleError.calledOnceWithExactly(`invalid phone number: "${DENORMALIZED_NUMBER}"`)).to.be.true; }); it('returns false for duplicate phone number', async () => { buildContactFormHtml('my-contact-id'); phoneNumberValidate.returns(true); - dbQuery.resolves({ rows: [{ id: 'some-id' }] }); + getUuidsByPhone.returns(asyncGeneratorOf(['some-id'])); const result = await FormModel.prototype.types.unique_tel.validate(DENORMALIZED_NUMBER); @@ -255,8 +263,8 @@ describe('Enketo: Phone Widget', () => { expect(settingsService.get.calledOnceWithExactly()).to.be.true; expect(phoneNumberValidate.calledOnceWithExactly(SETTINGS, DENORMALIZED_NUMBER)).to.be.true; expect(phoneNumberNormalize.calledOnceWithExactly(SETTINGS, DENORMALIZED_NUMBER)).to.be.true; - expect(dbService.get.calledOnceWithExactly()).to.be.true; - expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_phone', { key: NORMALIZED_NUMBER })).to.be.true; + expect(datasourceGet.calledOnceWithExactly()).to.be.true; + expect(getUuidsByPhone.calledOnceWithExactly(NORMALIZED_NUMBER)).to.be.true; expect(consoleError.calledOnceWithExactly(`phone number not unique: "${DENORMALIZED_NUMBER}"`)).to.be.true; }); @@ -264,7 +272,7 @@ describe('Enketo: Phone Widget', () => { const contactId = 'my-contact-id'; buildContactFormHtml(contactId); phoneNumberValidate.returns(true); - dbQuery.resolves({ rows: [{ id: contactId }] }); + getUuidsByPhone.returns(asyncGeneratorOf([contactId])); const result = await FormModel.prototype.types.unique_tel.validate(DENORMALIZED_NUMBER); @@ -272,8 +280,8 @@ describe('Enketo: Phone Widget', () => { expect(settingsService.get.calledOnceWithExactly()).to.be.true; expect(phoneNumberValidate.calledOnceWithExactly(SETTINGS, DENORMALIZED_NUMBER)).to.be.true; expect(phoneNumberNormalize.calledOnceWithExactly(SETTINGS, DENORMALIZED_NUMBER)).to.be.true; - expect(dbService.get.calledOnceWithExactly()).to.be.true; - expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_phone', { key: NORMALIZED_NUMBER })).to.be.true; + expect(datasourceGet.calledOnceWithExactly()).to.be.true; + expect(getUuidsByPhone.calledOnceWithExactly(NORMALIZED_NUMBER)).to.be.true; expect(consoleError.notCalled).to.be.true; }); }); @@ -288,7 +296,7 @@ describe('Enketo: Phone Widget', () => { expect(settingsService.get.calledOnceWithExactly()).to.be.true; expect(phoneNumberValidate.calledOnceWithExactly(SETTINGS, DENORMALIZED_NUMBER)).to.be.true; expect(phoneNumberNormalize.notCalled).to.be.true; - expect(dbService.get.notCalled).to.be.true; + expect(datasourceGet.notCalled).to.be.true; expect(consoleError.notCalled).to.be.true; }); @@ -301,7 +309,7 @@ describe('Enketo: Phone Widget', () => { expect(settingsService.get.calledOnceWithExactly()).to.be.true; expect(phoneNumberValidate.calledOnceWithExactly(SETTINGS, DENORMALIZED_NUMBER)).to.be.true; expect(phoneNumberNormalize.notCalled).to.be.true; - expect(dbService.get.notCalled).to.be.true; + expect(datasourceGet.notCalled).to.be.true; expect(consoleError.calledOnceWithExactly(`invalid phone number: "${DENORMALIZED_NUMBER}"`)).to.be.true; }); }); From cea897b27d0fc248cc5db397d27408127fe96fcf Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Wed, 15 Jul 2026 09:34:34 -0600 Subject: [PATCH 2/3] fix(#10973): fixing test --- .../api/controllers/contact.spec.js | 27 +++++++++++++++++-- .../web-components/cht-form/src/app.module.ts | 5 +++- .../src/stubs/cht-datasource.service.ts | 16 +++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/tests/integration/api/controllers/contact.spec.js b/tests/integration/api/controllers/contact.spec.js index 6d466f5055b..35feaaaa77e 100644 --- a/tests/integration/api/controllers/contact.spec.js +++ b/tests/integration/api/controllers/contact.spec.js @@ -340,6 +340,19 @@ describe('Contact API', () => { expect(responseCursor).to.not.equal(emptyNouveauCursor); }); + it('returns a page of contact ids for the given phone', async () => { + const opts = { + path: `${endpoint}`, + qs: { + phone: patient.phone + } + }; + const responsePage = await utils.request(opts); + + expect(responsePage.data).to.deep.equal([patient._id]); + expect(responsePage.cursor).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 qs = { @@ -702,13 +715,23 @@ describe('Contact API', () => { expect(responseIds).to.deep.equalInAnyOrder(allContactIds); }); - it('throws 400 error when neither ids nor type is provided', async () => { + it('throws 400 error when neither ids, type nor phone is provided', async () => { const opts = { path: endpoint }; await expect(utils.request(opts)).to.be.rejectedWith( - `400 - {"code":400,"error":"Either query param ids or type is required"}` + `400 - {"code":400,"error":"Either query param ids, type or phone is required"}` ); }); + it('returns a page of contacts for the given phone', async () => { + const responsePage = await utils.request({ path: endpoint, qs: { phone: patient.phone } }); + const responseIds = responsePage.data.map(doc => doc._id); + + expect(responseIds).to.deep.equal([patient._id]); + expect(responsePage.cursor).to.be.null; + // The doc-page returns full documents, not just ids. + responsePage.data.forEach(doc => expect(doc._rev).to.be.a('string')); + }); + it('throws 400 error when the contact type is invalid', async () => { const opts = { path: endpoint, qs: { type: 'invalidPerson' } }; await expect(utils.request(opts)) diff --git a/webapp/web-components/cht-form/src/app.module.ts b/webapp/web-components/cht-form/src/app.module.ts index 8efdd497674..7776291e392 100644 --- a/webapp/web-components/cht-form/src/app.module.ts +++ b/webapp/web-components/cht-form/src/app.module.ts @@ -10,6 +10,7 @@ import { TranslateService } from '@mm-services/translate.service'; import { HttpClientModule } from '@angular/common/http'; import { StoreModule } from '@ngrx/store'; import { LanguageService } from '@mm-services/language.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; @NgModule({ imports: [ @@ -35,7 +36,8 @@ export class AppModule implements DoBootstrap { constructor( injector: Injector, private readonly dbService: DbService, - private readonly translateService: TranslateService + private readonly translateService: TranslateService, + private readonly chtDatasourceService: CHTDatasourceService ) { const chtForm = createCustomElement(AppComponent, { injector }); customElements.define('cht-form', chtForm); @@ -52,6 +54,7 @@ export class AppModule implements DoBootstrap { Settings: { get: async () => ({ default_country_code: '1' }) }, Translate: this.translateService, DB: this.dbService, + CHTDatasource: this.chtDatasourceService, }; } } diff --git a/webapp/web-components/cht-form/src/stubs/cht-datasource.service.ts b/webapp/web-components/cht-form/src/stubs/cht-datasource.service.ts index e33763bc480..69f652c5ea7 100644 --- a/webapp/web-components/cht-form/src/stubs/cht-datasource.service.ts +++ b/webapp/web-components/cht-form/src/stubs/cht-datasource.service.ts @@ -13,6 +13,22 @@ export class CHTDatasourceService { } }; } + async get() { + return { + v1: { + contact: { + // No contacts are available in the standalone cht-form, so nothing matches a phone number. + // Used by phone-widget to look for contacts with the same phone number. + getUuidsByPhone: () => ({ + [Symbol.asyncIterator]: () => ({ + next: () => Promise.resolve({ done: true, value: undefined }) + }) + }) + } + } + }; + } + bind Promise>( _: (ctx: DataContext) => F ): (...p: Parameters) => ReturnType { From 9bf7dff62060c2cf685c1fcd40bfde8b0f583925 Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Thu, 16 Jul 2026 12:06:30 -0600 Subject: [PATCH 3/3] fix(#10973): sonar --- admin/src/js/services/message-queue.js | 30 +++++++++---------- api/src/controllers/contact.js | 27 +++++++++-------- .../src/libs/parameter-validators.ts | 7 +++-- .../api/controllers/contact.spec.js | 2 +- .../src/stubs/cht-datasource.service.ts | 4 +-- .../stubs/cht-datasource.service.spec.ts | 21 +++++++++++++ 6 files changed, 59 insertions(+), 32 deletions(-) diff --git a/admin/src/js/services/message-queue.js b/admin/src/js/services/message-queue.js index 676232e421d..dc904b50779 100644 --- a/admin/src/js/services/message-queue.js +++ b/admin/src/js/services/message-queue.js @@ -4,6 +4,20 @@ const registrationUtils = require('@medic/registration-utils'); const constants = require('@medic/constants'); const DOC_TYPES = constants.DOC_TYPES; +const collect = function(generator) { + const results = []; + const iterate = function() { + return generator.next().then(function(result) { + if (result.done) { + return results; + } + results.push(result.value); + return iterate(); + }); + }; + return iterate(); +}; + angular.module('services').factory('MessageQueueUtils', function( $q, @@ -36,20 +50,6 @@ angular.module('services').factory('MessageQueue', 'use strict'; 'ngInject'; - const collect = function(generator) { - const results = []; - const iterate = function() { - return generator.next().then(function(result) { - if (result.done) { - return results; - } - results.push(result.value); - return iterate(); - }); - }; - return iterate(); - }; - const findSummary = function(summaries, message) { if (!message.sms || !message.sms.to) { return; @@ -116,7 +116,7 @@ angular.module('services').factory('MessageQueue', })); }) .then(function(idsPerPhone) { - const ids = compactUnique([].concat.apply([], idsPerPhone)); + const ids = compactUnique(idsPerPhone.flat()); return GetSummaries.getContacts(ids); }) diff --git a/api/src/controllers/contact.js b/api/src/controllers/contact.js index 53a3573bfba..e682c8e713b 100644 --- a/api/src/controllers/contact.js +++ b/api/src/controllers/contact.js @@ -17,6 +17,20 @@ const buildIdsQualifier = (ids) => { return Qualifier.byIds(idsArray); }; +const buildFreetextTypePhoneQualifier = (query) => { + if (query.phone) { + return Qualifier.byPhone(query.phone); + } + const qualifier = {}; + if (query.freetext) { + Object.assign(qualifier, Qualifier.byFreetext(query.freetext)); + } + if (query.type) { + Object.assign(qualifier, Qualifier.byContactType(query.type)); + } + return qualifier; +}; + /** * @openapi * tags: @@ -142,18 +156,7 @@ module.exports = { { status: 400, message: 'Either query param freetext, type or phone is required' }, req, res ); } - let qualifier; - if (req.query.phone) { - qualifier = Qualifier.byPhone(req.query.phone); - } else { - 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 qualifier = buildFreetextTypePhoneQualifier(req.query); const docs = await getContactIds(qualifier, req.query.cursor, req.query.limit); return res.json(docs); }), diff --git a/shared-libs/cht-datasource/src/libs/parameter-validators.ts b/shared-libs/cht-datasource/src/libs/parameter-validators.ts index 883030325a2..e8537a803d3 100644 --- a/shared-libs/cht-datasource/src/libs/parameter-validators.ts +++ b/shared-libs/cht-datasource/src/libs/parameter-validators.ts @@ -124,10 +124,13 @@ export const assertFreetextQualifier: (qualifier: unknown) => asserts qualifier } }; +type ContactTypeFreetextPhoneQualifier = ContactTypeQualifier | FreetextQualifier | PhoneQualifier; +type ContactTypeIdsPhoneQualifier = ContactTypeQualifier | IdsQualifier | PhoneQualifier; + /** @internal */ export const assertContactTypeFreetextQualifier: ( qualifier: unknown -) => asserts qualifier is ContactTypeQualifier | FreetextQualifier | PhoneQualifier = ( +) => asserts qualifier is ContactTypeFreetextPhoneQualifier = ( qualifier: unknown ) => { if (!(isContactTypeQualifier(qualifier) || isFreetextQualifier(qualifier) || isPhoneQualifier(qualifier))) { @@ -140,7 +143,7 @@ export const assertContactTypeFreetextQualifier: ( /** @internal */ export const assertContactTypeIdsQualifier: ( qualifier: unknown -) => asserts qualifier is ContactTypeQualifier | IdsQualifier | PhoneQualifier = (qualifier: unknown) => { +) => asserts qualifier is ContactTypeIdsPhoneQualifier = (qualifier: unknown) => { if (!(isContactTypeQualifier(qualifier) || isIdsQualifier(qualifier) || isPhoneQualifier(qualifier))) { throw new InvalidArgumentError( `Invalid qualifier [${JSON.stringify(qualifier)}]. Must be a contact type, ids, or phone qualifier.` diff --git a/tests/integration/api/controllers/contact.spec.js b/tests/integration/api/controllers/contact.spec.js index 35feaaaa77e..d49a0ca4d5e 100644 --- a/tests/integration/api/controllers/contact.spec.js +++ b/tests/integration/api/controllers/contact.spec.js @@ -350,7 +350,7 @@ describe('Contact API', () => { const responsePage = await utils.request(opts); expect(responsePage.data).to.deep.equal([patient._id]); - expect(responsePage.cursor).to.be.equal(null); + expect(responsePage.cursor).to.be.null; }); it('returns a page of people type contact ids when limit and cursor is passed and cursor can be reused', diff --git a/webapp/web-components/cht-form/src/stubs/cht-datasource.service.ts b/webapp/web-components/cht-form/src/stubs/cht-datasource.service.ts index 69f652c5ea7..46b8156f88c 100644 --- a/webapp/web-components/cht-form/src/stubs/cht-datasource.service.ts +++ b/webapp/web-components/cht-form/src/stubs/cht-datasource.service.ts @@ -19,9 +19,9 @@ export class CHTDatasourceService { contact: { // No contacts are available in the standalone cht-form, so nothing matches a phone number. // Used by phone-widget to look for contacts with the same phone number. - getUuidsByPhone: () => ({ + getUuidsByPhone: (_phone: string): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ - next: () => Promise.resolve({ done: true, value: undefined }) + next: () => Promise.resolve({ done: true as const, value: undefined }) }) }) } diff --git a/webapp/web-components/cht-form/tests/karma/stubs/cht-datasource.service.spec.ts b/webapp/web-components/cht-form/tests/karma/stubs/cht-datasource.service.spec.ts index 5a6955e1c5a..48a52061e20 100644 --- a/webapp/web-components/cht-form/tests/karma/stubs/cht-datasource.service.spec.ts +++ b/webapp/web-components/cht-form/tests/karma/stubs/cht-datasource.service.spec.ts @@ -29,6 +29,27 @@ describe('CHT Datasource Service', () => { expect(result).to.be.undefined; }); + describe('get', () => { + it('exposes a contact datasource', async () => { + const chtApi = await service.get(); + + expect(chtApi).to.have.keys(['v1']); + expect(chtApi.v1).to.have.keys(['contact']); + expect(chtApi.v1.contact.getUuidsByPhone).to.be.a('function'); + }); + + it('getUuidsByPhone yields no contacts', async () => { + const chtApi = await service.get(); + + const uuids: string[] = []; + for await (const uuid of chtApi.v1.contact.getUuidsByPhone('+254712345678')) { + uuids.push(uuid); + } + + expect(uuids).to.deep.equal([]); + }); + }); + describe('addExtensionLib', () => { it('adds new function to extension library', () => { const extensionFn = `module.exports = () => 'hello world'`;