diff --git a/api/src/controllers/place.js b/api/src/controllers/place.js index 869d2b625ee..ff184786561 100644 --- a/api/src/controllers/place.js +++ b/api/src/controllers/place.js @@ -9,19 +9,35 @@ const getPlace = ({ with_lineage }) => ctx.bind( : Place.v1.get ); +const getPageByType = () => ctx.bind(Place.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 place = await getPlace(req.query)(Qualifier.byUuid(uuid)); if (!place) { return serverUtils.error({ status: 404, message: 'Place not found' }, req, res); } return res.json(place); + }), + getAll: serverUtils.doOrError(async (req, res) => { + await checkUserPermissions(req); + + const placeType = Qualifier.byContactType(req.query.type); + const limit = req.query.limit ? Number(req.query.limit) : req.query.limit; + + const docs = await getPageByType()( placeType, req.query.cursor, limit ); + + return res.json(docs); }) } }; diff --git a/api/src/routing.js b/api/src/routing.js index e192d40840d..f027f1ddb03 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -467,6 +467,7 @@ app.postJson('/api/v1/places/:id', function(req, res) { .catch(err => serverUtils.error(err, req, res)); }); +app.get('/api/v1/place', place.v1.getAll); app.get('/api/v1/place/:uuid', place.v1.get); app.postJson('/api/v1/people', function(req, res) { diff --git a/api/tests/mocha/controllers/person.spec.js b/api/tests/mocha/controllers/person.spec.js index fd0eef10d35..fe9ab0ee348 100644 --- a/api/tests/mocha/controllers/person.spec.js +++ b/api/tests/mocha/controllers/person.spec.js @@ -229,7 +229,7 @@ describe('Person Controller', () => { expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; }); - it('returns 400 error when argument is invalid', async () => { + it('returns 400 error when personType is invalid', async () => { const err = new InvalidArgumentError(`Invalid contact type: [${invalidPersonType}]`); isOnlineOnly.returns(true); hasAllPermissions.returns(true); diff --git a/api/tests/mocha/controllers/place.spec.js b/api/tests/mocha/controllers/place.spec.js index 8b851c24b65..a4328e110c8 100644 --- a/api/tests/mocha/controllers/place.spec.js +++ b/api/tests/mocha/controllers/place.spec.js @@ -1,6 +1,6 @@ const sinon = require('sinon'); const { expect } = require('chai'); -const { Place, Qualifier } = require('@medic/cht-datasource'); +const { Place, Qualifier, InvalidArgumentError} = require('@medic/cht-datasource'); const auth = require('../../../src/auth'); const controller = require('../../../src/controllers/place'); const dataContext = require('../../../src/services/data-context'); @@ -154,5 +154,112 @@ describe('Place Controller', () => { expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; }); }); + + describe('getAll', () => { + let placeGetPageByType; + let qualifierByContactType; + const placeType = 'place'; + const invalidPlaceType = 'invalidPlace'; + const placeTypeQualifier = { contactType: placeType }; + const place = { name: 'Clinic' }; + const limit = 100; + const cursor = null; + const places = Array.from({ length: 3 }, () => ({ ...place })); + + beforeEach(() => { + req = { + query: { + type: placeType, + cursor, + limit, + } + }; + placeGetPageByType = sinon.stub(); + qualifierByContactType = sinon.stub(Qualifier, 'byContactType'); + dataContextBind.withArgs(Place.v1.getPage).returns(placeGetPageByType); + qualifierByContactType.returns(placeTypeQualifier); + }); + + afterEach(() => { + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a page of places with correct query params', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + placeGetPageByType.resolves(places); + + await controller.v1.getAll(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.type)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Place.v1.getPage)).to.be.true; + expect(placeGetPageByType.calledOnceWithExactly(placeTypeQualifier, cursor, limit)).to.be.true; + expect(res.json.calledOnceWithExactly(places)).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(placeGetPageByType.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(placeGetPageByType.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 placeType is invalid', async () => { + const err = new InvalidArgumentError(`Invalid contact type: [${invalidPlaceType}].`); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + placeGetPageByType.throws(err); + + await controller.v1.getAll(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.type)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Place.v1.getPage)).to.be.true; + expect(placeGetPageByType.calledOnceWithExactly(placeTypeQualifier, cursor, limit)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + }); + + it('rethrows error in case of other errors', async () => { + const err = new Error('error'); + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + placeGetPageByType.throws(err); + + await controller.v1.getAll(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(qualifierByContactType.calledOnceWithExactly(req.query.type)).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Place.v1.getPage)).to.be.true; + expect(placeGetPageByType.calledOnceWithExactly(placeTypeQualifier, cursor, limit)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; + }); + }); }); }); diff --git a/shared-libs/cht-datasource/src/index.ts b/shared-libs/cht-datasource/src/index.ts index b4186f68dd5..394b23e9bae 100644 --- a/shared-libs/cht-datasource/src/index.ts +++ b/shared-libs/cht-datasource/src/index.ts @@ -70,6 +70,34 @@ export const getDatasource = (ctx: DataContext) => { * @throws Error if no UUID is provided */ getByUuidWithLineage: (uuid: string) => ctx.bind(Place.v1.getWithLineage)(Qualifier.byUuid(uuid)), + + /** + * Returns an array of places for the provided page specifications. + * @param placeType the type of place 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 place to return. Default is 100. + * @returns a page of places for the provided specifications + * @throws InvalidArgumentError if no type is provided or if the type is not for a place + * @throws InvalidArgumentError if the provided limit is `<= 0` + * @throws InvalidArgumentError 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: ( + placeType: string, + cursor: Nullable = null, + limit = 100 + ) => ctx.bind(Place.v1.getPage)( + Qualifier.byContactType(placeType), cursor, limit + ), + + /** + * Returns a generator for fetching all places with the given type. + * @param placeType the type of place to return + * @returns a generator for fetching all places with the given type + * @throws InvalidArgumentError if no type if provided or if the type is not for a place + */ + getByType: (placeType: string) => ctx.bind(Place.v1.getAll)(Qualifier.byContactType(placeType)) }, person: { /** @@ -95,9 +123,9 @@ export const getDatasource = (ctx: DataContext) => { * 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` + * @throws InvalidArgumentError if no type is provided or if the type is not for a person + * @throws InvalidArgumentError if the provided limit is `<= 0` + * @throws InvalidArgumentError 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: ( @@ -112,7 +140,7 @@ export const getDatasource = (ctx: DataContext) => { * Returns a generator for fetching all people with the given type. * @param personType the type of people to return * @returns a generator for fetching all people with the given type - * @throws Error if no type is provided or if the type is not for a person + * @throws InvalidArgumentError if no type is provided or if the type is not for a person */ 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 d1c87becbbc..732f6d1bef7 100644 --- a/shared-libs/cht-datasource/src/libs/core.ts +++ b/shared-libs/cht-datasource/src/libs/core.ts @@ -1,4 +1,6 @@ import { DataContext } from './data-context'; +import { ContactTypeQualifier, isContactTypeQualifier } from '../qualifier'; +import { InvalidArgumentError } from './error'; /** * A value that could be `null`. @@ -144,3 +146,26 @@ export const getPagedGenerator = async function* ( return null; }; + +/** @internal */ +export const assertTypeQualifier: (qualifier: unknown) => asserts qualifier is ContactTypeQualifier = ( + qualifier: unknown +) => { + if (!isContactTypeQualifier(qualifier)) { + throw new InvalidArgumentError(`Invalid contact type [${JSON.stringify(qualifier)}].`); + } +}; + +/** @internal */ +export const assertLimit: (limit: unknown) => asserts limit is number = (limit: unknown) => { + if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) { + throw new InvalidArgumentError(`The limit must be a positive number: [${String(limit)}].`); + } +}; + +/** @internal */ +export const assertCursor: (cursor: unknown) => asserts cursor is Nullable = (cursor: unknown) => { + if (cursor !== null && (typeof cursor !== 'string' || !cursor.length)) { + throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}].`); + } +}; diff --git a/shared-libs/cht-datasource/src/local/libs/doc.ts b/shared-libs/cht-datasource/src/local/libs/doc.ts index 69962a0698b..da8daf3d884 100644 --- a/shared-libs/cht-datasource/src/local/libs/doc.ts +++ b/shared-libs/cht-datasource/src/local/libs/doc.ts @@ -1,5 +1,5 @@ import logger from '@medic/logger'; -import { Nullable } from '../../libs/core'; +import { Nullable, Page } from '../../libs/core'; import { Doc, isDoc } from '../../libs/doc'; /** @internal */ @@ -53,3 +53,52 @@ export const queryDocsByKey = ( limit: number, skip: number ): Promise[]> => queryDocs(db, view, { include_docs: true, key, limit, skip }); + +/** + * Resolves a page containing an array of T using the getFunction to retrieve documents from the database + * and the filterFunction to validate the returned documents are all of type T. + * The length of the page's data array is guaranteed to equal limit unless there is no more data to retrieve + * from the database. This function will try to minimize the number of getFunction calls required to find + * the necessary data by over-fetching during followup calls if some retrieved docs are rejected by the filterFunction. + * @internal + */ +export const fetchAndFilter = ( + getFunction: (limit: number, skip: number) => Promise[]>, + filterFunction: (doc: Nullable, uuid?: string) => boolean, + limit: number, +): typeof recursionInner => { + const recursionInner = async ( + currentLimit: number, + currentSkip: number, + currentDocs: T[] = [], + ): Promise> => { + const docs = await getFunction(currentLimit, currentSkip); + const noMoreResults = docs.length < currentLimit; + const newDocs = docs.filter((doc): doc is T => filterFunction(doc)); + const overFetchCount = currentDocs.length + newDocs.length - limit || 0; + const totalDocs = [...currentDocs, ...newDocs].slice(0, limit); + + if (noMoreResults) { + return {data: totalDocs, cursor: null}; + } + + if (totalDocs.length === limit) { + const nextSkip = currentSkip + currentLimit - overFetchCount; + + return {data: totalDocs, cursor: nextSkip.toString()}; + } + + // Re-fetch twice as many docs as we need to limit number of recursions + const missingCount = currentLimit - newDocs.length; + logger.debug(`Found [${missingCount.toString()}] invalid docs. Re-fetching additional records.`); + const nextLimit = missingCount * 2; + const nextSkip = currentSkip + currentLimit; + + return recursionInner( + nextLimit, + nextSkip, + totalDocs, + ); + }; + return recursionInner; +}; diff --git a/shared-libs/cht-datasource/src/local/person.ts b/shared-libs/cht-datasource/src/local/person.ts index 301b3cf44ba..ecf4f86061f 100644 --- a/shared-libs/cht-datasource/src/local/person.ts +++ b/shared-libs/cht-datasource/src/local/person.ts @@ -3,7 +3,7 @@ import contactTypeUtils from '@medic/contact-types-utils'; import { deepCopy, isNonEmptyArray, Nullable, Page } from '../libs/core'; import { ContactTypeQualifier, UuidQualifier } from '../qualifier'; import * as Person from '../person'; -import { getDocById, getDocsByIds, queryDocsByKey } from './libs/doc'; +import {fetchAndFilter, 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'; @@ -11,14 +11,16 @@ import {InvalidArgumentError} from '../libs/error'; /** @internal */ export namespace v1 { - const isPerson = (settings: SettingsService, doc: Nullable, uuid = ''): doc is Person.v1.Person => { + const isPerson = (settings: SettingsService) => (doc: Nullable, uuid?: string): doc is Person.v1.Person => { if (!doc) { - logger.warn(`No person found for identifier [${uuid}].`); + if (uuid) { + logger.warn(`No person found for identifier [${uuid}].`); + } return false; } const hasPersonType = contactTypeUtils.isPerson(settings.getAll(), doc); if (!hasPersonType) { - logger.warn(`Document [${uuid}] is not a valid person.`); + logger.warn(`Document [${doc._id}] is not a valid person.`); return false; } return true; @@ -29,7 +31,7 @@ export namespace v1 { const getMedicDocById = getDocById(medicDb); return async (identifier: UuidQualifier): Promise> => { const doc = await getMedicDocById(identifier.uuid); - if (!isPerson(settings, doc, identifier.uuid)) { + if (!isPerson(settings)(doc, identifier.uuid)) { return null; } return doc; @@ -42,7 +44,7 @@ export namespace v1 { const getMedicDocsById = getDocsByIds(medicDb); return async (identifier: UuidQualifier): Promise> => { const [person, ...lineagePlaces] = await getLineageDocs(identifier.uuid); - if (!isPerson(settings, person, identifier.uuid)) { + if (!isPerson(settings)(person, identifier.uuid)) { return null; } // Intentionally not further validating lineage. For passivity, lineage problems should not block retrieval. @@ -73,50 +75,25 @@ export namespace v1 { const personTypesIds = personTypes.map((item) => item.id); if (!personTypesIds.includes(personType.contactType)) { - throw new InvalidArgumentError(`Invalid contact type [${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)}]`); + 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, - ); - }; + const getDocsByPageWithPersonType = ( + limit: number, + skip: number + ) => getDocsByPage([personType.contactType], limit, skip); - return fetchAndFilter(limit, skip); + return await fetchAndFilter( + getDocsByPageWithPersonType, + isPerson(settings), + limit + )(limit, skip) as Page; }; }; } diff --git a/shared-libs/cht-datasource/src/local/place.ts b/shared-libs/cht-datasource/src/local/place.ts index 9cd38be30e7..0c2663a04c7 100644 --- a/shared-libs/cht-datasource/src/local/place.ts +++ b/shared-libs/cht-datasource/src/local/place.ts @@ -1,24 +1,27 @@ import { Doc } from '../libs/doc'; import contactTypeUtils from '@medic/contact-types-utils'; -import { deepCopy, isNonEmptyArray, NonEmptyArray, Nullable } from '../libs/core'; -import { UuidQualifier } from '../qualifier'; +import { deepCopy, isNonEmptyArray, NonEmptyArray, Nullable, Page } from '../libs/core'; +import { ContactTypeQualifier, UuidQualifier } from '../qualifier'; import * as Place from '../place'; -import { getDocById, getDocsByIds } from './libs/doc'; +import {fetchAndFilter, getDocById, getDocsByIds, queryDocsByKey} from './libs/doc'; import { LocalDataContext, SettingsService } from './libs/data-context'; import { Contact } from '../libs/contact'; import logger from '@medic/logger'; import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage'; +import { InvalidArgumentError } from '../libs/error'; /** @internal */ export namespace v1 { - const isPlace = (settings: SettingsService, uuid: string, doc: Nullable): doc is Place.v1.Place => { + const isPlace = (settings: SettingsService) => (doc: Nullable, uuid?: string): doc is Place.v1.Place => { if (!doc) { - logger.warn(`No place found for identifier [${uuid}].`); + if (uuid) { + logger.warn(`No place found for identifier [${uuid}].`); + } return false; } const hasPlaceType = contactTypeUtils.isPlace(settings.getAll(), doc); if (!hasPlaceType) { - logger.warn(`Document [${uuid}] is not a valid place.`); + logger.warn(`Document [${doc._id}] is not a valid place.`); return false; } return true; @@ -29,7 +32,7 @@ export namespace v1 { const getMedicDocById = getDocById(medicDb); return async (identifier: UuidQualifier): Promise> => { const doc = await getMedicDocById(identifier.uuid); - const validPlace = isPlace(settings, identifier.uuid, doc); + const validPlace = isPlace(settings)(doc, identifier.uuid); return validPlace ? doc : null; }; }; @@ -40,7 +43,7 @@ export namespace v1 { const getMedicDocsById = getDocsByIds(medicDb); return async (identifier: UuidQualifier): Promise> => { const [place, ...lineagePlaces] = await getLineageDocs(identifier.uuid); - if (!isPlace(settings, identifier.uuid, place)) { + if (!isPlace(settings)(place, identifier.uuid)) { return null; } // Intentionally not further validating lineage. For passivity, lineage problems should not block retrieval. @@ -57,4 +60,39 @@ export namespace v1 { return deepCopy(placeWithLineage); }; }; + + /** @internal */ + export const getPage = ({ medicDb, settings }: LocalDataContext) => { + const getDocsByPage = queryDocsByKey(medicDb, 'medic-client/contacts_by_type'); + + return async ( + placeType: ContactTypeQualifier, + cursor: Nullable, + limit: number + ): Promise> => { + const placeTypes = contactTypeUtils.getPlaceTypes(settings.getAll()); + const placeTypeIds = placeTypes.map(p => p.id); + + if (!placeTypeIds.includes(placeType.contactType)) { + throw new InvalidArgumentError(`Invalid contact type [${placeType.contactType}].`); + } + + // Adding a number skip variable here so as not to confuse ourselves + const skip = Number(cursor); + if (isNaN(skip) || skip < 0 || !Number.isInteger(skip)) { + throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}].`); + } + + const getDocsByPageWithPlaceType = ( + limit: number, + skip: number + ) => getDocsByPage([placeType.contactType], limit, skip); + + return await fetchAndFilter( + getDocsByPageWithPlaceType, + isPlace(settings), + limit + )(limit, skip) as Page; + }; + }; } diff --git a/shared-libs/cht-datasource/src/person.ts b/shared-libs/cht-datasource/src/person.ts index 4694f14ebe4..8d20f85c887 100644 --- a/shared-libs/cht-datasource/src/person.ts +++ b/shared-libs/cht-datasource/src/person.ts @@ -1,4 +1,4 @@ -import { ContactTypeQualifier, isContactTypeQualifier, isUuidQualifier, UuidQualifier } from './qualifier'; +import { ContactTypeQualifier, isUuidQualifier, UuidQualifier } from './qualifier'; import { adapt, assertDataContext, DataContext } from './libs/data-context'; import { Contact, NormalizedParent } from './libs/contact'; import * as Remote from './remote'; @@ -7,7 +7,7 @@ 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'; +import { assertCursor, assertLimit, assertTypeQualifier, getPagedGenerator, Nullable, Page } from './libs/core'; /** */ export namespace v1 { @@ -34,26 +34,6 @@ export namespace v1 { } }; - 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)}]`); - } - }; - const getPerson = ( localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise diff --git a/shared-libs/cht-datasource/src/place.ts b/shared-libs/cht-datasource/src/place.ts index 0e301b51b7c..84c78d98de4 100644 --- a/shared-libs/cht-datasource/src/place.ts +++ b/shared-libs/cht-datasource/src/place.ts @@ -1,11 +1,12 @@ import { Contact, NormalizedParent } from './libs/contact'; import * as Person from './person'; -import { LocalDataContext } from './local/libs/data-context'; -import { isUuidQualifier, UuidQualifier } from './qualifier'; +import { LocalDataContext} from './local/libs/data-context'; +import {ContactTypeQualifier, isUuidQualifier, UuidQualifier} from './qualifier'; import { RemoteDataContext } from './remote/libs/data-context'; import { adapt, assertDataContext, DataContext } from './libs/data-context'; import * as Local from './local'; import * as Remote from './remote'; +import {assertCursor, assertLimit, assertTypeQualifier, getPagedGenerator, Nullable, Page} from './libs/core'; /** */ export namespace v1 { @@ -60,4 +61,67 @@ export namespace v1 { * @throws Error if the provided context or qualifier is invalid */ export const getWithLineage = getPlace(Local.Place.v1.getWithLineage, Remote.Place.v1.getWithLineage); + + /** + * Returns a function for retrieving a paged array of places from the given data context. + * @param context the current data context + * @returns a function for retrieving a paged array of places + * @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.Place.v1.getPage, Remote.Place.v1.getPage); + + /** + * Returns an array of places for the provided page specifications. + * @param placeType the type of places 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 places to return. Default is 100. + * @returns a page of places for the provided specification + * @throws Error if no type is provided or if the type is not for a place + * @throws Error if the provided `limit` value is `<=0` + * @throws Error if the provided cursor is not a valid page token or `null` + */ + const curriedFn = async ( + placeType: ContactTypeQualifier, + cursor: Nullable = null, + limit = 100 + ): Promise> => { + assertTypeQualifier(placeType); + assertCursor(cursor); + assertLimit(limit); + + return fn(placeType, cursor, limit); + }; + return curriedFn; + }; + + /** + * Returns a function for getting a generator that fetches places from the given data context. + * @param context the current data context + * @returns a function for getting a generator that fetches places + * @throws Error if a data context is not provided + */ + export const getAll = ( + context: DataContext + ): typeof curriedGen => { + assertDataContext(context); + const getPage = context.bind(v1.getPage); + + /** + * Returns a generator for fetching all places with the given type + * @param placeType the type of places to return + * @returns a generator for fetching all places with the given type + * @throws Error if no type is provided or if the type is not for a place + */ + const curriedGen = (placeType: ContactTypeQualifier) => { + assertTypeQualifier(placeType); + return getPagedGenerator(getPage, placeType); + }; + return curriedGen; + }; } 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 016d9026399..0f6df7f5870 100644 --- a/shared-libs/cht-datasource/src/remote/libs/data-context.ts +++ b/shared-libs/cht-datasource/src/remote/libs/data-context.ts @@ -1,6 +1,7 @@ import logger from '@medic/logger'; import { DataContext } from '../../libs/data-context'; import { AbstractDataContext, isString, Nullable } from '../../libs/core'; +import { InvalidArgumentError } from '../../libs/error'; /** @internal */ export class RemoteDataContext extends AbstractDataContext { @@ -48,6 +49,8 @@ export const getResource = (context: RemoteDataContext, path: string) => async < if (!response.ok) { if (response.status === 404) { return null; + } else if (response.status === 400) { + throw new InvalidArgumentError(response.statusText); } throw new Error(response.statusText); } @@ -66,7 +69,9 @@ export const getResources = (context: RemoteDataContext, path: string) => async const params = new URLSearchParams(queryParams).toString(); try { const response = await fetch(`${context.url}/${path}?${params}`); - if (!response.ok) { + if (response.status === 400) { + throw new InvalidArgumentError(response.statusText); + } else if (!response.ok) { throw new Error(response.statusText); } diff --git a/shared-libs/cht-datasource/src/remote/place.ts b/shared-libs/cht-datasource/src/remote/place.ts index 6aef2228c62..351ab5dfab0 100644 --- a/shared-libs/cht-datasource/src/remote/place.ts +++ b/shared-libs/cht-datasource/src/remote/place.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 Place from '../place'; -import { getResource, RemoteDataContext } from './libs/data-context'; +import { getResource, getResources, RemoteDataContext } from './libs/data-context'; /** @internal */ export namespace v1 { const getPlace = (remoteContext: RemoteDataContext) => getResource(remoteContext, 'api/v1/place'); + const getPlaces = (remoteContext: RemoteDataContext) => getResources(remoteContext, 'api/v1/place'); + /** @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) => ( + placeType: ContactTypeQualifier, + cursor: Nullable, + limit: number, + ): Promise> => { + const queryParams = { + 'limit': limit.toString(), + 'type': placeType.contactType, + ...(cursor ? { cursor } : {}) + }; + return getPlaces(remoteContext)(queryParams); + }; } diff --git a/shared-libs/cht-datasource/test/index.spec.ts b/shared-libs/cht-datasource/test/index.spec.ts index 882b6c3c338..19b47551dc2 100644 --- a/shared-libs/cht-datasource/test/index.spec.ts +++ b/shared-libs/cht-datasource/test/index.spec.ts @@ -53,7 +53,7 @@ describe('CHT Script API - getDatasource', () => { beforeEach(() => place = v1.place); it('contains expected keys', () => { - expect(place).to.have.all.keys(['getByUuid', 'getByUuidWithLineage']); + expect(place).to.have.all.keys(['getByType', 'getByUuid', 'getByUuidWithLineage', 'getPageByType']); }); it('getByUuid', async () => { @@ -85,6 +85,44 @@ describe('CHT Script API - getDatasource', () => { expect(placeGet.calledOnceWithExactly(qualifier)).to.be.true; expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; }); + + it('getPageByType', async () => { + const expectedPlaces: Page = {data: [], cursor: null}; + const placeGetPage = sinon.stub().resolves(expectedPlaces); + dataContextBind.returns(placeGetPage); + const placeType = 'place'; + const limit = 2; + const cursor = '1'; + const placeTypeQualifier = { contactType: placeType }; + const byContactType = sinon.stub(Qualifier, 'byContactType').returns(placeTypeQualifier); + + const returnedPlaces = await place.getPageByType(placeType, cursor, limit); + + expect(returnedPlaces).to.equal(expectedPlaces); + expect(dataContextBind.calledOnceWithExactly(Place.v1.getPage)).to.be.true; + expect(placeGetPage.calledOnceWithExactly(placeTypeQualifier, cursor, limit)).to.be.true; + expect(byContactType.calledOnceWithExactly(placeType)).to.be.true; + }); + + it('getByType', () => { + const mockAsyncGenerator = async function* () { + await Promise.resolve(); + yield []; + }; + + const placeGetAll = sinon.stub().returns(mockAsyncGenerator); + dataContextBind.returns(placeGetAll); + const placeType = 'place'; + const placeTypeQualifier = { contactType: placeType }; + const byContactType = sinon.stub(Qualifier, 'byContactType').returns(placeTypeQualifier); + + const res = place.getByType(placeType); + + expect(res).to.deep.equal(mockAsyncGenerator); + expect(dataContextBind.calledOnceWithExactly(Place.v1.getAll)).to.be.true; + expect(placeGetAll.calledOnceWithExactly(placeTypeQualifier)).to.be.true; + expect(byContactType.calledOnceWithExactly(placeType)).to.be.true; + }); }); describe('person', () => { 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 33c8aa7bdb0..c3f8d12f9cf 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, queryDocsByRange } from '../../../src/local/libs/doc'; +import {fetchAndFilter, getDocById, getDocsByIds, queryDocsByKey, queryDocsByRange} from '../../../src/local/libs/doc'; import { expect } from 'chai'; describe('local doc lib', () => { @@ -283,4 +283,93 @@ describe('local doc lib', () => { expect(isDoc.args).to.deep.equal([[doc0]]); }); }); + + describe('fetchAndFilter', () => { + let getFunction: sinon.SinonStub; + let filterFunction: sinon.SinonStub; + + beforeEach(() => { + getFunction = sinon.stub(); + filterFunction = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should return correct data when all docs are valid', async () => { + const docs = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]; + getFunction.resolves(docs); + filterFunction.returns(true); + + const fetchAndFilterFunc = fetchAndFilter(getFunction, filterFunction, 3); + const result = await fetchAndFilterFunc(3, 0); + + expect(result.data).to.deep.equal(docs); + expect(result.cursor).to.equal('3'); + expect(getFunction.calledOnceWith(3, 0)).to.be.true; + expect(filterFunction.callCount).to.equal(3); + }); + + it('should filter out invalid docs and fetch more if needed', async () => { + const docs1 = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]; + const docs2 = [{ _id: '4' }, { _id: '5' }]; + getFunction.onFirstCall().resolves(docs1); + getFunction.onSecondCall().resolves(docs2); + filterFunction.callsFake((doc: Doc.Doc) => doc._id !== '2'); + + const fetchAndFilterFunc = fetchAndFilter(getFunction, filterFunction, 3); + const result = await fetchAndFilterFunc(3, 0); + + expect(result.data).to.deep.equal([{ _id: '1' }, { _id: '3' }, { _id: '4' }]); + expect(result.cursor).to.equal('4'); + expect(getFunction.firstCall.calledWith(3, 0)).to.be.true; + expect(getFunction.secondCall.calledWith(2, 3)).to.be.true; + expect(filterFunction.callCount).to.equal(5); + }); + + it('should return null cursor when no more results', async () => { + const docs = [{ _id: '1' }, { _id: '2' }]; + getFunction.resolves(docs); + filterFunction.returns(true); + + const fetchAndFilterFunc = fetchAndFilter(getFunction, filterFunction, 3); + const result = await fetchAndFilterFunc(3, 0); + + expect(result.data).to.deep.equal(docs); + expect(result.cursor).to.be.null; + expect(getFunction.calledOnceWith(3, 0)).to.be.true; + expect(filterFunction.callCount).to.equal(2); + }); + + it('should handle empty result set', async () => { + getFunction.resolves([]); + filterFunction.returns(true); + + const fetchAndFilterFunc = fetchAndFilter(getFunction, filterFunction, 3); + const result = await fetchAndFilterFunc(3, 0); + + expect(result.data).to.deep.equal([]); + expect(result.cursor).to.be.null; + expect(getFunction.calledOnceWith(3, 0)).to.be.true; + expect(filterFunction.callCount).to.equal(0); + }); + + it('should handle all docs being filtered out', async () => { + const docs1 = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]; + const docs2 = [{ _id: '4' }, { _id: '5' }, { _id: '6' }]; + getFunction.onFirstCall().resolves(docs1); + getFunction.onSecondCall().resolves(docs2); + filterFunction.returns(false); + + const fetchAndFilterFunc = fetchAndFilter(getFunction, filterFunction, 3); + const result = await fetchAndFilterFunc(3, 0); + + expect(result.data).to.deep.equal([]); + expect(result.cursor).to.be.null; + expect(getFunction.firstCall.calledWith(3, 0)).to.be.true; + expect(getFunction.secondCall.calledWith(6, 3)).to.be.true; + expect(filterFunction.callCount).to.equal(6); + }); + }); }); diff --git a/shared-libs/cht-datasource/test/local/person.spec.ts b/shared-libs/cht-datasource/test/local/person.spec.ts index af1d5265da7..580d08b45ef 100644 --- a/shared-libs/cht-datasource/test/local/person.spec.ts +++ b/shared-libs/cht-datasource/test/local/person.spec.ts @@ -58,7 +58,7 @@ describe('local person', () => { }); it('returns null if the identified doc does not have a person type', async () => { - const doc = { type: 'not-person' }; + const doc = { type: 'not-person', _id: '_id' }; getDocByIdInner.resolves(doc); settingsGetAll.returns(settings); isPerson.returns(false); @@ -69,7 +69,7 @@ describe('local person', () => { expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; expect(isPerson.calledOnceWithExactly(settings, doc)).to.be.true; - expect(warn.calledOnceWithExactly(`Document [${identifier.uuid}] is not a valid person.`)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${doc._id}] is not a valid person.`)).to.be.true; }); it('returns null if the identified doc is not found', async () => { @@ -236,62 +236,72 @@ describe('local person', () => { let getPersonTypes: SinonStub; let queryDocsByKeyInner: SinonStub; let queryDocsByKeyOuter: SinonStub; + let fetchAndFilterInner: SinonStub; + let fetchAndFilterOuter: 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); + fetchAndFilterInner = sinon.stub(); + fetchAndFilterOuter = sinon.stub(LocalDoc, 'fetchAndFilter').returns(fetchAndFilterInner); }); 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 }; + fetchAndFilterInner.resolves(expectedResult); const res = await Person.v1.getPage(localContext)(personTypeQualifier, cursor, limit); expect(res).to.deep.equal(expectedResult); - expect(settingsGetAll.callCount).to.equal(4); + expect(settingsGetAll.callCount).to.equal(1); 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])); + expect(queryDocsByKeyInner.notCalled).to.be.true; + expect(fetchAndFilterOuter.calledOnce).to.be.true; + expect(fetchAndFilterOuter.firstCall.args[0]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[1]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[2]).to.be.equal(limit); + expect(fetchAndFilterInner.calledOnceWithExactly(limit, Number(cursor))).to.be.true; + expect(isPerson.notCalled).to.be.true; }); 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 }; + fetchAndFilterInner.resolves(expectedResult); const res = await Person.v1.getPage(localContext)(personTypeQualifier, notNullCursor, limit); expect(res).to.deep.equal(expectedResult); - expect(settingsGetAll.callCount).to.equal(4); + expect(settingsGetAll.callCount).to.equal(1); 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])); + expect(queryDocsByKeyInner.notCalled).to.be.true; + expect(fetchAndFilterOuter.firstCall.args[0]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[1]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[2]).to.be.equal(limit); + expect(fetchAndFilterInner.calledOnceWithExactly(limit, Number(notNullCursor))).to.be.true; + expect(isPerson.notCalled).to.be.true; }); 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}]` + `Invalid contact type [${invalidPersonTypeQualifier.contactType}].` ); expect(settingsGetAll.calledOnce).to.be.true; @@ -299,35 +309,36 @@ describe('local person', () => { 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; + expect(fetchAndFilterInner.notCalled).to.be.true; + expect(fetchAndFilterOuter.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}]`); + await expect(Person.v1.getPage(localContext)(personTypeQualifier, invalidSkip as string, limit)) + .to.be.rejectedWith(`Invalid cursor token: [${String(invalidSkip)}]`); 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(fetchAndFilterInner.notCalled).to.be.true; + expect(fetchAndFilterOuter.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 }; + fetchAndFilterInner.resolves(expectedResult); const res = await Person.v1.getPage(localContext)(personTypeQualifier, cursor, limit); @@ -337,36 +348,13 @@ describe('local person', () => { expect( queryDocsByKeyOuter.calledOnceWithExactly(localContext.medicDb, 'medic-client/contacts_by_type') ).to.be.true; - expect(queryDocsByKeyInner.calledOnceWithExactly([personIdentifier], limit, Number(cursor))).to.be.true; + expect(queryDocsByKeyInner.notCalled).to.be.true; + expect(fetchAndFilterOuter.firstCall.args[0]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[1]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[2]).to.be.equal(limit); + expect(fetchAndFilterInner.calledOnceWithExactly(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/local/place.spec.ts b/shared-libs/cht-datasource/test/local/place.spec.ts index da8c9c50c61..914fdf64840 100644 --- a/shared-libs/cht-datasource/test/local/place.spec.ts +++ b/shared-libs/cht-datasource/test/local/place.spec.ts @@ -58,7 +58,7 @@ describe('local place', () => { }); it('returns null if the identified doc does not have a place type', async () => { - const doc = { type: 'not-place' }; + const doc = { type: 'not-place', '_id': 'id' }; getDocByIdInner.resolves(doc); settingsGetAll.returns(settings); isPlace.returns(false); @@ -69,7 +69,7 @@ describe('local place', () => { expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; expect(isPlace.calledOnceWithExactly(settings, doc)).to.be.true; - expect(warn.calledOnceWithExactly(`Document [${identifier.uuid}] is not a valid place.`)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${doc._id}] is not a valid place.`)).to.be.true; }); it('returns null if the identified doc is not found', async () => { @@ -222,5 +222,138 @@ describe('local place', () => { expect(deepCopy.notCalled).to.be.true; }); }); + + describe('getPage', () => { + const limit = 3; + const cursor = null; + const notNullCursor = '5'; + const placeIdentifier = 'place'; + const placeTypeQualifier = {contactType: placeIdentifier} as const; + const invalidPlaceTypeQualifier = { contactType: 'invalid' } as const; + const placeType = [{person: true, id: placeIdentifier}] as Record[]; + let getPlaceTypes: SinonStub; + let queryDocsByKeyInner: SinonStub; + let queryDocsByKeyOuter: SinonStub; + let fetchAndFilterInner: SinonStub; + let fetchAndFilterOuter: SinonStub; + + beforeEach(() => { + queryDocsByKeyInner = sinon.stub(); + queryDocsByKeyOuter = sinon.stub(LocalDoc, 'queryDocsByKey').returns(queryDocsByKeyInner); + getPlaceTypes = sinon.stub(contactTypeUtils, 'getPlaceTypes').returns(placeType); + settingsGetAll.returns(settings); + fetchAndFilterInner = sinon.stub(); + fetchAndFilterOuter = sinon.stub(LocalDoc, 'fetchAndFilter').returns(fetchAndFilterInner); + }); + + it('returns a page of places', async () => { + const doc = { type: 'place' }; + const docs = [doc, doc, doc]; + const expectedResult = { + cursor: '3', + data: docs + }; + fetchAndFilterInner.resolves(expectedResult); + + const res = await Place.v1.getPage(localContext)(placeTypeQualifier, cursor, limit); + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(1); + expect(getPlaceTypes.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(fetchAndFilterOuter.calledOnce).to.be.true; + expect(fetchAndFilterOuter.firstCall.args[0]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[1]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[2]).to.be.equal(limit); + expect(fetchAndFilterInner.calledOnceWithExactly(limit, Number(cursor))).to.be.true; + expect(isPlace.notCalled).to.be.true; + }); + + it('returns a page of places when cursor is not null', async () => { + const doc = { type: 'place' }; + const docs = [doc, doc, doc]; + const expectedResult = { + cursor: '8', + data: docs + }; + fetchAndFilterInner.resolves(expectedResult); + + const res = await Place.v1.getPage(localContext)(placeTypeQualifier, notNullCursor, limit); + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.callCount).to.equal(1); + expect(getPlaceTypes.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(fetchAndFilterOuter.firstCall.args[0]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[1]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[2]).to.be.equal(limit); + expect(fetchAndFilterInner.calledOnceWithExactly(limit, Number(notNullCursor))).to.be.true; + expect(isPlace.notCalled).to.be.true; + }); + + it('throws an error if place type is invalid/does not exist', async () => { + await expect(Place.v1.getPage(localContext)(invalidPlaceTypeQualifier, cursor, limit)).to.be.rejectedWith( + `Invalid contact type [${invalidPlaceTypeQualifier.contactType}].` + ); + + expect(settingsGetAll.calledOnce).to.be.true; + expect(getPlaceTypes.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(fetchAndFilterInner.notCalled).to.be.true; + expect(fetchAndFilterOuter.notCalled).to.be.true; + expect(isPlace.notCalled).to.be.true; + }); + + [ + {}, + '-1', + undefined, + ].forEach((invalidSkip ) => { + it(`throws an error if cursor is invalid: ${String(invalidSkip)}`, async () => { + await expect(Place.v1.getPage(localContext)(placeTypeQualifier, invalidSkip as string, limit)) + .to.be.rejectedWith(`Invalid cursor token: [${String(invalidSkip)}]`); + + expect(settingsGetAll.calledOnce).to.be.true; + expect(getPlaceTypes.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(fetchAndFilterInner.notCalled).to.be.true; + expect(fetchAndFilterOuter.notCalled).to.be.true; + expect(isPlace.notCalled).to.be.true; + }); + }); + + it('returns empty array if places does not exist', async () => { + const expectedResult = { + data: [], + cursor + }; + fetchAndFilterInner.resolves(expectedResult); + + const res = await Place.v1.getPage(localContext)(placeTypeQualifier, cursor, limit); + + expect(res).to.deep.equal(expectedResult); + expect(settingsGetAll.calledOnce).to.be.true; + expect(getPlaceTypes.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(fetchAndFilterOuter.firstCall.args[0]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[1]).to.be.a('function'); + expect(fetchAndFilterOuter.firstCall.args[2]).to.be.equal(limit); + expect(fetchAndFilterInner.calledOnceWithExactly(limit, Number(cursor))).to.be.true; + expect(isPlace.notCalled).to.be.true; + }); + }); }); }); diff --git a/shared-libs/cht-datasource/test/place.spec.ts b/shared-libs/cht-datasource/test/place.spec.ts index 0a6c8dee942..bd3d4963fb9 100644 --- a/shared-libs/cht-datasource/test/place.spec.ts +++ b/shared-libs/cht-datasource/test/place.spec.ts @@ -6,17 +6,20 @@ import * as Context from '../src/libs/data-context'; import sinon, { SinonStub } from 'sinon'; import { expect } from 'chai'; import { DataContext } from '../src'; +import * as Core from '../src/libs/core'; describe('place', () => { const dataContext = { } as DataContext; 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('place', () => { expect(getPlaceWithLineage.notCalled).to.be.true; }); }); + + describe('getPage', () => { + const places = [{ _id: 'place1' }, { _id: 'place2' }, { _id: 'place3' }] as Place.v1.Place[]; + const cursor = '1'; + const pageData = { data: places, cursor }; + const limit = 3; + const placeTypeQualifier = {contactType: 'place'} as const; + const invalidQualifier = { contactType: 'invalid' } as const; + let getPage: SinonStub; + + beforeEach(() => { + getPage = sinon.stub(); + adapt.returns(getPage); + }); + + it('retrieves places from the data context when cursor is null', async () => { + isContactTypeQualifier.returns(true); + getPage.resolves(pageData); + + const result = await Place.v1.getPage(dataContext)(placeTypeQualifier, null, limit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Place.v1.getPage, Remote.Place.v1.getPage)).to.be.true; + expect(getPage.calledOnceWithExactly(placeTypeQualifier, null, limit)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((placeTypeQualifier))).to.be.true; + }); + + it('retrieves places from the data context when cursor is not null', async () => { + isContactTypeQualifier.returns(true); + getPage.resolves(pageData); + + const result = await Place.v1.getPage(dataContext)(placeTypeQualifier, cursor, limit); + + expect(result).to.equal(pageData); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Place.v1.getPage, Remote.Place.v1.getPage)).to.be.true; + expect(getPage.calledOnceWithExactly(placeTypeQualifier, cursor, limit)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((placeTypeQualifier))).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + isContactTypeQualifier.returns(true); + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Place.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(Place.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.Place.v1.getPage, Remote.Place.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(places); + + await expect(Place.v1.getPage(dataContext)(placeTypeQualifier, 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.Place.v1.getPage, Remote.Place.v1.getPage)) + .to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((placeTypeQualifier))).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(places); + + await expect(Place.v1.getPage(dataContext)(placeTypeQualifier, skipValue as string, limit)) + .to.be.rejectedWith(`Invalid cursor token: [${String(skipValue)}]`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Place.v1.getPage, Remote.Place.v1.getPage)) + .to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly((placeTypeQualifier))).to.be.true; + expect(getPage.notCalled).to.be.true; + }); + }); + }); + + describe('getAll', () => { + const placeType = 'place'; + const placeTypeQualifier = {contactType: placeType} as const; + const firstPlace = { _id: 'place1' } as Place.v1.Place; + const secondPlace = { _id: 'place2' } as Place.v1.Place; + const thirdPlace = { _id: 'place3' } as Place.v1.Place; + const places = [firstPlace, secondPlace, thirdPlace]; + const mockGenerator = function* () { + for (const place of places) { + yield place; + } + }; + + let placeGetPage: sinon.SinonStub; + let getPagedGenerator: sinon.SinonStub; + + beforeEach(() => { + placeGetPage = sinon.stub(Place.v1, 'getPage'); + dataContext.bind = sinon.stub().returns(placeGetPage); + getPagedGenerator = sinon.stub(Core, 'getPagedGenerator'); + }); + + it('should get place generator with correct parameters', () => { + isContactTypeQualifier.returns(true); + getPagedGenerator.returns(mockGenerator); + + const generator = Place.v1.getAll(dataContext)(placeTypeQualifier); + + expect(generator).to.deep.equal(mockGenerator); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(getPagedGenerator.calledOnceWithExactly(placeGetPage, placeTypeQualifier)).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(placeTypeQualifier)).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(() => Place.v1.getAll(dataContext)).to.throw(errMsg); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(placeGetPage.notCalled).to.be.true; + expect(isContactTypeQualifier.notCalled).to.be.true; + }); + + it('should throw an error for invalid placeType', () => { + isContactTypeQualifier.returns(false); + + expect(() => Place.v1.getAll(dataContext)(placeTypeQualifier)) + .to.throw(`Invalid contact type [${JSON.stringify(placeTypeQualifier)}].`); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(placeGetPage.notCalled).to.be.true; + expect(isContactTypeQualifier.calledOnceWithExactly(placeTypeQualifier)).to.be.true; + }); + }); }); }); diff --git a/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts b/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts index bc302dac2fa..7c9b8eabbbc 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 @@ -9,7 +9,7 @@ import { isRemoteDataContext, RemoteDataContext } from '../../../src/remote/libs/data-context'; -import { DataContext } from '../../../src'; +import { DataContext, InvalidArgumentError } from '../../../src'; describe('remote context lib', () => { const context = { url: 'hello.world' } as RemoteDataContext; @@ -117,6 +117,25 @@ describe('remote context lib', () => { expect(loggerError.notCalled).to.be.true; }); + it('throws InvalidArgumentError if the Bad Request - 400 status is returned', async () => { + const path = 'path'; + const errorMsg = 'Bad Request'; + const resourceId = 'resource'; + fetchResponse.ok = false; + fetchResponse.status = 400; + fetchResponse.statusText = errorMsg; + const expectedError = new InvalidArgumentError(errorMsg); + + await expect(getResource(context, path)(resourceId)).to.be.rejectedWith(errorMsg); + + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}/${resourceId}?`)).to.be.true; + expect(fetchResponse.json.notCalled).to.be.true; + expect(loggerError.args[0]).to.deep.equal([ + `Failed to fetch ${resourceId} from ${context.url}/${path}`, + expectedError + ]); + }); + it('throws an error if the resource fetch rejects', async () => { const path = 'path'; const resourceId = 'resource'; @@ -182,6 +201,24 @@ describe('remote context lib', () => { expect(fetchResponse.json.notCalled).to.be.true; }); + it('throws InvalidArgumentError if the Bad Request - 400 status is returned', async () => { + const path = 'path'; + const errorMsg = 'Bad Request'; + fetchResponse.ok = false; + fetchResponse.status = 400; + fetchResponse.statusText = errorMsg; + const expectedError = new InvalidArgumentError(errorMsg); + + await expect(getResources(context, path)(params)).to.be.rejectedWith(errorMsg); + + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}?${stringifiedParams}`)).to.be.true; + expect(fetchResponse.json.notCalled).to.be.true; + expect(loggerError.args[0]).to.deep.equal([ + `Failed to fetch resources from ${context.url}/${path} with params: ${stringifiedParams}`, + expectedError + ]); + }); + it('throws an error if the resource fetch resolves an error status', async () => { const path = 'path'; fetchResponse.ok = false; diff --git a/shared-libs/cht-datasource/test/remote/place.spec.ts b/shared-libs/cht-datasource/test/remote/place.spec.ts index 12b853b7727..4eb44bd20f5 100644 --- a/shared-libs/cht-datasource/test/remote/place.spec.ts +++ b/shared-libs/cht-datasource/test/remote/place.spec.ts @@ -8,10 +8,14 @@ describe('remote place', () => { 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 place', () => { expect(getResourceInner.calledOnceWithExactly(identifier.uuid, { with_lineage: 'true' })).to.be.true; }); }); + + describe('getPage', () => { + const limit = 3; + const cursor = '1'; + const placeType = 'place'; + const personTypeQualifier = { contactType: placeType }; + const queryParam = { + limit: limit.toString(), + type: placeType, + cursor, + }; + + it('returns places', async () => { + const doc = [{ type: 'place' }, {type: 'place'}]; + const expectedResponse = { data: doc, cursor }; + getResourcesInner.resolves(expectedResponse); + + const result = await Place.v1.getPage(remoteContext)(personTypeQualifier, cursor, limit); + + expect(result).to.equal(expectedResponse); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/place')).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 Place.v1.getPage(remoteContext)(personTypeQualifier, cursor, limit); + + expect(result).to.deep.equal([]); + expect(getResourcesOuter.calledOnceWithExactly(remoteContext, 'api/v1/place')).to.be.true; + expect(getResourcesInner.calledOnceWithExactly(queryParam)).to.be.true; + }); + }); }); }); diff --git a/tests/integration/api/controllers/person.spec.js b/tests/integration/api/controllers/person.spec.js index ec61d3faaf5..d35b93d179d 100644 --- a/tests/integration/api/controllers/person.spec.js +++ b/tests/integration/api/controllers/person.spec.js @@ -196,7 +196,7 @@ describe('Person API', () => { }; await expect(utils.request(opts)) - .to.be.rejectedWith(`400 - {"code":400,"error":"Invalid contact type [${invalidContactType}]"}`); + .to.be.rejectedWith(`400 - {"code":400,"error":"Invalid contact type [${invalidContactType}]."}`); }); it('throws 400 error when limit is invalid', async () => { @@ -210,7 +210,7 @@ describe('Person API', () => { }; await expect(utils.request(opts)) - .to.be.rejectedWith(`400 - {"code":400,"error":"The limit must be a positive number: [${-1}]"}`); + .to.be.rejectedWith(`400 - {"code":400,"error":"The limit must be a positive number: [${-1}]."}`); }); it('throws 400 error when cursor is invalid', async () => { @@ -225,14 +225,12 @@ describe('Person API', () => { await expect(utils.request(opts)) .to.be.rejectedWith( - `400 - {"code":400,"error":"Invalid cursor token: [${-1}]"}` + `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 = []; diff --git a/tests/integration/api/controllers/place.spec.js b/tests/integration/api/controllers/place.spec.js index 019b02f4cb7..d80ed338461 100644 --- a/tests/integration/api/controllers/place.spec.js +++ b/tests/integration/api/controllers/place.spec.js @@ -22,6 +22,27 @@ describe('Place API', () => { } }, }); + const placeType = 'clinic'; + const clinic1 = utils.deepFreeze(placeFactory.place().build({ + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + type: placeType, + contact: {} + })); + const clinic2 = utils.deepFreeze(placeFactory.place().build({ + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + type: placeType, + contact: {} + })); const userNoPerms = utils.deepFreeze(userFactory.build({ username: 'online-no-perms', @@ -42,9 +63,10 @@ describe('Place API', () => { roles: ['chw'] })); const dataContext = getRemoteDataContext(utils.getOrigin()); + const expectedPlaces = [place0, clinic1, clinic2]; before(async () => { - await utils.saveDocs([contact0, contact1, contact2, place0, place1, place2]); + await utils.saveDocs([contact0, contact1, contact2, place0, place1, place2, clinic1, clinic2]); await utils.createUsers([userNoPerms, offlineUser]); }); @@ -96,4 +118,107 @@ describe('Place API', () => { }); }); }); + + describe('GET /api/v1/place', async () => { + const getPage = Place.v1.getPage(dataContext); + const limit = 2; + const cursor = null; + const invalidContactType = 'invalidPlace'; + + it('returns a page of places for no limit and cursor passed', async () => { + const responsePage = await getPage(Qualifier.byContactType(placeType)); + const responsePlaces = responsePage.data; + const responseCursor = responsePage.cursor; + + expect(responsePlaces).excludingEvery(['_rev', 'reported_date']) + .to.deep.equalInAnyOrder(expectedPlaces); + expect(responseCursor).to.be.equal(null); + }); + + it('returns a page of places when limit and cursor is passed and cursor can be reused', async () => { + const firstPage = await getPage(Qualifier.byContactType(placeType), cursor, limit); + const secondPage = await getPage(Qualifier.byContactType(placeType), firstPage.cursor, limit); + + const allPeople = [...firstPage.data, ...secondPage.data]; + + expect(allPeople).excludingEvery(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPlaces); + expect(firstPage.data.length).to.be.equal(2); + expect(secondPage.data.length).to.be.equal(1); + expect(firstPage.cursor).to.be.equal('2'); + expect(secondPage.cursor).to.be.equal(null); + }); + + it(`throws error when user does not have can_view_contacts permission`, async () => { + const opts = { + path: `/api/v1/place`, + 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/place`, + 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 placeType is invalid', async () => { + const queryParams = { + 'type': invalidContactType + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `/api/v1/place?${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 = { + type: placeType, + limit: -1 + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `/api/v1/place?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith(`400 - {"code":400,"error":"The limit must be a positive number: [${-1}]."}`); + }); + + it('throws 400 error when cursor is invalid', async () => { + const queryParams = { + type: placeType, + cursor: '-1' + }; + const queryString = new URLSearchParams(queryParams).toString(); + const opts = { + path: `/api/v1/place?${queryString}`, + }; + + await expect(utils.request(opts)) + .to.be.rejectedWith( + `400 - {"code":400,"error":"Invalid cursor token: [${-1}]."}` + ); + }); + }); + + describe('Place.v1.getAll', async () => { + it('fetches all data by iterating through generator', async () => { + const docs = []; + + const generator = Place.v1.getAll(dataContext)(Qualifier.byContactType(placeType)); + + for await (const doc of generator) { + docs.push(doc); + } + + expect(docs).excluding(['_rev', 'reported_date']).to.deep.equalInAnyOrder(expectedPlaces); + }); + }); });