diff --git a/admin/src/js/services/message-queue.js b/admin/src/js/services/message-queue.js index e900aefdec4..1659f311d7f 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, Languages, MessageQueueUtils, Settings @@ -34,6 +35,8 @@ angular.module('services').factory('MessageQueue', 'use strict'; 'ngInject'; + const datasourcePromise = DataContext.then(dataContext => dataContext.getDatasource()); + const findSummary = function(summaries, message) { if (!message.sms || !message.sms.to) { return; @@ -94,13 +97,11 @@ 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 datasourcePromise + .then(function(datasource) { + return datasource.v1.contact.collectUuidsByPhones(phoneNumbers); + }) + .then(function(ids) { return DB({ remote: true }).query('medic/doc_summaries_by_id', { keys: ids }); }) .then(function(summaries) { diff --git a/admin/tests/unit/services/message-queue.spec.js b/admin/tests/unit/services/message-queue.spec.js index 5f890306af0..9f4a0019eb4 100644 --- a/admin/tests/unit/services/message-queue.spec.js +++ b/admin/tests/unit/services/message-queue.spec.js @@ -11,6 +11,8 @@ describe('MessageQueue service', function() { let query; let translate; let clock; + let collectUuidsByPhones; + let DataContext; beforeEach(() => { Settings = sinon.stub(); @@ -34,6 +36,9 @@ describe('MessageQueue service', function() { fillParentsInDocs: sinon.stub() } }; + collectUuidsByPhones = sinon.stub().resolves([]); + const datasource = { v1: { contact: { collectUuidsByPhones } } }; + DataContext = Promise.resolve({ getDatasource: () => datasource }); module('adminApp'); @@ -43,6 +48,7 @@ describe('MessageQueue service', function() { $provide.value('Settings', Settings); $provide.value('Languages', Languages); $provide.value('MessageQueueUtils', utils); + $provide.value('DataContext', DataContext); $provide.factory('DB', KarmaUtils.mockDB({ query: query })); }); @@ -285,9 +291,7 @@ 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' }] - }); + collectUuidsByPhones.withArgs(['phone1', 'phone2']).resolves(['contact1']); query.withArgs('medic/doc_summaries_by_id').resolves({ rows: [{ id: 'contact1', value: { name: 'James', phone: 'phone1' } }] @@ -303,14 +307,11 @@ describe('MessageQueue service', function() { return service.query('due').then(result => { chai.expect(result.total).to.equal(2); - chai.expect(query.callCount).to.equal(4); + 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(collectUuidsByPhones.calledOnceWithExactly(['phone1', 'phone2'])).to.be.true; - chai.expect(query.args[3]).to.deep.equal([ + chai.expect(query.args[2]).to.deep.equal([ 'medic/doc_summaries_by_id', { keys: ['contact1'] } ]); @@ -408,9 +409,7 @@ 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' } ] }); + collectUuidsByPhones.withArgs(['phone1', 'phone2']).resolves(['contact1', 'contact2']); query .withArgs('medic/doc_summaries_by_id') @@ -422,10 +421,8 @@ describe('MessageQueue service', function() { }); return service.query('due').then(result => { - chai.expect(query.callCount).to.equal(4); - chai.expect(query.args[2]).to.deep.equal([ - 'medic-client/contacts_by_phone', { keys: [ 'phone1', 'phone2' ]} - ]); + chai.expect(query.callCount).to.equal(3); + chai.expect(collectUuidsByPhones.calledOnceWithExactly(['phone1', 'phone2'])).to.be.true; chai.expect(result.messages[0].recipient).to.equal('contact one'); chai.expect(result.messages[1].recipient).to.equal('contact one'); @@ -587,16 +584,14 @@ describe('MessageQueue service', function() { to: recipient }])); - query - .withArgs('medic-client/contacts_by_phone') - .resolves({ rows: [{ key: 'recipient_id', value: 'recipient' }]}); + collectUuidsByPhones.withArgs(['recipient']).resolves(['recipient_id']); query .withArgs('medic/doc_summaries_by_id') .resolves({ rows: [{ key: 'recipient_id', value: { phone: 'recipient' }}]}); return service.query('tab').then(result => { chai.expect(result.messages.length).to.equal(15); - chai.expect(query.callCount).to.equal(6); + chai.expect(query.callCount).to.equal(5); chai.expect(query.args[2]).to.deep.equal([ 'medic-client/contacts_by_reference', { @@ -620,10 +615,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(collectUuidsByPhones.calledOnceWithExactly(['recipient'])).to.be.true; }); }); @@ -754,13 +746,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' } - ]}); + collectUuidsByPhones + .withArgs(['recipient1', 'recipient2', 'recipient3']) + .resolves(['recipient1_id', 'recipient2_id', 'recipient3_id']); query .withArgs('medic/doc_summaries_by_id') .resolves({ rows: [ @@ -931,10 +919,9 @@ 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(collectUuidsByPhones.calledOnceWithExactly([ + 'recipient1', 'recipient2', 'recipient3' + ])).to.be.true; chai.expect(result.messages).to.deep.equal([ { @@ -1076,9 +1063,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' }]}); + collectUuidsByPhones.withArgs(['recipient1']).resolves(['recipient_id']); query .withArgs('medic/doc_summaries_by_id') .resolves({ rows: [{ key: 'recipient_id', value: { phone: 'recipient1', name: 'recipient' }}]}); diff --git a/api/src/controllers/contact.js b/api/src/controllers/contact.js index 2a1d6d07dba..fe83c204a82 100644 --- a/api/src/controllers/contact.js +++ b/api/src/controllers/contact.js @@ -71,7 +71,9 @@ 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 qualifier param (`type`, `freetext`, or `phone`) must be provided. Qualifier + * params are mutually exclusive — the only combination allowed is `type` + `freetext` + * (backed by a dedicated view). Any other combination returns 400. * tags: [Contact] * x-since: 4.18.0 * x-permissions: @@ -82,8 +84,7 @@ 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. May be combined with `freetext`. * - in: query * name: freetext * schema: @@ -91,7 +92,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`. + * May be combined with `type`. + * - in: query + * name: phone + * schema: + * type: string + * description: > + * A phone number to match exactly against the contact's `phone` field. Passed as-is — no + * normalization is performed. Mutually exclusive with `type` / `freetext`. * - $ref: '#/components/parameters/cursor' * - $ref: '#/components/parameters/limitId' * responses: @@ -119,10 +127,37 @@ 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); + + // Qualifier params are mutually exclusive by default. `type` and `freetext` are the only + // pair that may be combined (backed by the contacts_by_type_freetext view). When a new + // qualifier is added, extend QUALIFIER_PARAMS — exclusivity is automatic. + const QUALIFIER_PARAMS = ['type', 'freetext', 'phone']; + const COMBINABLE = ['type', 'freetext']; + const present = QUALIFIER_PARAMS.filter(name => req.query[name]); + + if (!present.length) { + return serverUtils.error( + { status: 400, message: `At least one of query params ${QUALIFIER_PARAMS.join(', ')} is required` }, + req, + res + ); + } + if (present.length > 1 && !present.every(name => COMBINABLE.includes(name))) { + return serverUtils.error( + { + status: 400, + message: `Query params ${present.join(', ')} are mutually exclusive ` + + `(only ${COMBINABLE.join(' and ')} may be combined)`, + }, + req, + res + ); } + const qualifier = {}; + if (req.query.phone) { + Object.assign(qualifier, Qualifier.byPhone(req.query.phone)); + } if (req.query.freetext) { Object.assign(qualifier, Qualifier.byFreetext(req.query.freetext)); } @@ -132,5 +167,98 @@ module.exports = { const docs = await getContactIds(qualifier, req.query.cursor, req.query.limit); return res.json(docs); }), + + /** + * @openapi + * /api/v1/contact/uuid: + * post: + * summary: Get contact UUIDs (bulk variant) + * operationId: v1ContactUuidPost + * description: > + * Bulk variant of the GET endpoint. Accepts array-valued qualifiers in a JSON body — used + * when the array would not fit safely in a query string. The only multi-value qualifier + * currently accepted is `phones`. Returns the same paginated `{ data, cursor }` shape as + * the GET endpoint. + * tags: [Contact] + * x-since: 4.21.0 + * x-permissions: + * hasAll: [can_view_contacts] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * phones: + * type: array + * items: + * type: string + * description: > + * Phone numbers to match exactly against the contact's `phone` field. Passed + * as-is — no normalization. One CouchDB round trip regardless of array size. + * cursor: + * type: string + * nullable: true + * limit: + * type: integer + * required: [phones] + * responses: + * '200': + * description: A page of contact UUIDs + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * items: + * type: string + * cursor: + * $ref: '#/components/schemas/PageCursor' + * required: [data, cursor] + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ + postUuids: serverUtils.doOrError(async (req, res) => { + await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); + + // POST body mirrors the GET query-param shape, with array values where the qualifier accepts + // many. Mutual-exclusivity rules match the GET path: list the multi-value params here and + // mirror COMBINABLE. + const QUALIFIER_PARAMS = ['phones']; + const COMBINABLE = []; + const present = QUALIFIER_PARAMS.filter(name => req.body && req.body[name] !== undefined); + + if (!present.length) { + return serverUtils.error( + { status: 400, message: `At least one of body params ${QUALIFIER_PARAMS.join(', ')} is required` }, + req, + res + ); + } + if (present.length > 1 && !present.every(name => COMBINABLE.includes(name))) { + return serverUtils.error( + { + status: 400, + message: `Body params ${present.join(', ')} are mutually exclusive`, + }, + req, + res + ); + } + + const qualifier = {}; + if (req.body.phones) { + Object.assign(qualifier, Qualifier.byPhones(req.body.phones)); + } + const docs = await getContactIds(qualifier, req.body.cursor, req.body.limit); + return res.json(docs); + }), }, }; diff --git a/api/src/routing.js b/api/src/routing.js index 370f0f2cf3a..9e8e89feed1 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -738,6 +738,7 @@ app.postJson('/api/v1/person', person.v1.create); app.putJson('/api/v1/person/:uuid', person.v1.update); app.get('/api/v1/contact/uuid', contact.v1.getUuids); +app.postJson('/api/v1/contact/uuid', contact.v1.postUuids); app.get('/api/v1/contact/:uuid', contact.v1.get); app.get('/api/v1/report/uuid', report.v1.getUuids); diff --git a/api/tests/mocha/controllers/contact.spec.js b/api/tests/mocha/controllers/contact.spec.js index 47bd33027de..114ff0c841d 100644 --- a/api/tests/mocha/controllers/contact.spec.js +++ b/api/tests/mocha/controllers/contact.spec.js @@ -230,15 +230,13 @@ describe('Contact Controller', () => { expect(serverUtilsError.notCalled).to.be.true; }); - it('returns 400 error when contactType AND freetext is not present', async () => { + it('returns 400 error when no qualifier param is present', async () => { req = { query: { cursor, limit, } }; - const err = { status: 400, message: 'Either query param freetext or type is required' }; - contactGetUuidsPage.throws(err); await controller.v1.getUuids(req, res); @@ -250,7 +248,135 @@ describe('Contact Controller', () => { expect(qualifierByFreetext.notCalled).to.be.true; expect(contactGetUuidsPage.notCalled).to.be.true; expect(res.json.notCalled).to.be.true; - expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + expect(serverUtilsError.calledOnceWithExactly( + { status: 400, message: 'At least one of query params type, freetext, phone is required' }, + req, + res + )).to.be.true; + }); + + describe('phone query param', () => { + const phone = '+15551234567'; + const phoneOnlyQualifier = { phone }; + let qualifierByPhone; + + beforeEach(() => { + qualifierByPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneOnlyQualifier); + }); + + it('builds a phone qualifier when phone is the only qualifier', async () => { + req = { query: { phone, cursor, limit } }; + const expected = { data: ['uuid-1'], cursor: 'next' }; + contactGetUuidsPage.resolves(expected); + + 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(phoneOnlyQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(expected)).to.be.true; + }); + + [ + [{ type: contactType, phone }, 'type, phone'], + [{ freetext, phone }, 'freetext, phone'], + [{ type: contactType, freetext, phone }, 'type, freetext, phone'], + ].forEach(([query, presentList]) => { + it(`returns 400 when phone is combined with ${Object.keys(query).filter(k => k !== 'phone').join('/')}`, + async () => { + req = { query: { ...query, cursor, limit } }; + + await controller.v1.getUuids(req, res); + + expect(qualifierByPhone.notCalled).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(qualifierByFreetext.notCalled).to.be.true; + expect(contactGetUuidsPage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly( + { + status: 400, + message: `Query params ${presentList} are mutually exclusive ` + + '(only type and freetext may be combined)', + }, + req, + res + )).to.be.true; + }); + }); + + it('walks two cursor pages with limit 5', async () => { + const firstPage = { data: ['a', 'b', 'c', 'd', 'e'], cursor: '5' }; + const secondPage = { data: ['f'], cursor: null }; + contactGetUuidsPage.onFirstCall().resolves(firstPage); + contactGetUuidsPage.onSecondCall().resolves(secondPage); + + await controller.v1.getUuids({ query: { phone, cursor: null, limit: 5 } }, res); + expect(contactGetUuidsPage.firstCall.args).to.deep.equal([phoneOnlyQualifier, null, 5]); + expect(res.json.calledWith(firstPage)).to.be.true; + + await controller.v1.getUuids({ query: { phone, cursor: '5', limit: 5 } }, res); + expect(contactGetUuidsPage.secondCall.args).to.deep.equal([phoneOnlyQualifier, '5', 5]); + expect(res.json.calledWith(secondPage)).to.be.true; + }); + }); + }); + + describe('postUuids (bulk)', () => { + const phones = ['+15551234567', '+15559999999']; + const phonesQualifier = { phones }; + const cursor = null; + const limit = 100; + let qualifierByPhones; + + beforeEach(() => { + qualifierByPhones = sinon.stub(Qualifier, 'byPhones').returns(phonesQualifier); + }); + + it('builds a phones qualifier from the JSON body', async () => { + req = { body: { phones, cursor, limit } }; + const expected = { data: ['uuid-1', 'uuid-2'], cursor: 'next' }; + contactGetUuidsPage.resolves(expected); + + await controller.v1.postUuids(req, res); + + expect(assertPermissions.calledOnceWithExactly( + req, + { isOnline: true, hasAll: ['can_view_contacts'] } + )).to.be.true; + expect(qualifierByPhones.calledOnceWithExactly(phones)).to.be.true; + expect(contactGetUuidsPage.calledOnceWithExactly(phonesQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(expected)).to.be.true; + }); + + it('returns 400 when the body has no qualifier param', async () => { + req = { body: { cursor, limit } }; + + await controller.v1.postUuids(req, res); + + expect(qualifierByPhones.notCalled).to.be.true; + expect(contactGetUuidsPage.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly( + { status: 400, message: 'At least one of body params phones is required' }, + req, + res + )).to.be.true; + }); + + it('walks two cursor pages with limit 5', async () => { + const firstPage = { data: ['a', 'b', 'c', 'd', 'e'], cursor: '5' }; + const secondPage = { data: ['f'], cursor: null }; + contactGetUuidsPage.onFirstCall().resolves(firstPage); + contactGetUuidsPage.onSecondCall().resolves(secondPage); + + await controller.v1.postUuids({ body: { phones, cursor: null, limit: 5 } }, res); + expect(contactGetUuidsPage.firstCall.args).to.deep.equal([phonesQualifier, null, 5]); + expect(res.json.calledWith(firstPage)).to.be.true; + + await controller.v1.postUuids({ body: { phones, cursor: '5', limit: 5 } }, res); + expect(contactGetUuidsPage.secondCall.args).to.deep.equal([phonesQualifier, '5', 5]); + expect(res.json.calledWith(secondPage)).to.be.true; }); }); }); diff --git a/shared-libs/cht-datasource/src/contact.ts b/shared-libs/cht-datasource/src/contact.ts index 43231b1114e..b09be076722 100644 --- a/shared-libs/cht-datasource/src/contact.ts +++ b/shared-libs/cht-datasource/src/contact.ts @@ -4,8 +4,7 @@ import { Page, } from './libs/core'; import { - ContactTypeQualifier, - FreetextQualifier, + ContactGetUuidsQualifier, UuidQualifier } from './qualifier'; import { adapt, assertDataContext, DataContext } from './libs/data-context'; @@ -15,7 +14,7 @@ import * as Local from './local'; import * as Remote from './remote'; import { DEFAULT_IDS_PAGE_LIMIT } from './libs/constants'; import { - assertContactTypeFreetextQualifier, + assertContactGetUuidsQualifier, assertCursor, assertLimit, assertUuidQualifier, @@ -91,7 +90,11 @@ 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. May be one of: + * - a {@link ContactTypeQualifier} to filter by contact type, + * - a {@link FreetextQualifier} to filter by freetext search, + * - a {@link ContactTypeQualifier} combined with a {@link FreetextQualifier}, + * - a {@link PhoneQualifier} to filter contacts whose `phone` field matches the given value exactly. * @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. @@ -101,13 +104,13 @@ export namespace v1 { * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` */ const curriedFn = async ( - qualifier: ContactTypeQualifier | FreetextQualifier, + qualifier: ContactGetUuidsQualifier, cursor: Nullable = null, limit: number | `${number}` = DEFAULT_IDS_PAGE_LIMIT ): Promise> => { assertCursor(cursor); assertLimit(limit); - assertContactTypeFreetextQualifier(qualifier); + assertContactGetUuidsQualifier(qualifier); return fn(qualifier, cursor, Number(limit)); }; @@ -131,9 +134,9 @@ export namespace v1 { * @throws InvalidArgumentError if no qualifier is provided or if the qualifier is invalid */ const curriedGen = ( - qualifier: ContactTypeQualifier | FreetextQualifier + qualifier: ContactGetUuidsQualifier ): AsyncGenerator => { - assertContactTypeFreetextQualifier(qualifier); + assertContactGetUuidsQualifier(qualifier); return getPagedGenerator(getPage, qualifier); }; diff --git a/shared-libs/cht-datasource/src/index.ts b/shared-libs/cht-datasource/src/index.ts index a020911a09b..be600a46bf6 100644 --- a/shared-libs/cht-datasource/src/index.ts +++ b/shared-libs/cht-datasource/src/index.ts @@ -209,6 +209,56 @@ export const getDatasource = (ctx: DataContext) => { ) => ctx.bind(Contact.v1.getUuids)( Qualifier.byFreetext(freetext) ), + + /** + * Returns a generator for fetching all the contact identifiers whose `phone` field exactly + * matches the given phone number. The value is matched as-is — no normalization is performed. + * @param phone the phone number to match + * @returns a generator for fetching all matching contact identifiers + * @throws InvalidArgumentError if `phone` is not a non-empty string + */ + getUuidsByPhone: ( + phone: string, + ) => ctx.bind(Contact.v1.getUuids)( + Qualifier.byPhone(phone) + ), + + /** + * Returns all contact identifiers whose `phone` field exactly matches the given phone number, + * collected into a single array. Equivalent to draining {@link getUuidsByPhone} but returns a + * `Promise` for callers that can't consume an `AsyncGenerator` (e.g. bundles that + * don't support `for await`). + * @param phone the phone number to match + * @returns all matching contact identifiers + * @throws InvalidArgumentError if `phone` is not a non-empty string + */ + collectUuidsByPhone: async (phone: string): Promise => { + const generator = ctx.bind(Contact.v1.getUuids)(Qualifier.byPhone(phone)); + const ids: string[] = []; + for await (const id of generator) { + ids.push(id); + } + return ids; + }, + + /** + * Bulk variant of {@link collectUuidsByPhone}. Returns all contact identifiers whose `phone` + * field matches *any* of the given phone numbers, in a single round trip — the qualifier + * dispatches to one CouchDB view query (local) or one POST (remote), regardless of array + * size. Use this instead of `Promise.all` over `collectUuidsByPhone` when looking up many + * phones, to avoid the N-round-trip regression. + * @param phones the phone numbers to match. Values are passed as-is — no normalization. + * @returns all matching contact identifiers + * @throws InvalidArgumentError if `phones` is not a non-empty array of non-empty strings + */ + collectUuidsByPhones: async (phones: [string, ...string[]]): Promise => { + const generator = ctx.bind(Contact.v1.getUuids)(Qualifier.byPhones(phones)); + const ids: string[] = []; + for await (const id of generator) { + ids.push(id); + } + return ids; + }, }, place: { /** diff --git a/shared-libs/cht-datasource/src/libs/parameter-validators.ts b/shared-libs/cht-datasource/src/libs/parameter-validators.ts index 096c84e68ec..3573ee8121c 100644 --- a/shared-libs/cht-datasource/src/libs/parameter-validators.ts +++ b/shared-libs/cht-datasource/src/libs/parameter-validators.ts @@ -1,7 +1,9 @@ import { InvalidArgumentError } from './error'; import { + ContactGetUuidsQualifier, ContactTypeQualifier, FreetextQualifier, + isContactGetUuidsQualifier, isContactTypeQualifier, isFreetextQualifier, isUuidQualifier, @@ -121,14 +123,15 @@ export const assertFreetextQualifier: (qualifier: unknown) => asserts qualifier }; /** @internal */ -export const assertContactTypeFreetextQualifier: ( +export const assertContactGetUuidsQualifier: ( qualifier: unknown -) => asserts qualifier is ContactTypeQualifier | FreetextQualifier = ( +) => asserts qualifier is ContactGetUuidsQualifier = ( qualifier: unknown ) => { - if (!(isContactTypeQualifier(qualifier) || isFreetextQualifier(qualifier))) { + if (!isContactGetUuidsQualifier(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, or phone qualifier.' ); } }; @@ -141,18 +144,18 @@ export const assertUuidQualifier: (qualifier: unknown) => asserts qualifier is U }; /** @ignore */ -export const isContactType = (value: ContactTypeQualifier | FreetextQualifier): value is ContactTypeQualifier => { +export const isContactType = (value: ContactGetUuidsQualifier): value is ContactTypeQualifier => { return 'contactType' in value; }; /** @ignore */ -export const isFreetextType = (value: ContactTypeQualifier | FreetextQualifier): value is FreetextQualifier => { +export const isFreetextType = (value: ContactGetUuidsQualifier): value is FreetextQualifier => { return 'freetext' in value; }; /** @ignore */ export const isContactTypeAndFreetextType = ( - qualifier: ContactTypeQualifier | FreetextQualifier + qualifier: ContactGetUuidsQualifier ): qualifier is ContactTypeQualifier & FreetextQualifier => { return isContactType(qualifier) && isFreetextType(qualifier); }; diff --git a/shared-libs/cht-datasource/src/local/contact.ts b/shared-libs/cht-datasource/src/local/contact.ts index e888b48ac40..fbd77038224 100644 --- a/shared-libs/cht-datasource/src/local/contact.ts +++ b/shared-libs/cht-datasource/src/local/contact.ts @@ -1,11 +1,14 @@ import { LocalDataContext, SettingsService } from './libs/data-context'; -import { fetchAndFilterIds, getDocById, queryDocIdsByKey, queryDocIdsByRange } from './libs/doc'; +import { fetchAndFilterIds, getDocById, queryDocIdsByKey, queryDocIdsByKeys, queryDocIdsByRange } from './libs/doc'; import { + ContactGetUuidsQualifier, ContactTypeQualifier, FreetextQualifier, isContactTypeQualifier, isFreetextQualifier, isKeyedFreetextQualifier, + isPhoneQualifier, + isPhonesQualifier, UuidQualifier } from '../qualifier'; import * as Contact from '../contact'; @@ -105,14 +108,28 @@ 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 queryViewByPhones = queryDocIdsByKeys(medicDb, 'medic-client/contacts_by_phone'); const getOfflineFreetextQueryPageFn = getOfflineFreetextQueryFn(medicDb); const promisedUseNouveau = useNouveauIndexes(medicDb); return async ( - qualifier: ContactTypeQualifier | FreetextQualifier, + qualifier: ContactGetUuidsQualifier, 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 (isPhonesQualifier(qualifier)) { + const skip = validateCursor(cursor); + const getPageFn = (limit: number, skip: number) => queryViewByPhones(qualifier.phones, limit, skip); + return await fetchAndFilterIds(getPageFn, limit)(limit, skip); + } + if (isContactTypeQualifier(qualifier)) { assertValidContactType(settings.getAll(), qualifier); } diff --git a/shared-libs/cht-datasource/src/local/libs/doc.ts b/shared-libs/cht-datasource/src/local/libs/doc.ts index 77edb5114c0..6c2718214d1 100644 --- a/shared-libs/cht-datasource/src/local/libs/doc.ts +++ b/shared-libs/cht-datasource/src/local/libs/doc.ts @@ -127,6 +127,20 @@ export const queryDocIdsByKey = ( skip: number ): Promise => queryDocIds(db, view, { include_docs: false, reduce: false, key, limit, skip }); +/** + * Bulk variant of {@link queryDocIdsByKey}. Returns IDs of all docs whose emitted view key is in the + * provided list — one CouchDB round trip regardless of how many keys are passed. + * @internal + */ +export const queryDocIdsByKeys = ( + db: PouchDB.Database, + view: string +) => async ( + keys: unknown[], + limit: number, + skip: number +): Promise => queryDocIds(db, view, { include_docs: false, reduce: false, keys, limit, skip }); + /** * Resolves a page containing an array of T using the getFunction to retrieve documents from the database * and the filterFunction to validate the returned documents are all type T. diff --git a/shared-libs/cht-datasource/src/qualifier.ts b/shared-libs/cht-datasource/src/qualifier.ts index e8b2f433264..d66952e9d3d 100644 --- a/shared-libs/cht-datasource/src/qualifier.ts +++ b/shared-libs/cht-datasource/src/qualifier.ts @@ -144,6 +144,90 @@ export const isKeyedFreetextQualifier = (qualifier: FreetextQualifier): boolean return false; }; +/** + * A qualifier that identifies entities based on a phone number. The value is matched against the + * `phone` field of the contact as-is — no normalization (whitespace, country code, etc.) is + * performed by the qualifier; matching parity with the underlying view is the caller's responsibility. + */ +export type PhoneQualifier = Readonly<{ phone: string }>; + +/** + * Builds a qualifier for finding entities by their `phone` field. + * @param phone the phone number to search for. Passed as-is to the underlying view. + * @returns the qualifier + * @throws InvalidArgumentError if the phone 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' }); +}; + +/** + * Bulk variant of {@link PhoneQualifier} — identifies contacts whose `phone` field matches any value + * in the provided list. Use this when looking up many phones in a single call to avoid N round trips. + * Values are matched as-is — no normalization. + */ +export type PhonesQualifier = Readonly<{ phones: [string, ...string[]] }>; + +/** + * Builds a qualifier for finding entities whose `phone` field matches any of the given values. + * @param phones the phone numbers to search for. Passed as-is to the underlying view. + * @returns the qualifier + * @throws InvalidArgumentError if `phones` is not a non-empty array of non-empty strings + */ +export const byPhones = (phones: [string, ...string[]]): PhonesQualifier => { + if (!Array.isArray(phones) || phones.length === 0 || !phones.every(p => isString(p) && p.length > 0)) { + throw new InvalidArgumentError(`Invalid phones [${JSON.stringify(phones)}].`); + } + return { phones }; +}; + +/** + * Returns `true` if the given qualifier is a {@link PhonesQualifier}, otherwise `false`. + * @param qualifier the qualifier to check + */ +export const isPhonesQualifier = (qualifier: unknown): qualifier is PhonesQualifier => { + return isRecord(qualifier) + && hasField(qualifier, { name: 'phones', type: 'object' }) + && Array.isArray(qualifier.phones) + && qualifier.phones.length > 0 + && qualifier.phones.every(p => typeof p === 'string' && p.length > 0); +}; + +/** + * The set of qualifier shapes accepted by `Contact.v1.getUuidsPage` / `Contact.v1.getUuids` for + * filtering contacts. Extend this union (and {@link isContactGetUuidsQualifier}) when adding a new + * filtering dimension; call sites that accept any of them should reference this alias rather than + * spelling out the member types. + */ +export type ContactGetUuidsQualifier = + | ContactTypeQualifier + | FreetextQualifier + | PhoneQualifier + | PhonesQualifier; + +/** + * Returns `true` if the given qualifier is any member of {@link ContactGetUuidsQualifier}. + * @param qualifier the qualifier to check + */ +export const isContactGetUuidsQualifier = (qualifier: unknown): qualifier is ContactGetUuidsQualifier => { + return isContactTypeQualifier(qualifier) + || isFreetextQualifier(qualifier) + || isPhoneQualifier(qualifier) + || isPhonesQualifier(qualifier); +}; + /** * A qualifier that identifies entities based on a reporting period (e.g. a calendar month). The reporting period * should be represented with the format YYYY-MM (e.g. "2025-07"). diff --git a/shared-libs/cht-datasource/src/remote/contact.ts b/shared-libs/cht-datasource/src/remote/contact.ts index 40be594a32f..6bf684c80cc 100644 --- a/shared-libs/cht-datasource/src/remote/contact.ts +++ b/shared-libs/cht-datasource/src/remote/contact.ts @@ -1,5 +1,5 @@ -import { getResource, getResources, RemoteDataContext } from './libs/data-context'; -import { ContactTypeQualifier, FreetextQualifier, UuidQualifier } from '../qualifier'; +import { getResource, getResources, postResource, RemoteDataContext } from './libs/data-context'; +import { ContactGetUuidsQualifier, isPhoneQualifier, isPhonesQualifier, UuidQualifier } from '../qualifier'; import { Nullable, Page } from '../libs/core'; import * as Contact from '../contact'; import { isContactType, isFreetextType } from '../libs/parameter-validators'; @@ -10,6 +10,13 @@ export namespace v1 { const getContactUuids = (remoteContext: RemoteDataContext) => getResources(remoteContext, 'api/v1/contact/uuid'); + // POSTed to the same path for the bulk variant; body carries the array-valued qualifier(s) and + // pagination. Used when the qualifier shape can't fit a single GET (multi-value lookups). + // `postResource(path)` is called per-invocation (not at module load) so test stubs can intercept. + const postContactUuids = ( + remoteContext: RemoteDataContext + ) => postResource('api/v1/contact/uuid')(remoteContext); + /** @internal */ export const get = (remoteContext: RemoteDataContext) => ( identifier: UuidQualifier @@ -26,10 +33,23 @@ export namespace v1 { /** @internal */ export const getUuidsPage = (remoteContext: RemoteDataContext) => ( - qualifier: ContactTypeQualifier | FreetextQualifier, + qualifier: ContactGetUuidsQualifier, cursor: Nullable, limit: number ): Promise> => { + // Bulk qualifiers go over POST so the array can sit in a JSON body — avoids URL-length limits + // and the ambiguity of repeated/comma-encoded query params. + if (isPhonesQualifier(qualifier)) { + return postContactUuids(remoteContext)({ + phones: qualifier.phones, + limit, + ...(cursor ? { cursor } : {}), + }); + } + + const phoneParams: Record = isPhoneQualifier(qualifier) + ? { phone: qualifier.phone } + : {}; const freetextParams: Record = isFreetextType(qualifier) ? { freetext: qualifier.freetext } : {}; @@ -42,6 +62,7 @@ export namespace v1 { ...(cursor ? { cursor } : {}), ...typeParams, ...freetextParams, + ...phoneParams, }; return getContactUuids(remoteContext)(queryParams); }; diff --git a/shared-libs/cht-datasource/test/contact.spec.ts b/shared-libs/cht-datasource/test/contact.spec.ts index 42c8cfdbb02..3820f504602 100644 --- a/shared-libs/cht-datasource/test/contact.spec.ts +++ b/shared-libs/cht-datasource/test/contact.spec.ts @@ -236,7 +236,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, or phone qualifier.`); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect( adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) @@ -251,7 +251,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, or phone qualifier.`); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect( adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getUuidsPage, Remote.Contact.v1.getUuidsPage) @@ -266,7 +266,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, or phone qualifier.` ); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; @@ -383,7 +384,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, or phone qualifier.`); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect(contactGetIdsPage.notCalled).to.be.true; expect(isContactTypeQualifier.calledOnceWithExactly(invalidContactTypeQualifier)).to.be.true; @@ -395,7 +396,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, or phone qualifier.`); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect(contactGetIdsPage.notCalled).to.be.true; expect(isContactTypeQualifier.calledOnceWithExactly(invalidFreetextQualifier)).to.be.true; @@ -407,7 +408,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, or phone qualifier.` ); expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; expect(contactGetIdsPage.notCalled).to.be.true; diff --git a/shared-libs/cht-datasource/test/index.spec.ts b/shared-libs/cht-datasource/test/index.spec.ts index 426fddebc20..9efcf106fa6 100644 --- a/shared-libs/cht-datasource/test/index.spec.ts +++ b/shared-libs/cht-datasource/test/index.spec.ts @@ -342,6 +342,9 @@ describe('CHT Script API - getDatasource', () => { 'getUuidsByFreetext', 'getUuidsPageByType', 'getUuidsByType', + 'getUuidsByPhone', + 'collectUuidsByPhone', + 'collectUuidsByPhones', ] ); }); @@ -549,6 +552,74 @@ describe('CHT Script API - getDatasource', () => { expect(contactGetIds.calledOnceWithExactly(freetextQualifier)).to.be.true; expect(byFreetext.calledOnceWithExactly(freetext)).to.be.true; }); + + it('getUuidsByPhone', () => { + const mockAsyncGenerator = fakeGenerator(); + + const contactGetIds = sinon.stub().returns(mockAsyncGenerator); + dataContextBind.returns(contactGetIds); + const phone = '+15551234567'; + 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('collectUuidsByPhone collects all matching uuids into an array', async () => { + const contactGetIds = sinon.stub().returns(fakeGenerator(['uuid-1', 'uuid-2', 'uuid-3'])); + dataContextBind.returns(contactGetIds); + const phone = '+15551234567'; + const phoneQualifier = { phone }; + const byPhone = sinon.stub(Qualifier, 'byPhone').returns(phoneQualifier); + + const res = await contact.collectUuidsByPhone(phone); + + expect(res).to.deep.equal(['uuid-1', 'uuid-2', 'uuid-3']); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuids)).to.be.true; + expect(contactGetIds.calledOnceWithExactly(phoneQualifier)).to.be.true; + expect(byPhone.calledOnceWithExactly(phone)).to.be.true; + }); + + it('collectUuidsByPhone returns empty array when no matches', async () => { + const contactGetIds = sinon.stub().returns(fakeGenerator([])); + dataContextBind.returns(contactGetIds); + sinon.stub(Qualifier, 'byPhone').returns({ phone: '+15551234567' }); + + const res = await contact.collectUuidsByPhone('+15551234567'); + + expect(res).to.deep.equal([]); + }); + + it('collectUuidsByPhones collects matching uuids across all input phones', async () => { + const contactGetIds = sinon.stub().returns(fakeGenerator(['uuid-1', 'uuid-2', 'uuid-3'])); + dataContextBind.returns(contactGetIds); + const phones: [string, ...string[]] = ['+1', '+2', '+3']; + const phonesQualifier = { phones }; + const byPhones = sinon.stub(Qualifier, 'byPhones').returns(phonesQualifier); + + const res = await contact.collectUuidsByPhones(phones); + + expect(res).to.deep.equal(['uuid-1', 'uuid-2', 'uuid-3']); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getUuids)).to.be.true; + expect(contactGetIds.calledOnceWithExactly(phonesQualifier)).to.be.true; + expect(byPhones.calledOnceWithExactly(phones)).to.be.true; + }); + + it('collectUuidsByPhones returns empty array when no matches across all phones', async () => { + const contactGetIds = sinon.stub().returns(fakeGenerator([])); + dataContextBind.returns(contactGetIds); + const phones: [string, ...string[]] = ['+1', '+2']; + sinon.stub(Qualifier, 'byPhones').returns({ phones }); + + const res = await contact.collectUuidsByPhones(phones); + + expect(res).to.deep.equal([]); + }); }); describe('report', () => { 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 1ead7c6c52c..f0daa051fb4 100644 --- a/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts +++ b/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts @@ -1,7 +1,7 @@ import { ContactTypeQualifier, FreetextQualifier } from '../../src/qualifier'; import { expect } from 'chai'; import { - assertContactTypeFreetextQualifier, + assertContactGetUuidsQualifier, assertCursor, assertFreetextQualifier, assertLimit, @@ -113,38 +113,38 @@ describe('libs parameter-validators', () => { }); }); - describe('assertContactTypeFreetextQualifier', () => { + describe('assertContactGetUuidsQualifier', () => { it('should pass when given a valid contact type qualifier', () => { const validContactType = { contactType: 'email' }; - expect(() => assertContactTypeFreetextQualifier(validContactType)).to.not.throw(); + expect(() => assertContactGetUuidsQualifier(validContactType)).to.not.throw(); }); it('should pass when given a valid freetext qualifier', () => { const validFreetext = { freetext: 'some-text' }; - expect(() => assertContactTypeFreetextQualifier(validFreetext)).to.not.throw(); + expect(() => assertContactGetUuidsQualifier(validFreetext)).to.not.throw(); }); it('should throw InvalidArgumentError when given an invalid qualifier', () => { const invalidQualifier = { invalid: 'data' }; - expect(() => assertContactTypeFreetextQualifier(invalidQualifier)).to.throw(InvalidArgumentError); + expect(() => assertContactGetUuidsQualifier(invalidQualifier)).to.throw(InvalidArgumentError); }); it('should throw InvalidArgumentError with correct message for invalid qualifier', () => { const invalidQualifier = { invalid: 'data' }; - expect(() => assertContactTypeFreetextQualifier(invalidQualifier)).to.throw( + expect(() => assertContactGetUuidsQualifier(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, or phone qualifier.' ); }); it('should throw InvalidArgumentError when freetext is too short', () => { const shortFreetext = { freetext: 'ab' }; // Less than 3 characters - expect(() => assertContactTypeFreetextQualifier(shortFreetext)).to.throw(InvalidArgumentError); + expect(() => assertContactGetUuidsQualifier(shortFreetext)).to.throw(InvalidArgumentError); }); it('should pass when object satisfies both qualifier types', () => { @@ -153,15 +153,15 @@ describe('libs parameter-validators', () => { freetext: 'some text' }; - expect(() => assertContactTypeFreetextQualifier(validBothTypes)).to.not.throw(); + expect(() => assertContactGetUuidsQualifier(validBothTypes)).to.not.throw(); }); it('should handle null input appropriately', () => { - expect(() => assertContactTypeFreetextQualifier(null)).to.throw(InvalidArgumentError); + expect(() => assertContactGetUuidsQualifier(null)).to.throw(InvalidArgumentError); }); it('should handle undefined input appropriately', () => { - expect(() => assertContactTypeFreetextQualifier(undefined)).to.throw(InvalidArgumentError); + expect(() => assertContactGetUuidsQualifier(undefined)).to.throw(InvalidArgumentError); }); }); diff --git a/shared-libs/cht-datasource/test/local/contact.spec.ts b/shared-libs/cht-datasource/test/local/contact.spec.ts index cb35310daed..c88119f5a8e 100644 --- a/shared-libs/cht-datasource/test/local/contact.spec.ts +++ b/shared-libs/cht-datasource/test/local/contact.spec.ts @@ -211,6 +211,8 @@ describe('local contact', () => { const contactType = 'person'; const expectedResult = { cursor: 'bookmark', data: ['1', '2', '3'] }; let queryViewByType: SinonStub; + let queryViewByPhone: SinonStub; + let queryViewByPhones: SinonStub; let queryViewFreetextByKey: SinonStub; let queryViewFreetextByRange: SinonStub; let queryViewTypeFreetextByKey: SinonStub; @@ -225,12 +227,17 @@ describe('local contact', () => { getContactTypeIds = sinon.stub(contactTypeUtils, 'getContactTypeIds').returns([contactType]); queryViewByType = sinon.stub(); + queryViewByPhone = sinon.stub(); + queryViewByPhones = 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); @@ -238,6 +245,10 @@ describe('local contact', () => { .withArgs(localContext.medicDb, 'medic-offline-freetext/contacts_by_type_freetext') .returns(queryViewTypeFreetextByKey); + sinon.stub(LocalDoc, 'queryDocIdsByKeys') + .withArgs(localContext.medicDb, 'medic-client/contacts_by_phone') + .returns(queryViewByPhones); + queryViewFreetextByRange = sinon.stub(); queryViewTypeFreetextByRange = sinon.stub(); const queryDocIdsByRangeStub = sinon.stub(LocalDoc, 'queryDocIdsByRange'); @@ -336,6 +347,157 @@ describe('local contact', () => { }); }); + describe('phone qualifier', () => { + beforeEach(() => { + useNouveauIndexes.resolves(false); + fetchAndFilterIdsInner.resolves(expectedResult); + }); + + ([ + [null, 0], + ['2', 2] + ] as [string | null, number][]).forEach(([cursor, skip]) => { + it(`queries contacts_by_phone with the phone as key with cursor [${cursor}]`, async () => { + const phone = '+15551234567'; + const qualifier = Qualifier.byPhone(phone); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + + expect(res).to.deep.equal(expectedResult); + 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; + + 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('passes the phone through to the view as-is (no normalization)', async () => { + const phone = ' +1 (555) 123 4567 '; + const qualifier = Qualifier.byPhone(phone); + + await Contact.v1.getUuidsPage(localContext)(qualifier, null, limit); + + const pageFn = fetchAndFilterIdsOuter.firstCall.args[0] as (l: number, s: number) => unknown; + pageFn(limit, 0); + + expect(queryViewByPhone.calledWithExactly(phone, limit, 0)).to.be.true; + }); + + it('walks two cursor pages with the same phone', async () => { + const phone = '+15551234567'; + const qualifier = Qualifier.byPhone(phone); + const smallLimit = 5; + const firstPage = { cursor: '5', data: ['a', 'b', 'c', 'd', 'e'] }; + const secondPage = { cursor: null, data: ['f', 'g'] }; + fetchAndFilterIdsInner.onFirstCall().resolves(firstPage); + fetchAndFilterIdsInner.onSecondCall().resolves(secondPage); + + const page1 = await Contact.v1.getUuidsPage(localContext)(qualifier, null, smallLimit); + expect(page1).to.deep.equal(firstPage); + expect(fetchAndFilterIdsInner.firstCall.args).to.deep.equal([smallLimit, 0]); + + const page2 = await Contact.v1.getUuidsPage(localContext)(qualifier, page1.cursor, smallLimit); + expect(page2).to.deep.equal(secondPage); + expect(fetchAndFilterIdsInner.secondCall.args).to.deep.equal([smallLimit, 5]); + }); + + it('throws for invalid cursor', async () => { + const qualifier = Qualifier.byPhone('+15551234567'); + 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)}]` + ); + }); + }); + + describe('phones qualifier (bulk)', () => { + beforeEach(() => { + useNouveauIndexes.resolves(false); + fetchAndFilterIdsInner.resolves(expectedResult); + }); + + ([ + [null, 0], + ['2', 2] + ] as [string | null, number][]).forEach(([cursor, skip]) => { + it(`queries contacts_by_phone with keys: [...] in a single round trip [cursor=${cursor}]`, + async () => { + const phones: [string, ...string[]] = ['+15551234567', '+15559999999', '+44123456']; + const qualifier = Qualifier.byPhones(phones); + + const res = await Contact.v1.getUuidsPage(localContext)(qualifier, cursor, limit); + + expect(res).to.deep.equal(expectedResult); + expect(queryViewByPhone.notCalled).to.be.true; + expect(queryViewByType.notCalled).to.be.true; + expect(queryNouveauFreetext.notCalled).to.be.true; + expect(fetchAndFilterIdsOuter.calledOnce).to.be.true; + expect(fetchAndFilterIdsInner.calledOnceWithExactly(limit, skip)).to.be.true; + + const pageFn = fetchAndFilterIdsOuter.firstCall.args[0] as (l: number, s: number) => unknown; + pageFn(limit, skip); + + // Single call to the bulk view query — not N calls to the single-key one + expect(queryViewByPhones.calledOnceWithExactly(phones, limit, skip)).to.be.true; + }); + }); + + it('passes phone values through to the view as-is (no normalization per element)', async () => { + const phones: [string, ...string[]] = [' +1 (555) 123 4567 ', '+2']; + const qualifier = Qualifier.byPhones(phones); + + await Contact.v1.getUuidsPage(localContext)(qualifier, null, limit); + + const pageFn = fetchAndFilterIdsOuter.firstCall.args[0] as (l: number, s: number) => unknown; + pageFn(limit, 0); + + expect(queryViewByPhones.calledOnceWithExactly(phones, limit, 0)).to.be.true; + }); + + it('walks two cursor pages with the same phones array', async () => { + const phones: [string, ...string[]] = ['+1', '+2']; + const qualifier = Qualifier.byPhones(phones); + const smallLimit = 5; + const firstPage = { cursor: '5', data: ['a', 'b', 'c', 'd', 'e'] }; + const secondPage = { cursor: null, data: ['f', 'g'] }; + fetchAndFilterIdsInner.onFirstCall().resolves(firstPage); + fetchAndFilterIdsInner.onSecondCall().resolves(secondPage); + + const page1 = await Contact.v1.getUuidsPage(localContext)(qualifier, null, smallLimit); + expect(page1).to.deep.equal(firstPage); + expect(fetchAndFilterIdsInner.firstCall.args).to.deep.equal([smallLimit, 0]); + + const page2 = await Contact.v1.getUuidsPage(localContext)(qualifier, page1.cursor, smallLimit); + expect(page2).to.deep.equal(secondPage); + expect(fetchAndFilterIdsInner.secondCall.args).to.deep.equal([smallLimit, 5]); + }); + + it('throws for invalid cursor', async () => { + const qualifier = Qualifier.byPhones(['+1']); + 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)}]` + ); + }); + }); + describe('freetext qualifier', () => { describe('when useNouveauIndexes is true', () => { beforeEach(() => { diff --git a/shared-libs/cht-datasource/test/qualifier.spec.ts b/shared-libs/cht-datasource/test/qualifier.spec.ts index abcf2106e72..f4903b02bb7 100644 --- a/shared-libs/cht-datasource/test/qualifier.spec.ts +++ b/shared-libs/cht-datasource/test/qualifier.spec.ts @@ -4,6 +4,8 @@ import { byContactId, byContactIds, byFreetext, + byPhone, + byPhones, byReportingPeriod, byUsername, byUuid, FreetextQualifier, @@ -12,6 +14,8 @@ import { isContactIdsQualifier, isFreetextQualifier, isKeyedFreetextQualifier, + isPhoneQualifier, + isPhonesQualifier, isReportingPeriodQualifier, isUsernameQualifier, isUuidQualifier, @@ -165,6 +169,86 @@ describe('qualifier', () => { }); }); + describe('byPhone', () => { + it('builds a qualifier for searching contacts by phone', () => { + expect(byPhone('+15551234567')).to.deep.equal({ phone: '+15551234567' }); + }); + + it('preserves whitespace and country-code formatting (no normalization)', () => { + expect(byPhone(' +1 (555) 123 4567 ')).to.deep.equal({ phone: ' +1 (555) 123 4567 ' }); + }); + + [ + null, + '', + { }, + 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 ], + [ '+15551234567', false ], + [ { phone: { } }, false ], + [ { phone: '+15551234567' }, true ], + [ { phone: '+15551234567', other: 'other' }, true ], + [ { phone: '' }, true ], + ].forEach(([ qualifier, expected ]) => { + it(`evaluates ${JSON.stringify(qualifier)}`, () => { + expect(isPhoneQualifier(qualifier)).to.equal(expected); + }); + }); + }); + + describe('byPhones', () => { + it('builds a qualifier for the bulk lookup of contacts by phone', () => { + expect(byPhones(['+1', '+2', '+3'])).to.deep.equal({ phones: ['+1', '+2', '+3'] }); + }); + + it('preserves whitespace and country-code formatting per element (no normalization)', () => { + expect(byPhones([' +1 (555) 123 4567 ', '+2'])) + .to.deep.equal({ phones: [' +1 (555) 123 4567 ', '+2'] }); + }); + + ([ + null, + undefined, + 'not-an-array', + [], + [''], + ['valid', ''], + ['valid', null], + ['valid', 123], + ] as unknown as [string, ...string[]][]).forEach(phones => { + it(`throws an error for ${JSON.stringify(phones)}`, () => { + expect(() => byPhones(phones)).to.throw(`Invalid phones [${JSON.stringify(phones)}].`); + }); + }); + }); + + describe('isPhonesQualifier', () => { + [ + [ null, false ], + [ ['+1'], false ], // bare array, not a qualifier + [ { phones: '+1' }, false ], // string not array + [ { phones: [] }, false ], // empty array + [ { phones: ['+1', ''] }, false ], // empty element + [ { phones: ['+1', null] }, false ], // non-string element + [ { phones: ['+1'] }, true ], + [ { phones: ['+1', '+2', '+3'] }, true ], + [ { phones: ['+1'], other: 'other' }, true ], + ].forEach(([ qualifier, expected ]) => { + it(`evaluates ${JSON.stringify(qualifier)}`, () => { + expect(isPhonesQualifier(qualifier)).to.equal(expected); + }); + }); + }); + describe('byReportingPeriod', () => { it('builds a qualifier for searching by reporting period (YYYY-MM)', () => { expect(byReportingPeriod('2025-07')).to.deep.equal({ reportingPeriod: '2025-07' }); diff --git a/shared-libs/cht-datasource/test/remote/contact.spec.ts b/shared-libs/cht-datasource/test/remote/contact.spec.ts index 501e23d488b..c35172bf4c9 100644 --- a/shared-libs/cht-datasource/test/remote/contact.spec.ts +++ b/shared-libs/cht-datasource/test/remote/contact.spec.ts @@ -11,11 +11,18 @@ describe('remote contact', () => { let getResourcesInner: SinonStub; let getResourcesOuter: SinonStub; + let postResourceInnermost: SinonStub; // (body) => Promise + let postResourceMiddle: SinonStub; // (context) => (body) + let postResourceOuter: SinonStub; // (path) => (context) + beforeEach(() => { getResourceInner = sinon.stub(); getResourceOuter = sinon.stub(RemoteEnv, 'getResource').returns(getResourceInner); getResourcesInner = sinon.stub(); getResourcesOuter = sinon.stub(RemoteEnv, 'getResources').returns(getResourcesInner); + postResourceInnermost = sinon.stub(); + postResourceMiddle = sinon.stub().returns(postResourceInnermost); + postResourceOuter = sinon.stub(RemoteEnv, 'postResource').returns(postResourceMiddle); }); afterEach(() => sinon.restore()); @@ -150,6 +157,114 @@ describe('remote contact', () => { type: contactType, })).to.be.true; }); + + describe('phone qualifier', () => { + const phone = '+15551234567'; + const phoneQualifier = { phone }; + + it('passes the phone as a query param to the existing endpoint', async () => { + const expectedResponse = { data: ['a'], cursor }; + getResourcesInner.resolves(expectedResponse); + + const result = await Contact.v1.getUuidsPage(remoteContext)(phoneQualifier, 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; + }); + + it('omits cursor param when cursor is null', async () => { + const expectedResponse = { data: [], cursor: null }; + getResourcesInner.resolves(expectedResponse); + + await Contact.v1.getUuidsPage(remoteContext)(phoneQualifier, null, limit); + + expect(getResourcesInner.calledOnceWithExactly({ + limit: limit.toString(), + phone, + })).to.be.true; + }); + + it('passes the phone value as-is (no normalization)', async () => { + const rawPhone = '+1 (555) 123-4567'; + getResourcesInner.resolves({ data: [], cursor: null }); + + await Contact.v1.getUuidsPage(remoteContext)({ phone: rawPhone }, null, limit); + + expect(getResourcesInner.calledOnceWithExactly({ + limit: limit.toString(), + phone: rawPhone, + })).to.be.true; + }); + + it('walks two cursor pages with limit 5', async () => { + const firstPage = { data: ['a', 'b', 'c', 'd', 'e'], cursor: '5' }; + const secondPage = { data: ['f', 'g'], cursor: null }; + getResourcesInner.onFirstCall().resolves(firstPage); + getResourcesInner.onSecondCall().resolves(secondPage); + + const page1 = await Contact.v1.getUuidsPage(remoteContext)(phoneQualifier, null, 5); + expect(page1).to.deep.equal(firstPage); + expect(getResourcesInner.firstCall.args[0]).to.deep.equal({ limit: '5', phone }); + + const page2 = await Contact.v1.getUuidsPage(remoteContext)(phoneQualifier, page1.cursor, 5); + expect(page2).to.deep.equal(secondPage); + expect(getResourcesInner.secondCall.args[0]).to.deep.equal({ limit: '5', cursor: '5', phone }); + }); + }); + + describe('phones qualifier (bulk)', () => { + const phones: [string, ...string[]] = ['+15551234567', '+15559999999']; + const phonesQualifier = { phones }; + + it('POSTs to the same path with phones in the JSON body', async () => { + const expectedResponse = { data: ['a', 'b'], cursor }; + postResourceInnermost.resolves(expectedResponse); + + const result = await Contact.v1.getUuidsPage(remoteContext)(phonesQualifier, cursor, limit); + + expect(result).to.equal(expectedResponse); + expect(postResourceOuter.calledOnceWithExactly('api/v1/contact/uuid')).to.be.true; + expect(postResourceMiddle.calledOnceWithExactly(remoteContext)).to.be.true; + expect(postResourceInnermost.calledOnceWithExactly({ + phones, + limit, + cursor, + })).to.be.true; + // GET endpoint not touched + expect(getResourcesInner.notCalled).to.be.true; + }); + + it('omits cursor from the body when cursor is null', async () => { + postResourceInnermost.resolves({ data: [], cursor: null }); + + await Contact.v1.getUuidsPage(remoteContext)(phonesQualifier, null, limit); + + expect(postResourceInnermost.calledOnceWithExactly({ + phones, + limit, + })).to.be.true; + }); + + it('walks two cursor pages with limit 5', async () => { + const firstPage = { data: ['a', 'b', 'c', 'd', 'e'], cursor: '5' }; + const secondPage = { data: ['f'], cursor: null }; + postResourceInnermost.onFirstCall().resolves(firstPage); + postResourceInnermost.onSecondCall().resolves(secondPage); + + const page1 = await Contact.v1.getUuidsPage(remoteContext)(phonesQualifier, null, 5); + expect(page1).to.deep.equal(firstPage); + expect(postResourceInnermost.firstCall.args[0]).to.deep.equal({ phones, limit: 5 }); + + const page2 = await Contact.v1.getUuidsPage(remoteContext)(phonesQualifier, page1.cursor, 5); + expect(page2).to.deep.equal(secondPage); + expect(postResourceInnermost.secondCall.args[0]).to.deep.equal({ phones, limit: 5, cursor: '5' }); + }); + }); }); }); }); diff --git a/shared-libs/transitions/src/transitions/registration.js b/shared-libs/transitions/src/transitions/registration.js index 1eabd7d56c6..76febaf4b97 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,16 +415,17 @@ 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 getContactUuids = dataContext.bind(Contact.v1.getUuidsPage); + const getContact = dataContext.bind(Contact.v1.get); + const getPlace = dataContext.bind(Place.v1.get); + return getContactUuids(Qualifier.byPhone(options.doc.from), null, 1) + .then(page => page.data.length && getContact(Qualifier.byUuid(page.data[0]))) .then(contact => { if (!contact) { return; } options.doc.contact = contact; - const getPlace = dataContext.bind(Place.v1.get); return contact.parent && getPlace(Qualifier.byUuid(contact.parent._id)); }); }; 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..022eb19f0f3 100644 --- a/shared-libs/transitions/src/transitions/update_sent_by.js +++ b/shared-libs/transitions/src/transitions/update_sent_by.js @@ -1,7 +1,8 @@ const transitionUtils = require('./utils'); -const db = require('../db'); const NAME = 'update_sent_by'; const { DOC_TYPES } = require('@medic/constants'); +const { Contact, Qualifier } = require('@medic/cht-datasource'); +const dataContext = require('../data-context'); module.exports = { name: NAME, @@ -23,14 +24,13 @@ module.exports = { }, onMatch: change => { const doc = change.doc; + const getContactUuids = dataContext.bind(Contact.v1.getUuidsPage); + const getContact = dataContext.bind(Contact.v1.get); - 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 getContactUuids(Qualifier.byPhone(doc.from), null, 1) + .then(page => page.data.length && getContact(Qualifier.byUuid(page.data[0]))) + .then(contact => { + const sentBy = contact && contact.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..444ef684fcb 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 getContactUuidsPage; + let getContact; beforeEach(() => { getContactWithLineage = sinon.stub(); - dataContext.init({ - bind: sinon.stub().withArgs(Contact.v1.getWithLineage).returns(getContactWithLineage), - }); + getContactUuidsPage = sinon.stub(); + getContact = sinon.stub(); + const bind = sinon.stub(); + bind.withArgs(Contact.v1.getWithLineage).returns(getContactWithLineage); + bind.withArgs(Contact.v1.getUuidsPage).returns(getContactUuidsPage); + bind.withArgs(Contact.v1.get).returns(getContact); + dataContext.init({ bind }); }); it('should run all async transitions over docs and save all docs', () => { @@ -524,19 +530,21 @@ describe('functional transitions', () => { sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + // update_clinics & update_sent_by both look up contacts by phone via cht-datasource + getContactUuidsPage + .withArgs(Qualifier.byPhone('phone1'), null, 1) + .resolves({ data: ['contact1'], cursor: null }); + getContactUuidsPage + .withArgs(Qualifier.byPhone('phone2'), null, 1) + .resolves({ data: [], cursor: null }); + getContactUuidsPage + .withArgs(Qualifier.byPhone('phone3'), null, 1) + .resolves({ data: ['contact3'], cursor: null }); + // update_sent_by hydrates the resulting uuid via Contact.v1.get + getContact.withArgs(Qualifier.byUuid('contact1')).resolves(contact1); + getContact.withArgs(Qualifier.byUuid('contact3')).resolves(contact3); + 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 }] }); diff --git a/shared-libs/transitions/test/unit/transitions/registration.js b/shared-libs/transitions/test/unit/transitions/registration.js index 02787155f03..c882b1542d7 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,8 @@ let acceptPatientReports; let transition; let settings; let getPlace; +let getContactUuidsPage; +let getContact; describe('registration', () => { beforeEach(() => { @@ -31,11 +33,13 @@ describe('registration', () => { .returns({}) }); getPlace = sinon.stub(); - dataContext.init({ - bind: sinon - .stub() - .returns(getPlace) - }); + getContactUuidsPage = sinon.stub().resolves({ data: [], cursor: null }); + getContact = sinon.stub().resolves(); + const bind = sinon.stub().returns(getPlace); + bind.withArgs(Place.v1.get).returns(getPlace); + bind.withArgs(Contact.v1.getUuidsPage).returns(getContactUuidsPage); + bind.withArgs(Contact.v1.get).returns(getContact); + dataContext.init({ bind }); schedules = require('../../../src/lib/schedules'); transitionUtils = require('../../../src/transitions/utils'); @@ -165,16 +169,9 @@ 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 }, - }, - }, - ], - }); + const view = getContactUuidsPage; + view.resolves({ data: [submitterId], cursor: null }); + getContact.resolves({ _id: submitterId, parent: { _id: parentId } }); getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -191,10 +188,8 @@ describe('registration', () => { 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; + view.args[0][0].should.deep.equal(Qualifier.byPhone(senderPhoneNumber)); + 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); @@ -242,16 +237,9 @@ 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 }, - }, - }, - ], - }); + const view = getContactUuidsPage; + view.resolves({ data: [submitterId], cursor: null }); + getContact.resolves({ _id: submitterId, parent: { _id: parentId } }); getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); @@ -270,10 +258,8 @@ describe('registration', () => { 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; + view.args[0][0].should.deep.equal(Qualifier.byPhone(senderPhoneNumber)); + 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 +309,8 @@ describe('registration', () => { }, }; sinon.stub(utils, 'getContactUuid').resolves(); - sinon.stub(db.medic, 'query').resolves({ - rows: [ - { - doc: { - _id: submitterId, - parent: { _id: parentId }, - }, - }, - ], - }); + getContactUuidsPage.resolves({ data: [submitterId], cursor: null }); + getContact.resolves({ _id: submitterId, parent: { _id: parentId } }); getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); @@ -350,7 +328,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); }); @@ -385,16 +363,9 @@ 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 }, - }, - }, - ], - }); + const view = getContactUuidsPage; + view.resolves({ data: [submitterId], cursor: null }); + getContact.resolves({ _id: submitterId, parent: { _id: parentId } }); getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); @@ -413,10 +384,8 @@ describe('registration', () => { 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; + view.args[0][0].should.deep.equal(Qualifier.byPhone(senderPhoneNumber)); + 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 +411,8 @@ describe('registration', () => { fields: { patient_name: 'jack' }, }, }; - sinon - .stub(db.medic, 'query') - .resolves({ - rows: [{ doc: { parent: { _id: 'papa' } } }], - }); + getContactUuidsPage.resolves({ data: ['_submitter'], cursor: null }); + getContact.resolves({ parent: { _id: 'papa' } }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { form: 'R', @@ -473,11 +439,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' } } }], - }); + getContactUuidsPage.resolves({ data: ['_submitter'], cursor: null }); + getContact.resolves({ parent: { _id: 'papa' } }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(1); const eventConfig = { @@ -497,7 +460,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); @@ -518,16 +481,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' }, - }, - }, - ], - }); + getContactUuidsPage.resolves({ data: ['abc'], cursor: null }); + getContact.resolves({ _id: 'abc', parent: { _id: 'papa' } }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -551,7 +506,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'); @@ -571,11 +526,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' } } }], - }); + getContactUuidsPage.resolves({ data: ['_submitter'], cursor: null }); + getContact.resolves({ parent: { _id: 'papa' } }); getPlace.resolves({ _id: 'papa', type: 'place' }); sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -597,7 +549,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([ @@ -621,11 +573,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' } } }], - }); + getContactUuidsPage.resolves({ data: ['_submitter'], cursor: null }); + getContact.resolves({ parent: { _id: 'papa' } }); getPlace.resolves({ _id: 'papa', type: 'place' }); sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -649,7 +598,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([ @@ -678,11 +627,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 } } }], - }); + getContactUuidsPage.resolves({ data: ['_submitter'], cursor: null }); + getContact.resolves({ parent: { _id: submitterId } }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -698,7 +644,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); @@ -722,11 +668,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 } } }], - }); + getContactUuidsPage.resolves({ data: ['_submitter'], cursor: null }); + getContact.resolves({ parent: { _id: submitterId } }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -748,7 +691,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); @@ -772,11 +715,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 } } }], - }); + getContactUuidsPage.resolves({ data: ['_submitter'], cursor: null }); + getContact.resolves({ parent: { _id: submitterId } }); getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -795,7 +735,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 +1412,15 @@ 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' } - } - } - ] - }); + getContactUuidsPage.resolves({ data: ['supervisor'], cursor: null }); + getContact.resolves({ + _id: 'supervisor', + name: 'Frank', + contact_type: 'supervisor', + type: 'contact', + phone: '+111222', + parent: { _id: 'west_hc' } + }); getPlace.resolves({ _id: 'west_hc', name: 'west hc', @@ -1514,10 +1447,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; + getContactUuidsPage.callCount.should.equal(1); + getContactUuidsPage.args[0][0].should.deep.equal(Qualifier.byPhone('+111222')); + 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 +1695,7 @@ describe('registration', () => { }] }; config.get.withArgs('registrations').returns([eventConfig]); - sinon.stub(db.medic, 'query').withArgs('medic-client/contacts_by_phone').resolves({ rows: [] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); sinon.stub(validation, 'validate').resolves(); sinon.stub(utils, 'getRegistrations').resolves([]); @@ -1775,10 +1707,8 @@ 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; + getContactUuidsPage.callCount.should.equal(1); + getContactUuidsPage.args[0][0].should.deep.equal(Qualifier.byPhone('+111222')); 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..dc94f1fd2ec 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 getContactUuidsPage; let getContactWithLineage; + let bind; beforeEach(() => { + getContactUuidsPage = sinon.stub(); getContactWithLineage = sinon.stub(); - dataContext.init({ - bind: sinon.stub().returns(getContactWithLineage), - }); + bind = sinon.stub(); + bind.withArgs(Contact.v1.getUuidsPage).returns(getContactUuidsPage); + 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: [] }); + getContactUuidsPage.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(getContactUuidsPage.callCount).to.equal(1); + chai.expect(getContactUuidsPage.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,13 @@ describe('self_report transition', () => { } ]); config.getTranslations.returns({ en: { the_message: 'translated message' }}); - db.medic.query.resolves({rows: []}); + getContactUuidsPage.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(getContactUuidsPage.callCount).to.equal(1); + chai.expect(getContactUuidsPage.args[0]).to.deep.equal([Qualifier.byPhone('12345'), null, 1]); 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 +174,13 @@ describe('self_report transition', () => { patient_id: 'martin_id' }; - db.medic.query.resolves({ rows: [{ id: 'the_contact' }] }); + getContactUuidsPage.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(getContactUuidsPage.callCount).to.equal(1); + chai.expect(getContactUuidsPage.args[0]).to.deep.equal([Qualifier.byPhone('654987'), null, 1]); 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 +220,13 @@ describe('self_report transition', () => { patient_id: 'martin_id' }; - db.medic.query.resolves({ rows: [{ id: 'the_contact' }] }); + getContactUuidsPage.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(getContactUuidsPage.callCount).to.equal(1); + chai.expect(getContactUuidsPage.args[0]).to.deep.equal([Qualifier.byPhone('999999'), null, 1]); 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 +239,12 @@ describe('self_report transition', () => { it('should throw db errors', () => { const doc = { from: 'aaa' }; - db.medic.query.rejects({ some: 'err' }); + getContactUuidsPage.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(getContactWithLineage.callCount).to.equal(0); chai.expect(doc).to.have.all.keys('from'); // no changes to the doc }); @@ -262,7 +252,7 @@ describe('self_report transition', () => { it('should throw lineage errors', () => { const doc = { from: '654987' }; - db.medic.query.resolves({ rows: [{ id: 'the_contact' }] }); + getContactUuidsPage.resolves({ data: ['the_contact'], cursor: null }); getContactWithLineage.rejects({ other: 'err' }); return transition @@ -270,8 +260,7 @@ 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(getContactUuidsPage.callCount).to.equal(1); chai.expect(getContactWithLineage.callCount).to.equal(1); chai.expect(doc).to.have.all.keys('from'); // no changes to the doc }); @@ -295,17 +284,13 @@ describe('self_report transition', () => { patient_id: 'stan' }; - db.medic.query.resolves({ rows: [{ id: 'contact_uuid' }] }); + getContactUuidsPage.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(getContactUuidsPage.callCount).to.equal(1); + chai.expect(getContactUuidsPage.args[0]).to.deep.equal([Qualifier.byPhone('111222333'), null, 1]); 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 +318,13 @@ describe('self_report transition', () => { patient_id: 'stan' }; - db.medic.query.resolves({ rows: [{ id: 'contact1' }, { id: 'contact2' }] }); + getContactUuidsPage.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(getContactUuidsPage.callCount).to.equal(1); + chai.expect(getContactUuidsPage.args[0]).to.deep.equal([Qualifier.byPhone('98765'), null, 1]); 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 +374,13 @@ describe('self_report transition', () => { patient_id: 'martin_id' }; - db.medic.query.resolves({ rows: [{ id: 'the_contact' }] }); + getContactUuidsPage.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(getContactUuidsPage.callCount).to.equal(1); + chai.expect(getContactUuidsPage.args[0]).to.deep.equal([Qualifier.byPhone('999999'), null, 1]); 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 +391,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 (locale)', () => { config.get.returns([ { form: 'the_form', messages: [ @@ -435,14 +415,13 @@ describe('self_report transition', () => { sw: { the_message: 'not english', other_message: 'msg' }, }); - db.medic.query.resolves({rows: []}); + getContactUuidsPage.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(getContactUuidsPage.callCount).to.equal(1); + chai.expect(getContactUuidsPage.args[0]).to.deep.equal([Qualifier.byPhone('12345'), null, 1]); 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 +429,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..6615fa8e618 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'); @@ -10,6 +10,7 @@ const phone = '+34567890123'; let transition; let getContactWithLineage; +let getContactUuidsPage; describe('update clinic', () => { beforeEach(() => { @@ -21,9 +22,10 @@ describe('update clinic', () => { transition = require('../../../src/transitions/update_clinics'); dataContext.init({ bind: sinon.stub() }); getContactWithLineage = sinon.stub(); - dataContext.init({ - bind: sinon.stub().returns(getContactWithLineage), - }); + getContactUuidsPage = sinon.stub().resolves({ data: [], cursor: null }); + const bind = sinon.stub().returns(getContactWithLineage); + bind.withArgs(Contact.v1.getUuidsPage).returns(getContactUuidsPage); + dataContext.init({ bind }); }); afterEach(() => { @@ -89,13 +91,14 @@ describe('update clinic', () => { }, }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ id: contact._id }] }); + getContactUuidsPage.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.isTrue(getContactUuidsPage.calledOnceWithExactly(Qualifier.byPhone(phone), null, 1)); }); }); @@ -105,7 +108,7 @@ describe('update clinic', () => { from: 'WRONG', content_type: 'xml' }; - sinon.stub(db.medic, 'query').resolves({ rows: [] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); return transition.onMatch({ doc: doc }).then(changed => { assert(!changed); assert(!doc.contact); @@ -260,9 +263,9 @@ describe('update clinic', () => { type: DOC_TYPES.DATA_RECORD, }, }; - const view = sinon.stub(db.medic, 'query').resolves({ rows: [] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); return transition.onMatch(change).then(() => { - assert.equal(view.args[0][1].key, '123'); + assert.deepEqual(getContactUuidsPage.args[0][0], Qualifier.byPhone('123')); }); }); @@ -272,8 +275,8 @@ describe('update clinic', () => { type: DOC_TYPES.DATA_RECORD, }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ id: 'someID' }] }); - getContactWithLineage.withArgs('someID').rejects('some error'); + getContactUuidsPage.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 +289,7 @@ describe('update clinic', () => { type: DOC_TYPES.DATA_RECORD, }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); return transition.onMatch({ doc }).then(changed => { assert(changed); assert(!doc.contact); @@ -302,7 +305,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); config.get.withArgs('forms').returns({ 'other': {} }); return transition.onMatch({ doc }).then(changed => { @@ -321,7 +324,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); const stubbedConfig = config.get; stubbedConfig.returns([ { form: 'someForm', @@ -361,7 +364,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); sinon.stub(utils, 'translate').returns('facility not found'); const stubbedConfig = config.get; stubbedConfig.returns([ { @@ -396,7 +399,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); config.get.withArgs('forms').returns({ 'someForm': {} }); sinon.stub(utils, 'translate').returns('facility not found'); @@ -420,7 +423,7 @@ describe('update clinic', () => { form: 'someForm' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); config.get.withArgs('forms').returns({ 'other': {} }); return transition.onMatch({ doc }).then(changed => { @@ -440,7 +443,7 @@ describe('update clinic', () => { content_type: 'xml' }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuidsPage.resolves({ data: [], cursor: null }); return transition.onMatch({ doc }).then(changed => { assert(!changed); @@ -456,7 +459,7 @@ describe('update clinic', () => { form: 'someForm', }; - sinon.stub(db.medic, 'query').resolves({ rows: [{ key: '123' }] }); + getContactUuidsPage.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..cb2a37bff3a 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,22 @@ const sinon = require('sinon'); const assert = require('chai').assert; -const db = require('../../../src/db'); +const { Contact, Qualifier } = require('@medic/cht-datasource'); const config = require('../../../src/config'); +const dataContext = require('../../../src/data-context'); describe('update sent by', () => { let transition; + let getContactUuidsPage; + let getContact; beforeEach(() => { config.init({ getAll: sinon.stub().returns({}), }); + getContactUuidsPage = sinon.stub(); + getContact = sinon.stub(); + const bind = sinon.stub(); + bind.withArgs(Contact.v1.getUuidsPage).returns(getContactUuidsPage); + bind.withArgs(Contact.v1.get).returns(getContact); + dataContext.init({ bind }); transition = require('../../../src/transitions/update_sent_by'); }); @@ -16,22 +25,27 @@ describe('update sent by', () => { sinon.restore(); }); - it('updates sent_by to clinic name if contact name', () => { + it('updates sent_by to clinic name if contact name', async () => { const doc = { from: '+34567890123' }; - const dbView = sinon.stub(db.medic, 'query').resolves({ rows: [ { doc: { name: 'Clinic' } } ] } ); - return transition.onMatch({ doc: doc }).then(changed => { - assert(changed); - assert.equal(doc.sent_by, 'Clinic'); - assert(dbView.calledOnce); - }); + getContactUuidsPage.resolves({ data: ['contact-uuid'], cursor: null }); + getContact.resolves({ _id: 'contact-uuid', name: 'Clinic' }); + + const changed = await transition.onMatch({ doc }); + + assert(changed); + assert.equal(doc.sent_by, 'Clinic'); + assert.isTrue(getContactUuidsPage.calledOnceWithExactly(Qualifier.byPhone('+34567890123'), null, 1)); + assert.isTrue(getContact.calledOnceWithExactly(Qualifier.byUuid('contact-uuid'))); }); - it('sent_by untouched if nothing available', () => { + it('sent_by untouched if nothing available', async () => { const doc = { from: 'unknown number' }; - sinon.stub(db.medic, 'query').resolves({}); - return transition.onMatch({ doc: doc }).then(changed => { - assert(!changed); - assert.strictEqual(doc.sent_by, undefined); - }); + getContactUuidsPage.resolves({ data: [], cursor: null }); + + const changed = await transition.onMatch({ doc }); + + assert(!changed); + assert.strictEqual(doc.sent_by, undefined); + assert.isTrue(getContact.notCalled); }); }); diff --git a/shared-libs/validation/src/validation_utils.js b/shared-libs/validation/src/validation_utils.js index b7b36c8cb58..512d72d6e40 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,9 @@ const validPhone = (value) => { }; const uniquePhone = async (value) => { - const results = await db.medic.query('medic-client/contacts_by_phone', { key: value }); - return !results?.rows?.length; + const getContactUuids = dataContext.bind(Contact.v1.getUuidsPage); + const page = await getContactUuids(Qualifier.byPhone(value), null, 1); + return !page.data.length; }; module.exports = { diff --git a/shared-libs/validation/test/validations.js b/shared-libs/validation/test/validations.js index 72dca815e95..55b03e03570 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; @@ -35,6 +35,7 @@ describe('validations', () => { reportGetUuids = sinon.stub(); dataContext.bind.returns(reportGetUuids); + dataContext.bind.withArgs(Contact.v1.getUuidsPage).returns(sinon.stub().resolves({ data: [], cursor: null })); qualifier = sinon.stub(Qualifier, 'byFreetext'); validation.init({ db, config, translate, dataContext }); @@ -304,14 +305,8 @@ 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' - } - ] - }); + dataContext.bind.withArgs(Contact.v1.getUuidsPage) + .returns(sinon.stub().resolves({ data: ['original'], cursor: null })); const validations = [ { property: 'phone_number', @@ -326,7 +321,7 @@ describe('validations', () => { ]; const doc = { _id: 'duplicate', - xyz: '+9779841111111', + phone_number: '+9779841111111', }; return validation.validate(doc, validations).then(errors => { assert.equal(errors.length, 1); @@ -459,7 +454,7 @@ 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 }); + // default beforeEach stub resolves to empty data const validations = [ { property: 'phone_number', @@ -474,7 +469,7 @@ describe('validations', () => { ]; const doc = { _id: 'unique', - xyz: '+9779841111111', + phone_number: '+9779841111111', }; return validation.validate(doc, validations).then(errors => { assert.equal(errors.length, 0); diff --git a/webapp/src/js/enketo/widgets/phone-widget.js b/webapp/src/js/enketo/widgets/phone-widget.js index 145a6c8d20c..82c01c623cb 100644 --- a/webapp/src/js/enketo/widgets/phone-widget.js +++ b/webapp/src/js/enketo/widgets/phone-widget.js @@ -15,10 +15,10 @@ 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(); + return datasource.v1.contact.collectUuidsByPhone(phoneNumber); +}; 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 0e4ef82a5c2..1f2b7ce3a3f 100644 --- a/webapp/src/ts/services/integration-api.service.ts +++ b/webapp/src/ts/services/integration-api.service.ts @@ -9,6 +9,7 @@ import { AndroidApiService } from '@mm-services/android-api.service'; import { DbService } from '@mm-services/db.service'; import { EnketoService } from '@mm-services/enketo.service'; import { TranslateService } from '@mm-services/translate.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; @Injectable({ providedIn: 'root' @@ -24,6 +25,7 @@ export class IntegrationApiService { Settings; AndroidApi; DB; + CHTDatasource; constructor( private dbService:DbService, @@ -35,6 +37,7 @@ export class IntegrationApiService { private mrdtService:MRDTService, private settingsService:SettingsService, private androidApiService:AndroidApiService, + private chtDatasourceService:CHTDatasourceService, ) { this.DB = dbService; this.AndroidAppLauncher = androidAppLauncherService; @@ -45,6 +48,7 @@ export class IntegrationApiService { this.Settings = settingsService; this.AndroidApi = androidApiService; this.Translate = translateService; + 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..72071ad8c03 100644 --- a/webapp/tests/karma/js/enketo/widgets/phone-widget.spec.ts +++ b/webapp/tests/karma/js/enketo/widgets/phone-widget.spec.ts @@ -18,6 +18,8 @@ describe('Enketo: Phone Widget', () => { let settingsService; let dbQuery; let dbService; + let collectUuidsByPhone; + let chtDatasourceService; let originalCHTCore; const inputSelector = (name) => $('input[name="' + name + '"]'); @@ -64,9 +66,14 @@ describe('Enketo: Phone Widget', () => { settingsService = { get: sinon.stub().resolves(SETTINGS) }; dbQuery = sinon.stub().resolves({ rows: [] }); dbService = { get: sinon.stub().returns({ query: dbQuery }) }; + collectUuidsByPhone = sinon.stub().resolves([]); + chtDatasourceService = { + get: sinon.stub().resolves({ v1: { contact: { collectUuidsByPhone } } }) + }; window.CHTCore = { Settings: settingsService, - DB: dbService + DB: dbService, + CHTDatasource: chtDatasourceService }; }); @@ -225,8 +232,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(chtDatasourceService.get.calledOnce).to.be.true; + expect(collectUuidsByPhone.calledOnceWithExactly(NORMALIZED_NUMBER)).to.be.true; expect(consoleError.notCalled).to.be.true; }); @@ -239,15 +246,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(chtDatasourceService.get.notCalled).to.be.true; + expect(collectUuidsByPhone.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' }] }); + collectUuidsByPhone.resolves(['some-id']); const result = await FormModel.prototype.types.unique_tel.validate(DENORMALIZED_NUMBER); @@ -255,8 +262,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(chtDatasourceService.get.calledOnce).to.be.true; + expect(collectUuidsByPhone.calledOnceWithExactly(NORMALIZED_NUMBER)).to.be.true; expect(consoleError.calledOnceWithExactly(`phone number not unique: "${DENORMALIZED_NUMBER}"`)).to.be.true; }); @@ -264,7 +271,7 @@ describe('Enketo: Phone Widget', () => { const contactId = 'my-contact-id'; buildContactFormHtml(contactId); phoneNumberValidate.returns(true); - dbQuery.resolves({ rows: [{ id: contactId }] }); + collectUuidsByPhone.resolves([contactId]); const result = await FormModel.prototype.types.unique_tel.validate(DENORMALIZED_NUMBER); @@ -272,8 +279,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(chtDatasourceService.get.calledOnce).to.be.true; + expect(collectUuidsByPhone.calledOnceWithExactly(NORMALIZED_NUMBER)).to.be.true; expect(consoleError.notCalled).to.be.true; }); });