diff --git a/.eslintrc b/.eslintrc index ede9d7641aa..93363b9d6e2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,6 +16,7 @@ "build/**", "jsdocs/**", "shared-libs/cht-datasource/dist/**", + "shared-libs/cht-datasource/docs/**", "tests/scalability/report*/**", "tests/scalability/jmeter/**", "webapp/src/ts/providers/xpath-element-path.provider.ts" diff --git a/api/src/controllers/person.js b/api/src/controllers/person.js index d8712a2163c..85d3127a4b3 100644 --- a/api/src/controllers/person.js +++ b/api/src/controllers/person.js @@ -8,20 +8,35 @@ const getPerson = ({ with_lineage }) => ctx.bind( ? Person.v1.getWithLineage : Person.v1.get ); +const getPageByType = () => ctx.bind(Person.v1.getPage); + +const checkUserPermissions = async (req) => { + const userCtx = await auth.getUserCtx(req); + if (!auth.isOnlineOnly(userCtx) || !auth.hasAllPermissions(userCtx, 'can_view_contacts')) { + return Promise.reject({ code: 403, message: 'Insufficient privileges' }); + } +}; module.exports = { v1: { get: serverUtils.doOrError(async (req, res) => { - const userCtx = await auth.getUserCtx(req); - if (!auth.isOnlineOnly(userCtx) || !auth.hasAllPermissions(userCtx, 'can_view_contacts')) { - return Promise.reject({ code: 403, message: 'Insufficient privileges' }); - } + await checkUserPermissions(req); const { uuid } = req.params; const person = await getPerson(req.query)(Qualifier.byUuid(uuid)); if (!person) { return serverUtils.error({ status: 404, message: 'Person not found' }, req, res); } return res.json(person); - }) - } + }), + getAll: serverUtils.doOrError(async (req, res) => { + await checkUserPermissions(req); + + const personType = Qualifier.byContactType(req.query.personType); + const limit = req.query.limit ? Number(req.query.limit) : req.query.limit; + + const docs = await getPageByType()( personType, req.query.cursor, limit ); + + return res.json(docs); + }), + }, }; diff --git a/api/src/routing.js b/api/src/routing.js index 1bebdcb1ff7..e192d40840d 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -481,6 +481,7 @@ app.postJson('/api/v1/people', function(req, res) { .catch(err => serverUtils.error(err, req, res)); }); +app.get('/api/v1/person', person.v1.getAll); app.get('/api/v1/person/:uuid', person.v1.get); app.postJson('/api/v1/bulk-delete', bulkDocs.bulkDelete); diff --git a/api/src/server-utils.js b/api/src/server-utils.js index a107431c8c1..c642fbffb4b 100644 --- a/api/src/server-utils.js +++ b/api/src/server-utils.js @@ -5,6 +5,7 @@ const isClientHuman = require('./is-client-human'); const logger = require('@medic/logger'); const MEDIC_BASIC_AUTH = 'Basic realm="Medic Web Services"'; const cookie = require('./services/cookie'); +const {InvalidArgumentError} = require('@medic/cht-datasource'); const wantsJSON = req => req.accepts(['text', 'json']) === 'json'; @@ -57,12 +58,17 @@ module.exports = { if (typeof err === 'string') { return module.exports.serverError(err, req, res); } + // https://github.com/nodejs/node/issues/9027 let code = err.code || err.statusCode || err.status || 500; + if (err instanceof InvalidArgumentError) { + code = 400; + } if (!Number.isInteger(code)) { logger.warn(`Non-numeric error code: ${code}`); code = 500; } + if (code === 401) { return module.exports.notLoggedIn(req, res, showPrompt); } diff --git a/api/tests/mocha/controllers/person.spec.js b/api/tests/mocha/controllers/person.spec.js index 8d841e03793..dae89c769fc 100644 --- a/api/tests/mocha/controllers/person.spec.js +++ b/api/tests/mocha/controllers/person.spec.js @@ -1,6 +1,6 @@ const sinon = require('sinon'); const { expect } = require('chai'); -const { Person, Qualifier } = require('@medic/cht-datasource'); +const { Person, Qualifier, InvalidArgumentError } = require('@medic/cht-datasource'); const auth = require('../../../src/auth'); const controller = require('../../../src/controllers/person'); const dataContext = require('../../../src/services/data-context'); @@ -154,5 +154,112 @@ describe('Person Controller', () => { expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; }); }); + + describe('getAll', () => { + let personGetPageByType; + let qualifierByContactType; + const personType = 'person'; + const invalidPersonType = 'invalidPerson'; + const personTypeQualifier = { contactType: personType }; + const person = { name: 'John Doe' }; + const limit = 100; + const cursor = null; + const people = Array.from({ length: 3 }, () => ({ ...person })); + + beforeEach(() => { + req = { + query: { + personType, + cursor, + limit, + } + }; + personGetPageByType = sinon.stub(); + qualifierByContactType = sinon.stub(Qualifier, 'byContactType'); + dataContextBind.withArgs(Person.v1.getPage).returns(personGetPageByType); + qualifierByContactType.returns(personTypeQualifier); + }); + + afterEach(() => { + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a page of people with correct query params', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + personGetPageByType.resolves(people); + + await controller.v1.getAll(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.personType)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Person.v1.getPage)).to.be.true; + expect(personGetPageByType.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(people)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('returns error if user does not have can_view_contacts permission', async () => { + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(false); + + await controller.v1.getAll(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(personGetPageByType.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + }); + + it('returns error if not an online user', async () => { + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(false); + + await controller.v1.getAll(req, res); + + expect(hasAllPermissions.notCalled).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(qualifierByContactType.notCalled).to.be.true; + expect(personGetPageByType.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + }); + + it('returns 400 error when argument is invalid', async () => { + const err = new InvalidArgumentError(`Invalid contact type: [${invalidPersonType}]`); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + personGetPageByType.throws(err); + + await controller.v1.getAll(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.personType)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Person.v1.getPage)).to.be.true; + expect(personGetPageByType.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + }); + + it('rethrows error in case of other errors', async () => { + const err = new Error('error'); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + personGetPageByType.throws(err); + + await controller.v1.getAll(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.personType)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Person.v1.getPage)).to.be.true; + expect(personGetPageByType.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + }); + }); }); }); diff --git a/api/tests/mocha/server-utils.spec.js b/api/tests/mocha/server-utils.spec.js index 939ecb8bb42..c5665acdbf1 100644 --- a/api/tests/mocha/server-utils.spec.js +++ b/api/tests/mocha/server-utils.spec.js @@ -3,6 +3,7 @@ const chai = require('chai'); const environment = require('@medic/environment'); const serverUtils = require('../../src/server-utils'); const cookie = require('../../src/services/cookie'); +const {InvalidArgumentError} = require('@medic/cht-datasource'); let req; let res; @@ -60,6 +61,18 @@ describe('Server utils', () => { chai.expect(res.end.callCount).to.equal(0); }); + it('function handles InvalidArgument error as bad request(400) error', () => { + const err = new InvalidArgumentError('Bad Request'); + + serverUtils.error(err, req, res); + + chai.expect(res.writeHead.callCount).to.eq(1); + chai.expect(res.writeHead.args[0][0]).to.eq(400); + chai.expect(res.writeHead.args[0][1]['Content-Type']).to.equal('text/plain'); + chai.expect(res.end.callCount).to.equal(1); + chai.expect(res.end.args[0][0]).to.equal('Bad Request'); + }); + it('function handles 503 errors - #3821', () => { // an example error thrown by the `request` library const error = { @@ -124,7 +137,6 @@ describe('Server utils', () => { chai.expect(res.end.callCount).to.equal(1); chai.expect(res.end.args[0][0]).to.equal('foo'); }); - }); describe('notLoggedIn', () => { diff --git a/shared-libs/cht-datasource/.eslintrc.js b/shared-libs/cht-datasource/.eslintrc.js index 061b1e55a75..7edcec0373d 100644 --- a/shared-libs/cht-datasource/.eslintrc.js +++ b/shared-libs/cht-datasource/.eslintrc.js @@ -1,3 +1,13 @@ +const JS_DOC_REQUIRED_CONTEXTS = [ + 'FunctionDeclaration', + 'FunctionExpression', + 'VariableDeclaration', + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + 'TSEnumDeclaration', + 'TSMethodSignature' +]; + module.exports = { overrides: [ { @@ -19,11 +29,6 @@ module.exports = { settings: { jsdoc: { contexts: [ - 'VariableDeclaration', - 'TSInterfaceDeclaration', - 'TSTypeAliasDeclaration', - 'TSEnumDeclaration', - 'TSMethodSignature' ] } }, @@ -42,8 +47,22 @@ module.exports = { FunctionExpression: true, MethodDefinition: true, }, - publicOnly: true, - }] + contexts: JS_DOC_REQUIRED_CONTEXTS, + publicOnly: true + }], + ['jsdoc/require-param']: ['error', { + contexts: JS_DOC_REQUIRED_CONTEXTS, + exemptedBy: ['inheritdoc', 'private', 'internal'] + }], + ['jsdoc/require-returns']: ['error', { + contexts: JS_DOC_REQUIRED_CONTEXTS, + exemptedBy: ['inheritdoc', 'private', 'internal'] + }], + ['jsdoc/require-yields']: ['error', { + contexts: JS_DOC_REQUIRED_CONTEXTS, + exemptedBy: ['inheritdoc', 'private', 'internal'] + }], + ['jsdoc/check-tag-names']: ['error', { definedTags: ['typeParam']}], } } ] diff --git a/shared-libs/cht-datasource/src/index.ts b/shared-libs/cht-datasource/src/index.ts index 1ff1e485bc1..b4186f68dd5 100644 --- a/shared-libs/cht-datasource/src/index.ts +++ b/shared-libs/cht-datasource/src/index.ts @@ -27,6 +27,7 @@ * const myPerson = await datasource.v1.person.getByUuid(myUuid); */ import { hasAnyPermission, hasPermissions } from './auth'; +import { Nullable } from './libs/core'; import { assertDataContext, DataContext } from './libs/data-context'; import * as Person from './person'; import * as Place from './place'; @@ -36,6 +37,7 @@ export { Nullable, NonEmptyArray } from './libs/core'; export { DataContext } from './libs/data-context'; export { getLocalDataContext } from './local'; export { getRemoteDataContext } from './remote'; +export { InvalidArgumentError } from './libs/error'; export * as Person from './person'; export * as Place from './place'; export * as Qualifier from './qualifier'; @@ -85,6 +87,34 @@ export const getDatasource = (ctx: DataContext) => { * @throws Error if no UUID is provided */ getByUuidWithLineage: (uuid: string) => ctx.bind(Person.v1.getWithLineage)(Qualifier.byUuid(uuid)), + + /** + * Returns an array of people for the provided page specifications. + * @param personType the type of people to return + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of people to return. Default is 100. + * @returns a page of people for the provided specifications + * @throws Error if no type is provided or if the type is not for a person + * @throws Error if the provided limit is `<= 0` + * @throws Error if the provided cursor is not a valid page token or `null` + * @see {@link getByType} which provides the same data, but without having to manually account for paging + */ + getPageByType: ( + personType: string, + cursor: Nullable = null, + limit = 100 + ) => ctx.bind(Person.v1.getPage)( + Qualifier.byContactType(personType), cursor, limit + ), + + /** + * Returns a generator for fetching all people with the given type. + * @param personType the type of people to return + * @returns a generator for fetching all people with the given type + * @throws Error if no type is provided or if the type is not for a person + */ + getByType: (personType: string) => ctx.bind(Person.v1.getAll)(Qualifier.byContactType(personType)), } } }; diff --git a/shared-libs/cht-datasource/src/libs/core.ts b/shared-libs/cht-datasource/src/libs/core.ts index f2475cc84f8..d1c87becbbc 100644 --- a/shared-libs/cht-datasource/src/libs/core.ts +++ b/shared-libs/cht-datasource/src/libs/core.ts @@ -112,3 +112,35 @@ export const findById = (values: T[], id: string): Nulla export abstract class AbstractDataContext implements DataContext { readonly bind = (fn: (ctx: DataContext) => T): T => fn(this); } + +/** + * Represents a page of results. The `data` array contains the results for this page. The `cursor` field contains a + * token that can be used to fetch the next page of results. If no `cursor` value is returned, there are no additional + * results available. (Note that no assumptions should be made about the _contents_ of the cursor string.) + * @typeParam T the type of the data in the page + */ +export interface Page { + readonly data: T[]; + readonly cursor: Nullable; +} + +/** @internal */ +export const getPagedGenerator = async function* ( + fetchFunction: (args: S, s: Nullable, l: number) => Promise>, + fetchFunctionArgs: S +): AsyncGenerator { + const limit = 100; + let cursor: Nullable = null; + + do { + const docs = await fetchFunction(fetchFunctionArgs, cursor, limit); + + for (const doc of docs.data) { + yield doc; + } + + cursor = docs.cursor; + } while (cursor); + + return null; +}; diff --git a/shared-libs/cht-datasource/src/libs/error.ts b/shared-libs/cht-datasource/src/libs/error.ts new file mode 100644 index 00000000000..38168373744 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/error.ts @@ -0,0 +1,15 @@ +/** + * Represents an error that occurs when an invalid argument is provided. + * This error is typically thrown when a function or method receives an argument + * that doesn't meet the expected criteria or constraints. + */ +export class InvalidArgumentError extends Error { + /** + * Constructor + * @param message a descriptive error message why the error was raised + */ + constructor(message: string) { + super(message); + this.name = 'InvalidArgumentError'; + } +} diff --git a/shared-libs/cht-datasource/src/local/libs/doc.ts b/shared-libs/cht-datasource/src/local/libs/doc.ts index 37417ba6808..69962a0698b 100644 --- a/shared-libs/cht-datasource/src/local/libs/doc.ts +++ b/shared-libs/cht-datasource/src/local/libs/doc.ts @@ -27,15 +27,29 @@ export const getDocsByIds = (db: PouchDB.Database) => async (uuids: string[ .filter((doc): doc is Doc => isDoc(doc)); }; +const queryDocs = ( + db: PouchDB.Database, + view: string, + options: PouchDB.Query.Options> +) => db + .query(view, options) + .then(({ rows }) => rows.map(({ doc }) => isDoc(doc) ? doc : null)); + /** @internal */ -export const queryDocsByKey = ( +export const queryDocsByRange = ( db: PouchDB.Database, view: string -) => async (key: string): Promise[]> => db - .query(view, { - startkey: [key], - endkey: [key, {}], - include_docs: true - }) - .then(({ rows }) => rows.map(({ doc }) => isDoc(doc) ? doc : null)); +) => async ( + startkey: unknown, + endkey: unknown +): Promise[]> => queryDocs(db, view, { include_docs: true, startkey, endkey}); +/** @internal */ +export const queryDocsByKey = ( + db: PouchDB.Database, + view: string +) => async ( + key: unknown, + limit: number, + skip: number +): Promise[]> => queryDocs(db, view, { include_docs: true, key, limit, skip }); diff --git a/shared-libs/cht-datasource/src/local/libs/lineage.ts b/shared-libs/cht-datasource/src/local/libs/lineage.ts index c23bb40ab69..15b7c97cbaa 100644 --- a/shared-libs/cht-datasource/src/local/libs/lineage.ts +++ b/shared-libs/cht-datasource/src/local/libs/lineage.ts @@ -10,7 +10,7 @@ import { Nullable } from '../../libs/core'; import { Doc } from '../../libs/doc'; -import { queryDocsByKey } from './doc'; +import { queryDocsByRange } from './doc'; import logger from '@medic/logger'; /** @@ -18,9 +18,10 @@ import logger from '@medic/logger'; * sorted such that the identified document is the first element and the parent documents are in order of lineage. * @internal */ -export const getLineageDocsById = ( - medicDb: PouchDB.Database -): (id: string) => Promise[]> => queryDocsByKey(medicDb, 'medic-client/docs_by_id_lineage'); +export const getLineageDocsById = (medicDb: PouchDB.Database): (id: string) => Promise[]> => { + const fn = queryDocsByRange(medicDb, 'medic-client/docs_by_id_lineage'); + return (id: string) => fn([id], [id, {}]); +}; /** @internal */ export const getPrimaryContactIds = (places: NonEmptyArray>): string[] => places diff --git a/shared-libs/cht-datasource/src/local/person.ts b/shared-libs/cht-datasource/src/local/person.ts index 2f23d6d5170..301b3cf44ba 100644 --- a/shared-libs/cht-datasource/src/local/person.ts +++ b/shared-libs/cht-datasource/src/local/person.ts @@ -1,16 +1,17 @@ import { Doc } from '../libs/doc'; import contactTypeUtils from '@medic/contact-types-utils'; -import { deepCopy, isNonEmptyArray, Nullable } from '../libs/core'; -import { UuidQualifier } from '../qualifier'; +import { deepCopy, isNonEmptyArray, Nullable, Page } from '../libs/core'; +import { ContactTypeQualifier, UuidQualifier } from '../qualifier'; import * as Person from '../person'; -import { getDocById, getDocsByIds } from './libs/doc'; +import { getDocById, getDocsByIds, queryDocsByKey } from './libs/doc'; import { LocalDataContext, SettingsService } from './libs/data-context'; import logger from '@medic/logger'; import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage'; +import {InvalidArgumentError} from '../libs/error'; /** @internal */ export namespace v1 { - const isPerson = (settings: SettingsService, uuid: string, doc: Nullable): doc is Person.v1.Person => { + const isPerson = (settings: SettingsService, doc: Nullable, uuid = ''): doc is Person.v1.Person => { if (!doc) { logger.warn(`No person found for identifier [${uuid}].`); return false; @@ -28,7 +29,7 @@ export namespace v1 { const getMedicDocById = getDocById(medicDb); return async (identifier: UuidQualifier): Promise> => { const doc = await getMedicDocById(identifier.uuid); - if (!isPerson(settings, identifier.uuid, doc)) { + if (!isPerson(settings, doc, identifier.uuid)) { return null; } return doc; @@ -41,7 +42,7 @@ export namespace v1 { const getMedicDocsById = getDocsByIds(medicDb); return async (identifier: UuidQualifier): Promise> => { const [person, ...lineagePlaces] = await getLineageDocs(identifier.uuid); - if (!isPerson(settings, identifier.uuid, person)) { + if (!isPerson(settings, person, identifier.uuid)) { return null; } // Intentionally not further validating lineage. For passivity, lineage problems should not block retrieval. @@ -58,4 +59,64 @@ export namespace v1 { return deepCopy(personWithLineage); }; }; + + /** @internal */ + export const getPage = ({ medicDb, settings }: LocalDataContext) => { + const getDocsByPage = queryDocsByKey(medicDb, 'medic-client/contacts_by_type'); + + return async ( + personType: ContactTypeQualifier, + cursor: Nullable, + limit: number, + ): Promise> => { + const personTypes = contactTypeUtils.getPersonTypes(settings.getAll()); + const personTypesIds = personTypes.map((item) => item.id); + + if (!personTypesIds.includes(personType.contactType)) { + throw new InvalidArgumentError(`Invalid contact type [${personType.contactType}]`); + } + + // Adding a number skip variable here so as not to confuse ourselves + const skip = Number(cursor); + if (isNaN(skip) || skip < 0 || !Number.isInteger(skip)) { + throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}]`); + } + + const fetchAndFilter = async ( + currentLimit: number, + currentSkip: number, + currentPersonDocs: Person.v1.Person[] = [], + ): Promise> => { + const docs = await getDocsByPage([personType.contactType], currentLimit, currentSkip); + const noMoreResults = docs.length < currentLimit; + const newPersonDocs = docs.filter((doc): doc is Person.v1.Person => isPerson(settings, doc, doc?._id)); + const overFetchCount = currentPersonDocs.length + newPersonDocs.length - limit || 0; + const totalPeople = [...currentPersonDocs, ...newPersonDocs].slice(0, limit); + + if (noMoreResults) { + return { data: totalPeople, cursor: null }; + } + + if (totalPeople.length === limit) { + const nextSkip = currentSkip + currentLimit - overFetchCount; + + return { data: totalPeople, cursor: nextSkip.toString() }; + } + + // Re-fetch twice as many docs as we need to limit number of recursions + const missingCount = currentLimit - newPersonDocs.length; + logger.debug(`Found [${missingCount.toString()}] invalid persons. Re-fetching additional records.`); + const nextLimit = missingCount * 2; + const nextSkip = currentSkip + currentLimit; + + return fetchAndFilter( + nextLimit, + nextSkip, + totalPeople, + ); + }; + + return fetchAndFilter(limit, skip); + }; + }; } diff --git a/shared-libs/cht-datasource/src/person.ts b/shared-libs/cht-datasource/src/person.ts index 9f1fd64c9f6..4694f14ebe4 100644 --- a/shared-libs/cht-datasource/src/person.ts +++ b/shared-libs/cht-datasource/src/person.ts @@ -1,4 +1,4 @@ -import { isUuidQualifier, UuidQualifier } from './qualifier'; +import { ContactTypeQualifier, isContactTypeQualifier, isUuidQualifier, UuidQualifier } from './qualifier'; import { adapt, assertDataContext, DataContext } from './libs/data-context'; import { Contact, NormalizedParent } from './libs/contact'; import * as Remote from './remote'; @@ -6,6 +6,8 @@ import * as Local from './local'; import * as Place from './place'; import { LocalDataContext } from './local/libs/data-context'; import { RemoteDataContext } from './remote/libs/data-context'; +import { InvalidArgumentError } from './libs/error'; +import { getPagedGenerator, Nullable, Page } from './libs/core'; /** */ export namespace v1 { @@ -28,7 +30,27 @@ export namespace v1 { const assertPersonQualifier: (qualifier: unknown) => asserts qualifier is UuidQualifier = (qualifier: unknown) => { if (!isUuidQualifier(qualifier)) { - throw new Error(`Invalid identifier [${JSON.stringify(qualifier)}].`); + throw new InvalidArgumentError(`Invalid identifier [${JSON.stringify(qualifier)}].`); + } + }; + + const assertTypeQualifier: (qualifier: unknown) => asserts qualifier is ContactTypeQualifier = ( + qualifier: unknown + ) => { + if (!isContactTypeQualifier(qualifier)) { + throw new InvalidArgumentError(`Invalid contact type [${JSON.stringify(qualifier)}].`); + } + }; + + const assertLimit: (limit: unknown) => asserts limit is number = (limit: unknown) => { + if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) { + throw new InvalidArgumentError(`The limit must be a positive number: [${String(limit)}]`); + } + }; + + const assertCursor: (cursor: unknown) => asserts cursor is Nullable = (cursor: unknown) => { + if (cursor !== null && (typeof cursor !== 'string' || !cursor.length)) { + throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}]`); } }; @@ -59,4 +81,67 @@ export namespace v1 { * @throws Error if the provided context or qualifier is invalid */ export const getWithLineage = getPerson(Local.Person.v1.getWithLineage, Remote.Person.v1.getWithLineage); + + /** + * Returns a function for retrieving a paged array of people from the given data context. + * @param context the current data context + * @returns a function for retrieving a paged array of people + * @throws Error if a data context is not provided + * @see {@link getAll} which provides the same data, but without having to manually account for paging + */ + export const getPage = ( + context: DataContext + ): typeof curriedFn => { + assertDataContext(context); + const fn = adapt(context, Local.Person.v1.getPage, Remote.Person.v1.getPage); + + /** + * Returns an array of people for the provided page specifications. + * @param personType the type of people to return + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of people to return. Default is 100. + * @returns a page of people for the provided specification + * @throws Error if no type is provided or if the type is not for a person + * @throws Error if the provided `limit` value is `<=0` + * @throws Error if the provided cursor is not a valid page token or `null` + */ + const curriedFn = async ( + personType: ContactTypeQualifier, + cursor: Nullable = null, + limit = 100, + ): Promise> => { + assertTypeQualifier(personType); + assertCursor(cursor); + assertLimit(limit); + + return fn(personType, cursor, limit); + }; + return curriedFn; + }; + + /** + * Returns a function for getting a generator that fetches people from the given data context. + * @param context the current data context + * @returns a function for getting a generator that fetches people + * @throws Error if a data context is not provided + */ + export const getAll = ( + context: DataContext + ): typeof curriedGen => { + assertDataContext(context); + const getPage = context.bind(v1.getPage); + + /** + * Returns a generator for fetching all people with the given type + * @param personType the type of people to return + * @returns a generator for fetching all people with the given type + * @throws Error if no type is provided or if the type is not for a person + */ + const curriedGen = (personType: ContactTypeQualifier): AsyncGenerator => { + assertTypeQualifier(personType); + return getPagedGenerator(getPage, personType); + }; + return curriedGen; + }; } diff --git a/shared-libs/cht-datasource/src/qualifier.ts b/shared-libs/cht-datasource/src/qualifier.ts index 5b37a18edf5..67c6ac67b1a 100644 --- a/shared-libs/cht-datasource/src/qualifier.ts +++ b/shared-libs/cht-datasource/src/qualifier.ts @@ -1,4 +1,5 @@ import { isString, hasField, isRecord } from './libs/core'; +import { InvalidArgumentError } from './libs/error'; /** * A qualifier that identifies an entity by its UUID. @@ -13,7 +14,7 @@ export type UuidQualifier = Readonly<{ uuid: string }>; */ export const byUuid = (uuid: string): UuidQualifier => { if (!isString(uuid) || uuid.length === 0) { - throw new Error(`Invalid UUID [${JSON.stringify(uuid)}].`); + throw new InvalidArgumentError(`Invalid UUID [${JSON.stringify(uuid)}].`); } return { uuid }; }; @@ -27,3 +28,31 @@ export const byUuid = (uuid: string): UuidQualifier => { export const isUuidQualifier = (identifier: unknown): identifier is UuidQualifier => { return isRecord(identifier) && hasField(identifier, { name: 'uuid', type: 'string' }); }; + +/** + * A qualifier that identifies contacts based on type. + */ +export type ContactTypeQualifier = Readonly<{ contactType: string }>; + +/** + * Build the TypeQualifier that categorizes an entity by its type + * @param contactType the type of the entity + * @returns the type + * @throws Error if the type is invalid + */ +export const byContactType = (contactType: string): ContactTypeQualifier => { + if (!isString(contactType) || contactType.length === 0) { + throw new InvalidArgumentError(`Invalid contact type [${JSON.stringify(contactType)}].`); + } + + return { contactType }; +}; + +/** + * Returns `true` if the given qualifier is a {@link ContactTypeQualifier} otherwise `false`. + * @param contactType the type to check + * @returns `true` if the given type is a {@link ContactTypeQualifier}, otherwise `false`. + */ +export const isContactTypeQualifier = (contactType: unknown): contactType is ContactTypeQualifier => { + return isRecord(contactType) && hasField(contactType, { name: 'contactType', type: 'string' }); +}; diff --git a/shared-libs/cht-datasource/src/remote/libs/data-context.ts b/shared-libs/cht-datasource/src/remote/libs/data-context.ts index 4154142dc35..016d9026399 100644 --- a/shared-libs/cht-datasource/src/remote/libs/data-context.ts +++ b/shared-libs/cht-datasource/src/remote/libs/data-context.ts @@ -58,3 +58,21 @@ export const getResource = (context: RemoteDataContext, path: string) => async < throw error; } }; + +/** @internal */ +export const getResources = (context: RemoteDataContext, path: string) => async ( + queryParams?: Record, +): Promise => { + const params = new URLSearchParams(queryParams).toString(); + try { + const response = await fetch(`${context.url}/${path}?${params}`); + if (!response.ok) { + throw new Error(response.statusText); + } + + return (await response.json()) as T; + } catch (error) { + logger.error(`Failed to fetch resources from ${context.url}/${path} with params: ${params}`, error); + throw error; + } +}; diff --git a/shared-libs/cht-datasource/src/remote/person.ts b/shared-libs/cht-datasource/src/remote/person.ts index 23544a5378d..706ab17896a 100644 --- a/shared-libs/cht-datasource/src/remote/person.ts +++ b/shared-libs/cht-datasource/src/remote/person.ts @@ -1,12 +1,14 @@ -import { Nullable } from '../libs/core'; -import { UuidQualifier } from '../qualifier'; +import { Nullable, Page } from '../libs/core'; +import { ContactTypeQualifier, UuidQualifier } from '../qualifier'; import * as Person from '../person'; -import { getResource, RemoteDataContext } from './libs/data-context'; +import { getResource, getResources, RemoteDataContext } from './libs/data-context'; /** @internal */ export namespace v1 { const getPerson = (remoteContext: RemoteDataContext) => getResource(remoteContext, 'api/v1/person'); + const getPeople = (remoteContext: RemoteDataContext) => getResources(remoteContext, 'api/v1/person'); + /** @internal */ export const get = (remoteContext: RemoteDataContext) => ( identifier: UuidQualifier @@ -19,4 +21,18 @@ export namespace v1 { identifier.uuid, { with_lineage: 'true' } ); + + /** @internal */ + export const getPage = (remoteContext: RemoteDataContext) => ( + personType: ContactTypeQualifier, + cursor: Nullable, + limit: number, + ): Promise> => { + const queryParams = { + 'limit': limit.toString(), + 'personType': personType.contactType, + ...(cursor ? { cursor } : {}) + }; + return getPeople(remoteContext)(queryParams); + }; } diff --git a/shared-libs/cht-datasource/test/index.spec.ts b/shared-libs/cht-datasource/test/index.spec.ts index b53f03a7686..882b6c3c338 100644 --- a/shared-libs/cht-datasource/test/index.spec.ts +++ b/shared-libs/cht-datasource/test/index.spec.ts @@ -7,6 +7,7 @@ import * as Qualifier from '../src/qualifier'; import sinon, { SinonStub } from 'sinon'; import * as Context from '../src/libs/data-context'; import { DataContext } from '../src'; +import { Page } from '../src/libs/core'; describe('CHT Script API - getDatasource', () => { let dataContext: DataContext; @@ -92,7 +93,7 @@ describe('CHT Script API - getDatasource', () => { beforeEach(() => person = v1.person); it('contains expected keys', () => { - expect(person).to.have.all.keys(['getByUuid', 'getByUuidWithLineage']); + expect(person).to.have.all.keys(['getByType', 'getByUuid', 'getByUuidWithLineage', 'getPageByType']); }); it('getByUuid', async () => { @@ -124,6 +125,44 @@ describe('CHT Script API - getDatasource', () => { expect(personGet.calledOnceWithExactly(qualifier)).to.be.true; expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; }); + + it('getPageByType', async () => { + const expectedPeople: Page = {data: [], cursor: null}; + const personGetPage = sinon.stub().resolves(expectedPeople); + dataContextBind.returns(personGetPage); + const personType = 'person'; + const limit = 2; + const cursor = '1'; + const personTypeQualifier = { contactType: personType }; + const byContactType = sinon.stub(Qualifier, 'byContactType').returns(personTypeQualifier); + + const returnedPeople = await person.getPageByType(personType, cursor, limit); + + expect(returnedPeople).to.equal(expectedPeople); + expect(dataContextBind.calledOnceWithExactly(Person.v1.getPage)).to.be.true; + expect(personGetPage.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true; + expect(byContactType.calledOnceWithExactly(personType)).to.be.true; + }); + + it('getByType', () => { + const mockAsyncGenerator = async function* () { + await Promise.resolve(); + yield []; + }; + + const personGetAll = sinon.stub().returns(mockAsyncGenerator); + dataContextBind.returns(personGetAll); + const personType = 'person'; + const personTypeQualifier = { contactType: personType }; + const byContactType = sinon.stub(Qualifier, 'byContactType').returns(personTypeQualifier); + + const res = person.getByType(personType); + + expect(res).to.deep.equal(mockAsyncGenerator); + expect(dataContextBind.calledOnceWithExactly(Person.v1.getAll)).to.be.true; + expect(personGetAll.calledOnceWithExactly(personTypeQualifier)).to.be.true; + expect(byContactType.calledOnceWithExactly(personType)).to.be.true; + }); }); }); }); diff --git a/shared-libs/cht-datasource/test/libs/core.spec.ts b/shared-libs/cht-datasource/test/libs/core.spec.ts index 9344cbbd4c3..94d60c946f3 100644 --- a/shared-libs/cht-datasource/test/libs/core.spec.ts +++ b/shared-libs/cht-datasource/test/libs/core.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { - AbstractDataContext, deepCopy, findById, getLastElement, + AbstractDataContext, deepCopy, findById, getLastElement, getPagedGenerator, hasField, hasFields, isDataObject, isIdentifiable, isNonEmptyArray, @@ -8,7 +8,7 @@ import { isString, NonEmptyArray } from '../../src/libs/core'; -import sinon from 'sinon'; +import sinon, { SinonStub } from 'sinon'; describe('core lib', () => { afterEach(() => sinon.restore()); @@ -204,4 +204,68 @@ describe('core lib', () => { expect(testFn.calledOnceWithExactly(ctx)).to.be.true; }); }); + + describe('getPagedGenerator', () => { + let fetchFunctionStub: SinonStub; + const limit = 100; + const cursor = null; + + beforeEach(() => { + fetchFunctionStub = sinon.stub(); + }); + + it('yields document one by one', async () => { + const mockDocs = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const mockPage = { data: mockDocs, cursor }; + const extraArg = 'value'; + fetchFunctionStub.resolves(mockPage); + + const generator = getPagedGenerator(fetchFunctionStub, extraArg); + + const results = []; + + for await (const doc of generator) { + results.push(doc); + } + + expect(results).to.deep.equal(mockDocs); + expect(fetchFunctionStub.calledOnceWithExactly(extraArg, cursor, limit)).to.be.true; + }); + + it('should handle multiple pages', async () => { + const mockDoc = { id: 1 }; + const mockDocs1 = Array.from({ length: 100 }, () => ({ ...mockDoc })); + const mockPage1 = { data: mockDocs1, cursor: '100' }; + const mockDocs2 = [{ id: 101 }]; + const mockPage2 = { data: mockDocs2, cursor }; + const extraArg = 'value'; + + fetchFunctionStub.onFirstCall().resolves(mockPage1); + fetchFunctionStub.onSecondCall().resolves(mockPage2); + + const generator = getPagedGenerator(fetchFunctionStub, extraArg); + + const results = []; + for await (const doc of generator) { + results.push(doc); + } + + expect(results).to.deep.equal([...mockDocs1, ...mockDocs2]); + expect(fetchFunctionStub.callCount).to.equal(2); + expect(fetchFunctionStub.firstCall.args).to.deep.equal([extraArg, cursor, limit]); + expect(fetchFunctionStub.secondCall.args).to.deep.equal([extraArg, (Number(cursor) + limit).toString(), limit]); + }); + + it('should handle empty result', async () => { + fetchFunctionStub.resolves({ data: [], cursor }); + + const generator = getPagedGenerator(fetchFunctionStub, { limit: 10, cursor }); + + const result = await generator.next(); + + expect(result.done).to.be.true; + expect(result.value).to.be.equal(null); + expect(fetchFunctionStub.calledOnce).to.be.true; + }); + }); }); diff --git a/shared-libs/cht-datasource/test/local/libs/doc.spec.ts b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts index 640237a1165..33c8aa7bdb0 100644 --- a/shared-libs/cht-datasource/test/local/libs/doc.spec.ts +++ b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts @@ -1,7 +1,7 @@ import * as Doc from '../../../src/libs/doc'; import sinon, { SinonStub } from 'sinon'; import logger from '@medic/logger'; -import { getDocById, getDocsByIds, queryDocsByKey } from '../../../src/local/libs/doc'; +import { getDocById, getDocsByIds, queryDocsByKey, queryDocsByRange } from '../../../src/local/libs/doc'; import { expect } from 'chai'; describe('local doc lib', () => { @@ -149,7 +149,7 @@ describe('local doc lib', () => { }); }); - describe('queryDocsByKey', () => { + describe('queryDocsByRange', () => { it('returns lineage docs for the given id', async () => { const doc0 = { _id: 'doc0' }; const doc1 = { _id: 'doc1' }; @@ -163,13 +163,14 @@ describe('local doc lib', () => { }); isDoc.returns(true); - const result = await queryDocsByKey(db, 'medic-client/docs_by_id_lineage')(doc0._id); + const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc1._id); expect(result).to.deep.equal([doc0, doc1, doc2]); + expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { - startkey: [doc0._id], - endkey: [doc0._id, {}], - include_docs: true + include_docs: true, + startkey: doc0._id, + endkey: doc1._id })).to.be.true; expect(isDoc.args).to.deep.equal([[doc0], [doc1], [doc2]]); }); @@ -186,12 +187,12 @@ describe('local doc lib', () => { }); isDoc.returns(true); - const result = await queryDocsByKey(db, 'medic-client/docs_by_id_lineage')(doc0._id); + const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc2._id); expect(result).to.deep.equal([doc0, null, doc2]); expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { - startkey: [doc0._id], - endkey: [doc0._id, {}], + startkey: doc0._id, + endkey: doc2._id, include_docs: true })).to.be.true; expect(isDoc.args).to.deep.equal([[doc0], [null], [doc2]]); @@ -204,15 +205,82 @@ describe('local doc lib', () => { }); isDoc.returns(false); - const result = await queryDocsByKey(db, 'medic-client/docs_by_id_lineage')(doc0._id); + const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc0._id); expect(result).to.deep.equal([null]); expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { - startkey: [doc0._id], - endkey: [doc0._id, {}], + startkey: doc0._id, + endkey: doc0._id, include_docs: true })).to.be.true; expect(isDoc.calledOnceWithExactly(doc0)).to.be.true; }); }); + + describe('queryDocsByKey', () => { + const limit = 100; + const skip = 0; + const contactType = 'person'; + + it('returns docs on the basis of given key in pages', async () => { + const doc0 = { _id: 'doc0' }; + const doc1 = { _id: 'doc1' }; + const doc2 = { _id: 'doc2' }; + + dbQuery.resolves({ + rows: [ + { doc: doc0 }, + { doc: doc1 }, + { doc: doc2 } + ] + }); + isDoc.returns(true); + + const result = await queryDocsByKey(db, 'medic-client/contacts_by_type')(contactType, limit, skip); + + expect(result).to.deep.equal([doc0, doc1, doc2]); + expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_type', { + include_docs: true, + key: contactType, + limit, + skip + })).to.be.true; + expect(isDoc.args).to.deep.equal([[doc0], [doc1], [doc2]]); + }); + + it('returns empty array if docs are not found', async () => { + dbQuery.resolves({ rows: [] }); + isDoc.returns(true); + + const result = await queryDocsByKey(db, 'medic-client/contacts_by_type')(contactType, limit, skip); + + expect(result).to.deep.equal([]); + expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_type', { + include_docs: true, key: contactType, limit, skip + })).to.be.true; + expect(isDoc.args).to.deep.equal([]); + }); + + it('returns null valued array if rows from database are not docs', async () => { + const doc0 = { _id: 'doc0' }; + + dbQuery.resolves({ + rows: [ + { doc: doc0 }, + ] + }); + isDoc.returns(false); + + const result = await queryDocsByKey(db, 'medic-client/contacts_by_type')(contactType, limit, skip); + + expect(result).to.deep.equal([null]); + expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_type', { + include_docs: true, + key: contactType, + limit, + skip + })).to.be.true; + expect(isDoc.args).to.deep.equal([[doc0]]); + }); + }); }); diff --git a/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts b/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts index 20adcd0a0cd..60539b9cb93 100644 --- a/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts +++ b/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts @@ -16,20 +16,22 @@ describe('local lineage lib', () => { beforeEach(() => { debug = sinon.stub(logger, 'debug'); }); - afterEach(() => sinon.restore()); - it('getLineageDocsById', () => { - const queryFn = sinon.stub(); - const queryDocsByKey = sinon - .stub(LocalDoc, 'queryDocsByKey') + it('getLineageDocsById', async () => { + const uuid = '123'; + const queryFn = sinon.stub().resolves([]); + const queryDocsByRange = sinon + .stub(LocalDoc, 'queryDocsByRange') .returns(queryFn); const medicDb = { hello: 'world' } as unknown as PouchDB.Database; - const result = getLineageDocsById(medicDb); + const fn = getLineageDocsById(medicDb); + const result = await fn(uuid); - expect(result).to.equal(queryFn); - expect(queryDocsByKey.calledOnceWithExactly(medicDb, 'medic-client/docs_by_id_lineage')).to.be.true; + expect(result).to.deep.equal([]); + expect(queryDocsByRange.calledOnceWithExactly(medicDb, 'medic-client/docs_by_id_lineage')).to.be.true; + expect(queryFn.calledOnceWithExactly([uuid], [uuid, {}])).to.be.true; }); describe('getPrimaryContactIds', () => { diff --git a/shared-libs/cht-datasource/test/local/person.spec.ts b/shared-libs/cht-datasource/test/local/person.spec.ts index 22d3c3b1e51..af1d5265da7 100644 --- a/shared-libs/cht-datasource/test/local/person.spec.ts +++ b/shared-libs/cht-datasource/test/local/person.spec.ts @@ -224,5 +224,149 @@ describe('local person', () => { expect(deepCopy.notCalled).to.be.true; }); }); + + describe('getPage', () => { + const limit = 3; + const cursor = null; + const notNullCursor = '5'; + const personIdentifier = 'person'; + const personTypeQualifier = {contactType: personIdentifier} as const; + const invalidPersonTypeQualifier = { contactType: 'invalid' } as const; + const personType = [{person: true, id: personIdentifier}] as Record[]; + let getPersonTypes: SinonStub; + let queryDocsByKeyInner: SinonStub; + let queryDocsByKeyOuter: SinonStub; + + beforeEach(() => { + queryDocsByKeyInner = sinon.stub(); + queryDocsByKeyOuter = sinon.stub(LocalDoc, 'queryDocsByKey').returns(queryDocsByKeyInner); + getPersonTypes = sinon.stub(contactTypeUtils, 'getPersonTypes').returns(personType); + settingsGetAll.returns(settings); + isPerson.returns(true); + }); + + it('returns a page of people', async () => { + const doc = { type: 'person'}; + const docs = [doc, doc, doc]; + queryDocsByKeyInner.resolves(docs); + const expectedResult = { + cursor: '3', + data: docs + }; + + const res = await Person.v1.getPage(localContext)(personTypeQualifier, cursor, limit); + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(4); + expect(getPersonTypes.calledOnceWithExactly(settings)).to.be.true; + expect( + queryDocsByKeyOuter.calledOnceWithExactly(localContext.medicDb, 'medic-client/contacts_by_type') + ).to.be.true; + expect(queryDocsByKeyInner.calledOnceWithExactly([personIdentifier], limit, Number(cursor))).to.be.true; + expect(isPerson.callCount).to.equal(3); + isPerson.args.forEach((arg) => expect(arg).to.deep.equal([settings, doc])); + }); + + it('returns a page of people when cursor is not null', async () => { + const doc = { type: 'person'}; + const docs = [doc, doc, doc]; + queryDocsByKeyInner.resolves(docs); + const expectedResult = { + cursor: '8', + data: docs + }; + + const res = await Person.v1.getPage(localContext)(personTypeQualifier, notNullCursor, limit); + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(4); + expect(getPersonTypes.calledOnceWithExactly(settings)).to.be.true; + expect( + queryDocsByKeyOuter.calledOnceWithExactly(localContext.medicDb, 'medic-client/contacts_by_type') + ).to.be.true; + expect(queryDocsByKeyInner.calledOnceWithExactly([personIdentifier], limit, Number(notNullCursor))).to.be.true; + expect(isPerson.callCount).to.equal(3); + isPerson.args.forEach((arg) => expect(arg).to.deep.equal([settings, doc])); + }); + + it('throws an error if person identifier is invalid/does not exist', async () => { + await expect(Person.v1.getPage(localContext)(invalidPersonTypeQualifier, cursor, limit)).to.be.rejectedWith( + `Invalid contact type [${invalidPersonTypeQualifier.contactType}]` + ); + + expect(settingsGetAll.calledOnce).to.be.true; + expect(getPersonTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocsByKeyOuter.calledOnceWithExactly(localContext.medicDb, 'medic-client/contacts_by_type')) + .to.be.true; + expect(queryDocsByKeyInner.notCalled).to.be.true; + expect(isPerson.notCalled).to.be.true; + }); + + [ + {}, + '', + '-1', + undefined, + false + ].forEach((invalidSkip ) => { + it(`throws an error if cursor is invalid: ${String(invalidSkip)}`, async () => { + await expect(Person.v1.getPage(localContext)(invalidPersonTypeQualifier, invalidSkip as string, limit)) + .to.be.rejectedWith(`Invalid contact type [${invalidPersonTypeQualifier.contactType}]`); + + expect(settingsGetAll.calledOnce).to.be.true; + expect(getPersonTypes.calledOnceWithExactly(settings)).to.be.true; + expect(queryDocsByKeyOuter.calledOnceWithExactly(localContext.medicDb, 'medic-client/contacts_by_type')) + .to.be.true; + expect(queryDocsByKeyInner.notCalled).to.be.true; + expect(isPerson.notCalled).to.be.true; + }); + }); + + it('returns empty array if people does not exist', async () => { + queryDocsByKeyInner.resolves([]); + const expectedResult = { + data: [], + cursor + }; + + const res = await Person.v1.getPage(localContext)(personTypeQualifier, cursor, limit); + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.calledOnce).to.be.true; + expect(getPersonTypes.calledOnceWithExactly(settings)).to.be.true; + expect( + queryDocsByKeyOuter.calledOnceWithExactly(localContext.medicDb, 'medic-client/contacts_by_type') + ).to.be.true; + expect(queryDocsByKeyInner.calledOnceWithExactly([personIdentifier], limit, Number(cursor))).to.be.true; + expect(isPerson.notCalled).to.be.true; + }); + + it('returns page of people by refetching the database if the previous lot consisted on non-persons', async () => { + const doc = { type: 'person'}; + const docs = [doc, doc, doc]; + queryDocsByKeyInner.resolves(docs); + isPerson.onFirstCall().returns(false); + isPerson.onSecondCall().returns(false); + isPerson.returns(true); + const expectedResult = { + data: docs, + cursor + }; + + const res = await Person.v1.getPage(localContext)(personTypeQualifier, cursor, limit); + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(7); + expect(getPersonTypes.calledOnceWithExactly(settings)).to.be.true; + expect( + queryDocsByKeyOuter.calledOnceWithExactly(localContext.medicDb, 'medic-client/contacts_by_type') + ).to.be.true; + expect(queryDocsByKeyInner.callCount).to.be.equal(2); + expect(queryDocsByKeyInner.firstCall.args).to.deep.equal([[personIdentifier], limit, Number(cursor)]); + expect(queryDocsByKeyInner.secondCall.args).to.deep.equal([[personIdentifier], 4, 3]); + expect(isPerson.callCount).to.equal(6); + isPerson.args.forEach((arg) => expect(arg).to.deep.equal([settings, doc])); + }); + }); }); }); diff --git a/shared-libs/cht-datasource/test/person.spec.ts b/shared-libs/cht-datasource/test/person.spec.ts index 5dd3f0c1f2a..7e739c3a167 100644 --- a/shared-libs/cht-datasource/test/person.spec.ts +++ b/shared-libs/cht-datasource/test/person.spec.ts @@ -3,6 +3,7 @@ import * as Local from '../src/local'; import * as Remote from '../src/remote'; import * as Qualifier from '../src/qualifier'; import * as Context from '../src/libs/data-context'; +import * as Core from '../src/libs/core'; import sinon, { SinonStub } from 'sinon'; import { expect } from 'chai'; import { DataContext } from '../src'; @@ -12,11 +13,13 @@ describe('person', () => { let assertDataContext: SinonStub; let adapt: SinonStub; let isUuidQualifier: SinonStub; + let isContactTypeQualifier: SinonStub; beforeEach(() => { assertDataContext = sinon.stub(Context, 'assertDataContext'); adapt = sinon.stub(Context, 'adapt'); isUuidQualifier = sinon.stub(Qualifier, 'isUuidQualifier'); + isContactTypeQualifier = sinon.stub(Qualifier, 'isContactTypeQualifier'); }); afterEach(() => sinon.restore()); @@ -123,5 +126,171 @@ describe('person', () => { expect(getPersonWithLineage.notCalled).to.be.true; }); }); + + describe('getPage', () => { + const people = [{ _id: 'person1' }, { _id: 'person2' }, { _id: 'person3' }] as Person.v1.Person[]; + const cursor = '1'; + const pageData = { data: people, cursor }; + const limit = 3; + const personTypeQualifier = {contactType: 'person'} as const; + const invalidQualifier = { contactType: 'invalid' } as const; + let getPage: SinonStub; + + beforeEach(() => { + getPage = sinon.stub(); + adapt.returns(getPage); + }); + + it('retrieves people from the data context when cursor is null', async () => { + isContactTypeQualifier.returns(true); + getPage.resolves(pageData); + + const result = await Person.v1.getPage(dataContext)(personTypeQualifier, null, limit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.getPage, Remote.Person.v1.getPage)).to.be.true; + expect(getPage.calledOnceWithExactly(personTypeQualifier, null, limit)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((personTypeQualifier))).to.be.true; + }); + + it('retrieves people from the data context when cursor is not null', async () => { + isContactTypeQualifier.returns(true); + getPage.resolves(pageData); + + const result = await Person.v1.getPage(dataContext)(personTypeQualifier, cursor, limit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.getPage, Remote.Person.v1.getPage)).to.be.true; + expect(getPage.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((personTypeQualifier))).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + isContactTypeQualifier.returns(true); + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Person.v1.getPage(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(getPage.notCalled).to.be.true; + expect(isContactTypeQualifier.notCalled).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isContactTypeQualifier.returns(false); + + await expect(Person.v1.getPage(dataContext)(invalidQualifier, cursor, limit)) + .to.be.rejectedWith(`Invalid contact type [${JSON.stringify(invalidQualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.getPage, Remote.Person.v1.getPage)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(invalidQualifier)).to.be.true; + expect(getPage.notCalled).to.be.true; + }); + + [ + -1, + null, + {}, + '', + 0, + 1.1, + false + ].forEach((limitValue) => { + it(`throws an error if limit is invalid: ${String(limitValue)}`, async () => { + isContactTypeQualifier.returns(true); + getPage.resolves(people); + + await expect(Person.v1.getPage(dataContext)(personTypeQualifier, cursor, limitValue as number)) + .to.be.rejectedWith(`The limit must be a positive number: [${String(limitValue)}]`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.getPage, Remote.Person.v1.getPage)) + .to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((personTypeQualifier))).to.be.true; + expect(getPage.notCalled).to.be.true; + }); + }); + + [ + {}, + '', + 1, + false, + ].forEach((skipValue) => { + it('throws an error if cursor is invalid', async () => { + isContactTypeQualifier.returns(true); + getPage.resolves(people); + + await expect(Person.v1.getPage(dataContext)(personTypeQualifier, skipValue as string, limit)) + .to.be.rejectedWith(`Invalid cursor token: [${String(skipValue)}]`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.getPage, Remote.Person.v1.getPage)) + .to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((personTypeQualifier))).to.be.true; + expect(getPage.notCalled).to.be.true; + }); + }); + }); + + describe('getAll', () => { + const personType = 'person'; + const personTypeQualifier = {contactType: personType} as const; + const firstPerson = { _id: 'person1' } as Person.v1.Person; + const secondPerson = { _id: 'person2' } as Person.v1.Person; + const thirdPerson = { _id: 'person3' } as Person.v1.Person; + const people = [firstPerson, secondPerson, thirdPerson]; + const mockGenerator = function* () { + for (const person of people) { + yield person; + } + }; + + let personGetPage: sinon.SinonStub; + let getPagedGenerator: sinon.SinonStub; + + beforeEach(() => { + personGetPage = sinon.stub(Person.v1, 'getPage'); + dataContext.bind = sinon.stub().returns(personGetPage); + getPagedGenerator = sinon.stub(Core, 'getPagedGenerator'); + }); + + it('should get people generator with correct parameters', () => { + isContactTypeQualifier.returns(true); + getPagedGenerator.returns(mockGenerator); + + const generator = Person.v1.getAll(dataContext)(personTypeQualifier); + + expect(generator).to.deep.equal(mockGenerator); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(getPagedGenerator.calledOnceWithExactly(personGetPage, personTypeQualifier)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(personTypeQualifier)).to.be.true; + }); + + it('should throw an error for invalid datacontext', () => { + const errMsg = 'Invalid data context [null].'; + isContactTypeQualifier.returns(true); + assertDataContext.throws(new Error(errMsg)); + + expect(() => Person.v1.getAll(dataContext)).to.throw(errMsg); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(personGetPage.notCalled).to.be.true; + expect(isContactTypeQualifier.notCalled).to.be.true; + }); + + it('should throw an error for invalid personType', () => { + isContactTypeQualifier.returns(false); + + expect(() => Person.v1.getAll(dataContext)(personTypeQualifier)) + .to.throw(`Invalid contact type [${JSON.stringify(personTypeQualifier)}]`); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(personGetPage.notCalled).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(personTypeQualifier)).to.be.true; + }); + }); }); }); diff --git a/shared-libs/cht-datasource/test/qualifier.spec.ts b/shared-libs/cht-datasource/test/qualifier.spec.ts index 0b9fcb3b5b3..49a06da641c 100644 --- a/shared-libs/cht-datasource/test/qualifier.spec.ts +++ b/shared-libs/cht-datasource/test/qualifier.spec.ts @@ -1,4 +1,4 @@ -import { byUuid, isUuidQualifier } from '../src/qualifier'; +import {byContactType, byUuid, isContactTypeQualifier, isUuidQualifier} from '../src/qualifier'; import { expect } from 'chai'; describe('qualifier', () => { @@ -31,4 +31,36 @@ describe('qualifier', () => { }); }); }); + + describe('byContactType', () => { + it('builds a qualifier that identifies an entity by its contactType', () => { + expect(byContactType('person')).to.deep.equal({ contactType: 'person' }); + }); + + [ + null, + '', + { }, + ].forEach(contactType => { + it(`throws an error for ${JSON.stringify(contactType)}`, () => { + expect(() => byContactType(contactType as string)).to.throw( + `Invalid contact type [${JSON.stringify(contactType)}].` + ); + }); + }); + }); + + describe('isContactTypeQualifier', () => { + [ + [ null, false ], + [ 'person', false ], + [ { contactType: { } }, false ], + [ { contactType: 'person' }, true ], + [ { contactType: 'person', other: 'other' }, true ] + ].forEach(([ contactType, expected ]) => { + it(`evaluates ${JSON.stringify(contactType)}`, () => { + expect(isContactTypeQualifier(contactType)).to.equal(expected); + }); + }); + }); }); diff --git a/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts b/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts index 8516b547e2e..bc302dac2fa 100644 --- a/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts +++ b/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts @@ -4,6 +4,7 @@ import sinon, { SinonStub } from 'sinon'; import { assertRemoteDataContext, getResource, + getResources, getRemoteDataContext, isRemoteDataContext, RemoteDataContext @@ -149,4 +150,53 @@ describe('remote context lib', () => { expect(fetchResponse.json.notCalled).to.be.true; }); }); + + describe('getResources', () => { + const params = {abc: 'xyz'}; + const stringifiedParams = new URLSearchParams(params).toString(); + + it('fetches a resource with a path', async () => { + const path = 'path'; + const resource = { hello: 'world' }; + fetchResponse.json.resolves(resource); + + const response = await getResources(context, path)(params); + + expect(response).to.equal(resource); + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}?${stringifiedParams}`)).to.be.true; + expect(fetchResponse.json.calledOnceWithExactly()).to.be.true; + }); + + it('throws an error if the resource fetch rejects', async () => { + const path = 'path'; + const expectedError = new Error('unexpected error'); + fetchStub.rejects(expectedError); + + await expect(getResources(context, path)(params)).to.be.rejectedWith(expectedError); + + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}?${stringifiedParams}`)).to.be.true; + expect(loggerError.calledOnceWithExactly( + `Failed to fetch resources from ${context.url}/${path} with params: ${stringifiedParams}`, + expectedError + )).to.be.true; + expect(fetchResponse.json.notCalled).to.be.true; + }); + + it('throws an error if the resource fetch resolves an error status', async () => { + const path = 'path'; + fetchResponse.ok = false; + fetchResponse.status = 501; + fetchResponse.statusText = 'Not Implemented'; + + await expect(getResources(context, path)(params)).to.be.rejectedWith(fetchResponse.statusText); + + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}?${stringifiedParams}`)).to.be.true; + expect(loggerError.calledOnce).to.be.true; + expect(loggerError.args[0]).to.deep.equal([ + `Failed to fetch resources from ${context.url}/${path} with params: ${stringifiedParams}`, + new Error(fetchResponse.statusText) + ]); + expect(fetchResponse.json.notCalled).to.be.true; + }); + }); }); diff --git a/shared-libs/cht-datasource/test/remote/person.spec.ts b/shared-libs/cht-datasource/test/remote/person.spec.ts index 41ac0a4b0b0..a4a91cbccc0 100644 --- a/shared-libs/cht-datasource/test/remote/person.spec.ts +++ b/shared-libs/cht-datasource/test/remote/person.spec.ts @@ -8,10 +8,14 @@ describe('remote person', () => { const remoteContext = {} as RemoteDataContext; let getResourceInner: SinonStub; let getResourceOuter: SinonStub; + let getResourcesInner: SinonStub; + let getResourcesOuter: SinonStub; beforeEach(() => { getResourceInner = sinon.stub(); getResourceOuter = sinon.stub(RemoteEnv, 'getResource').returns(getResourceInner); + getResourcesInner = sinon.stub(); + getResourcesOuter = sinon.stub(RemoteEnv, 'getResources').returns(getResourcesInner); }); afterEach(() => sinon.restore()); @@ -64,5 +68,39 @@ describe('remote person', () => { expect(getResourceInner.calledOnceWithExactly(identifier.uuid, { with_lineage: 'true' })).to.be.true; }); }); + + describe('getPage', () => { + const limit = 3; + const cursor = '1'; + const personType = 'person'; + const personTypeQualifier = {contactType: personType}; + const queryParam = { + limit: limit.toString(), + personType, + cursor, + }; + + it('returns people', async () => { + const doc = [{ type: 'person' }, {type: 'person'}]; + const expectedResponse = { data: doc, cursor }; + getResourcesInner.resolves(expectedResponse); + + const result = await Person.v1.getPage(remoteContext)(personTypeQualifier, cursor, limit); + + expect(result).to.equal(expectedResponse); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/person')).to.be.true; + expect(getResourcesInner.calledOnceWithExactly(queryParam)).to.be.true; + }); + + it('returns empty array if docs are not found', async () => { + getResourcesInner.resolves([]); + + const result = await Person.v1.getPage(remoteContext)(personTypeQualifier, cursor, limit); + + expect(result).to.deep.equal([]); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/person')).to.be.true; + expect(getResourcesInner.calledOnceWithExactly(queryParam)).to.be.true; + }); + }); }); }); diff --git a/tests/integration/.mocharc-base.js b/tests/integration/.mocharc-base.js index 473c38d1491..99223dd2f61 100644 --- a/tests/integration/.mocharc-base.js +++ b/tests/integration/.mocharc-base.js @@ -2,8 +2,11 @@ require('../aliases'); const chaiExclude = require('chai-exclude'); const chaiAsPromised = require('chai-as-promised'); const chai = require('chai'); +const deepEqualInAnyOrder = require('deep-equal-in-any-order'); + chai.use(chaiExclude); chai.use(chaiAsPromised); +chai.use(deepEqualInAnyOrder); global.expect = chai.expect; module.exports = { diff --git a/tests/integration/api/controllers/person.spec.js b/tests/integration/api/controllers/person.spec.js index aefdd14fac6..7a4686199da 100644 --- a/tests/integration/api/controllers/person.spec.js +++ b/tests/integration/api/controllers/person.spec.js @@ -7,8 +7,8 @@ const userFactory = require('@factories/cht/users/users'); describe('Person API', () => { const contact0 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'chw' })); - const contact1 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'chw_supervisor' })); - const contact2 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'program_officer' })); + const contact1 = utils.deepFreeze(personFactory.build({ name: 'contact1', role: 'chw_supervisor' })); + const contact2 = utils.deepFreeze(personFactory.build({ name: 'contact2', role: 'program_officer' })); const placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); const place0 = utils.deepFreeze({ ...placeMap.get('clinic'), contact: { _id: contact0._id } }); const place1 = utils.deepFreeze({ ...placeMap.get('health_center'), contact: { _id: contact1._id } }); @@ -46,10 +46,47 @@ describe('Person API', () => { }, roles: ['chw'] })); + const allDocItems = [contact0, contact1, contact2, place0, place1, place2, patient]; const dataContext = getRemoteDataContext(utils.getOrigin()); + const personType = 'person'; + const e2eTestUser = { + '_id': 'e2e_contact_test_id', + 'type': personType, + }; + const onlineUserPlaceHierarchy = { + parent: { + _id: place1._id, + parent: { + _id: place2._id, + } + } + }; + const offlineUserPlaceHierarchy = { + parent: { + _id: place0._id, + ...onlineUserPlaceHierarchy + } + }; + const expectedPeople = [ + contact0, + contact1, + contact2, + patient, + e2eTestUser, + { + type: personType, + ...userNoPerms.contact, + ...onlineUserPlaceHierarchy + }, + { + type: personType, + ...offlineUser.contact, + ...offlineUserPlaceHierarchy + } + ]; before(async () => { - await utils.saveDocs([contact0, contact1, contact2, place0, place1, place2, patient]); + await utils.saveDocs(allDocItems); await utils.createUsers([userNoPerms, offlineUser]); }); @@ -104,4 +141,108 @@ describe('Person API', () => { }); }); }); + + describe('GET /api/v1/person', async () => { + const getPage = Person.v1.getPage(dataContext); + const limit = 4; + const cursor = null; + const invalidContactType = 'invalidPerson'; + + it('returns a page of people for no limit and cursor passed', async () => { + const responsePage = await getPage(Qualifier.byContactType(personType)); + const responsePeople = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePeople).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPeople); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of people when limit and cursor is passed and cursor can be reused', async () => { + const firstPage = await getPage(Qualifier.byContactType(personType), cursor, limit); + const secondPage = await getPage(Qualifier.byContactType(personType), firstPage.cursor, limit); + + const allPeople = [...firstPage.data, ...secondPage.data]; + + expect(allPeople).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPeople); + expect(firstPage.data.length).to.be.equal(4); + expect(secondPage.data.length).to.be.equal(3); + expect(firstPage.cursor).to.be.equal('4'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it(`throws error when user does not have can_view_contacts permission`, async () => { + const opts = { + path: `/api/v1/person`, + auth: { username: userNoPerms.username, password: userNoPerms.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + + it(`throws error when user is not an online user`, async () => { + const opts = { + path: `/api/v1/person`, + auth: { username: offlineUser.username, password: offlineUser.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + + it('throws 400 error when personType is invalid', async () => { + const queryParams = { + 'personType': invalidContactType + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `/api/v1/person?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith(`400 - {"code":400,"error":"Invalid contact type [${invalidContactType}]"}`); + }); + + it('throws 400 error when limit is invalid', async () => { + const queryParams = { + personType, + limit: -1 + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `/api/v1/person?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith(`400 - {"code":400,"error":"The limit must be a positive number: [${-1}]"}`); + }); + + it('throws 400 error when cursor is invalid', async () => { + const queryParams = { + personType, + cursor: '-1' + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `/api/v1/person?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith( + `400 - {"code":400,"error":"Invalid cursor token: [${-1}]"}` + ); + }); + }); + + describe('Person.v1.getAll', async () => { + const personType = 'person'; + + it('fetches all data by iterating through generator', async () => { + const docs = []; + + const generator = Person.v1.getAll(dataContext)(Qualifier.byContactType(personType)); + + for await (const doc of generator) { + docs.push(doc); + } + + expect(docs).excluding(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPeople); + }); + }); });