diff --git a/.eslintrc b/.eslintrc index 3227d7b1c99..ede9d7641aa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -15,6 +15,7 @@ "api/src/public/login/lib-bowser.js", "build/**", "jsdocs/**", + "shared-libs/cht-datasource/dist/**", "tests/scalability/report*/**", "tests/scalability/jmeter/**", "webapp/src/ts/providers/xpath-element-path.provider.ts" diff --git a/.github/ISSUE_TEMPLATE/z_release_major.md b/.github/ISSUE_TEMPLATE/z_release_major.md index eb10aaa4470..dc586ebd574 100644 --- a/.github/ISSUE_TEMPLATE/z_release_major.md +++ b/.github/ISSUE_TEMPLATE/z_release_major.md @@ -18,25 +18,25 @@ assignees: '' When development is ready to begin one of the engineers should be nominated as a Release Engineer. They will be responsible for making sure the following tasks are completed though not necessarily completing them. -- [ ] Set the version number in the `package.json` and `package-lock.json`, then submit a PR to `master` branch. The easiest way to do this is to use `npm --no-git-tag-version version `. -- [ ] Ensure that issues from merged commits are closed and mapped to a milestone. +- [ ] Checkout to a new `-update-version` branch (eg: `1234-update-version`) and set the version number in the `package.json` and `package-lock.json`. The easiest way to do this is to use `npm --no-git-tag-version version `. Once the version is updated, submit a PR to `master` branch. +- [ ] Ensure that issues associated with commits merged to `master` since the last release are closed and mapped to the milestone. # Releasing - Release Engineer -Once all issues have been merged into `master`, and the `master` branch has the new version number, then the release process can start: +Once the PR has been merged into `master`, and the `master` branch has the new version number, then the release process can start: - [ ] Create a new release branch from `master` named `..x` in `cht-core`. Post a message to #development Slack channel using this template: ``` @core_devs I've just created the `..x` release branch. Please be aware that any further changes intended for this release will have to be merged to `master` then backported. Thanks! ``` -- [ ] Build a beta named `..-beta.1` by pushing a lightweight git tag (e.g. `git tag ..-beta.1`). +- [ ] Build a beta named `..-beta.1` by creating a lightweight git tag (e.g. `git tag ..-beta.1`) and then push the created tag. - [ ] Once the CI completes successfully notify the team by writing a message in the #product-team Slack channel: ``` @product_team, I’ve just created the `..-beta.1` tag. Please let me know if there’s any final update we need to make. If all is good, then in 24h, I will start the release. Thanks! ``` -- [ ] The beta tag will automatically trigger the scalability build. Once it passes, download the scalability results on S3 at medic-e2e/scalability/$TAG_NAME. Add the release `.json` file to `cht-core/tests/scalability/previous_results`. More info in the [scalability documentation](https://github.com/medic/cht-core/blob/master/tests/scalability/README.md). +- [ ] The beta tag will automatically trigger the scalability build. Once it passes, download the scalability results on S3 at medic-e2e/scalability/$TAG_NAME. If you do not have access to the scalability results ask someone in the #product-team who has access. Add the release `.json` file to `cht-core/tests/scalability/previous_results`. More info in the [scalability documentation](https://github.com/medic/cht-core/blob/master/tests/scalability/README.md). - [ ] Add release notes to the [Core Framework Releases](https://docs.communityhealthtoolkit.org/core/releases/) page: - [ ] Create a new document for the release in the [releases folder](https://github.com/medic/cht-docs/tree/main/content/en/core/releases). - [ ] Ensure all issues are in the GH Milestone, they have human readable descriptions, and that they're correctly labelled. In particular: they have one "Type" label, "UI/UX" if they change the UI, and "Breaking change" if appropriate. @@ -44,9 +44,9 @@ If all is good, then in 24h, I will start the release. Thanks! - [ ] Collect known migration steps, descriptions, screenshots, videos, data, and anything else to help communicate particularly important changes. This information should already be on the issue, but if not, prompt the change author to provide it. - [ ] Document any required or recommended upgrades to our other products (eg: cht-conf, cht-gateway, cht-android). - [ ] Add the release to the [Supported versions](https://docs.communityhealthtoolkit.org/core/releases/#supported-versions) and update the EOL date of the previous release. Update the status of any releases that are past their End Of Life date. Also add a link in the `Release Notes` section to the new release page. -- [ ] Create a release in GitHub from the release branch so it shows up under the [Releases tab](https://github.com/medic/cht-core/releases) with the naming convention `..`, and change the Target dropdown to the release branch (eg: `4.4.x`). This will create the git tag automatically. Ensure the release notes PR above is merged. Link to the release notes in the description of the release. -- [ ] Confirm the release build completes successfully and the new release is available on the [market](https://staging.dev.medicmobile.org/_couch/builds_4/_design/builds/_view/releases). Make sure that the document has new entry with `id: medic:medic:..` -- [ ] Upgrade the [demo](https://demo-cht.dev.medicmobile.org/) instance to this version. +- [ ] Create a release in GitHub from the release branch so it shows up under the [Releases tab](https://github.com/medic/cht-core/releases) with the naming convention `..`. In the releases tab, you select the "Choose a tag", type tag in the search box, then create a tag for the release `..`. Next, change the Target dropdown to the release branch (eg: `4.4.x`). Ensure the release notes PR above is merged. Add a link to the release notes in the description of the release. +- [ ] Once you publish the release, confirm the release build completes successfully and the new release is available on the [market](https://staging.dev.medicmobile.org/_couch/builds_4/_design/builds/_view/releases). Make sure that the document has new entry with `id: medic:medic:..` +- [ ] Upgrade the [demo](https://demo-cht.dev.medicmobile.org/) instance to the newly released version. - [ ] Use cht-conf to upload the configuration from the `/config/demo` folder to the `demo-cht.dev` server. - [ ] Announce the release on the [CHT forum](https://forum.communityhealthtoolkit.org/c/product/releases/26), under the "Product - Releases" category using this template: ``` @@ -74,5 +74,5 @@ We’ve also implemented loads of other improvements and fixed a heap of bugs. ``` - [ ] Add one last update to the #product-team Slack channel and use the thread to lead an internal release retrospective covering what went well and areas to improve for next time. -- [ ] Add any open "known issues" from the prior release that were not fixed in this release. Done by adding the correct `Affects: 4.x.x` label. +- [ ] Go to the [Issues tab](https://github.com/medic/cht-core/issues) and filter the issues with `is:issue label:"Affects: 4.x.x" ` , replace `4.x.x` with the previous version number. Add any open "known issues" from the prior release that were not fixed in this release. Done by adding the correct `Affects: 4.x.x` label. - [ ] Mark this issue "done" and close the Milestone. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ac24766afbb..6ac90913ba5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,7 @@ jobs: build: name: Compile the app runs-on: ubuntu-22.04 + timeout-minutes: 60 steps: - name: Install bats @@ -88,6 +89,8 @@ jobs: needs: build name: ${{ matrix.cmd }} runs-on: ubuntu-22.04 + timeout-minutes: 60 + strategy: fail-fast: false matrix: @@ -124,6 +127,7 @@ jobs: needs: build name: ${{ matrix.cmd }} runs-on: ubuntu-22.04 + timeout-minutes: 60 strategy: fail-fast: false @@ -180,15 +184,34 @@ jobs: tests/results/ if: ${{ failure() }} + translations: + needs: build + name: Lint translations + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - run: | + cd scripts + npm ci + cd .. + - run: npm run lint-translations + tests: needs: build name: ${{ matrix.cmd }}-${{ matrix.suite || '' }}${{ matrix.chrome-version == '90' && '-minimum-browser' || '' }} runs-on: ubuntu-22.04 + timeout-minutes: 60 + env: CHROME_VERSION: ${{ matrix.chrome-version }} JOB_NAME: ${{ matrix.cmd }}-${{ matrix.suite || '' }}${{ matrix.chrome-version == '90' && '-minimum-browser' || '' }} - strategy: fail-fast: false matrix: @@ -340,9 +363,11 @@ jobs: if: ${{ failure() }} publish: - needs: [tests, config-tests, test-cht-form] + needs: [tests, config-tests, test-cht-form, translations] name: Publish branch build runs-on: ubuntu-22.04 + timeout-minutes: 60 + if: ${{ github.event_name != 'pull_request' }} steps: @@ -388,12 +413,47 @@ jobs: node ./publish.js node ./tag-docker-images.js + + publish-generated-docs: + needs: [publish] + name: Publish generated docs + runs-on: ubuntu-22.04 + timeout-minutes: 5 + + if: ${{ github.event_name != 'pull_request' }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - run: npm ci + - name: Generate TypeDoc + run: npm run --prefix shared-libs/cht-datasource gen-docs + - name: Main Branch Only - Deploy to GH pages + uses: peaceiris/actions-gh-pages@v3 + with: + personal_token: ${{ secrets.DEPLOY_TO_GITHUB_PAGES }} + external_repository: medic/cht-datasource + publish_dir: ./shared-libs/cht-datasource/docs + user_name: medic-ci + user_email: medic-ci@github + publish_branch: main + upgrade: needs: [publish] - name: Upgrade from latest release + name: Upgrade from ${{ matrix.version }} runs-on: ubuntu-22.04 + timeout-minutes: 60 + if: ${{ github.event_name != 'pull_request' }} + strategy: + fail-fast: false + matrix: + version: [ '4.2.4', 'latest' ] + steps: - name: Configure AWS credentials Public if: ${{ env.INTERNAL_CONTRIBUTOR }} @@ -413,6 +473,7 @@ jobs: - name: Set ENV run: | echo "BUILDS_SERVER=$STAGING_SERVER" >> $GITHUB_ENV + echo "BASE_VERSION=${{ matrix.version }}" >> $GITHUB_ENV - run: npm ci - name: Create logs directory run: mkdir tests/logs @@ -422,7 +483,7 @@ jobs: - name: Archive Results uses: actions/upload-artifact@v4 with: - name: Upgrade + name: upgrade-${{ matrix.version }} path: | allure-results allure-report diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml index 05bf46d2b4e..9121c9f79f6 100644 --- a/.github/workflows/conventional-commits.yml +++ b/.github/workflows/conventional-commits.yml @@ -21,4 +21,6 @@ jobs: - name: Install dependencies run: npm ci - name: Run - run: npx --no-install commitlint <<< "${{ github.event.pull_request.title }}" + env: + TITLE: ${{ github.event.pull_request.title }} + run: npx --no-install commitlint <<< "$TITLE" diff --git a/.github/workflows/test_couchdb.yml b/.github/workflows/test_couchdb.yml index 2f7f8ec84cd..9bade99c878 100644 --- a/.github/workflows/test_couchdb.yml +++ b/.github/workflows/test_couchdb.yml @@ -1,3 +1,4 @@ +name: Test CouchDB (Conditional) on: pull_request: paths: diff --git a/.gitignore b/.gitignore index 6545d774018..353e748da6b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ dist api/**/extracted-resources/**/* api/build/**/* jsdocs +shared-libs/**/docs config/*/.gdrive.*.json config/*/.snapshots/* config/*/backups/* diff --git a/.husky/pre-commit b/.husky/pre-commit index 2a12fb446f7..c0f627c447c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,5 @@ #!/usr/bin/env sh +# shellcheck disable=SC1091 # don't test thirdparty packages . "$(dirname -- "$0")/_/husky.sh" branch="$(git rev-parse --abbrev-ref HEAD)" diff --git a/.mocharc.js b/.mocharc.js new file mode 100644 index 00000000000..51771015629 --- /dev/null +++ b/.mocharc.js @@ -0,0 +1,6 @@ +const chaiExclude = require('chai-exclude'); +const chaiAsPromised = require('chai-as-promised'); +const chai = require('chai'); + +chai.use(chaiExclude); +chai.use(chaiAsPromised); diff --git a/admin/package-lock.json b/admin/package-lock.json index f86c9255673..8a2bff8d39f 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -45,8 +45,8 @@ "moment": "^2.29.1" } }, - "../shared-libs/cht-script-api": { - "name": "@medic/cht-script-api", + "../shared-libs/cht-datasource": { + "name": "@medic/cht-datasource", "version": "1.0.0", "extraneous": true, "license": "Apache-2.0" diff --git a/admin/src/js/controllers/edit-user.js b/admin/src/js/controllers/edit-user.js index 7e84ab66f57..babe710669e 100644 --- a/admin/src/js/controllers/edit-user.js +++ b/admin/src/js/controllers/edit-user.js @@ -1,6 +1,8 @@ const moment = require('moment'); const passwordTester = require('simple-password-tester'); const phoneNumber = require('@medic/phone-number'); +const cht = require('@medic/cht-datasource'); +const chtDatasource = cht.getDatasource(cht.getRemoteDataContext()); const PASSWORD_MINIMUM_LENGTH = 8; const PASSWORD_MINIMUM_SCORE = 50; const USERNAME_ALLOWED_CHARS = /^[a-z0-9_-]+$/; @@ -60,18 +62,36 @@ angular const allowTokenLogin = settings => settings.token_login && settings.token_login.enabled; + /** + * Ensures that facility_id is an array for backward compatibility. + * @returns {Array} The normalized facility_id as an array. + */ + const getFacilityId = function () { + if (!$scope.model.facility_id) { + $scope.model.facility_id = []; + } + + if (!Array.isArray($scope.model.facility_id)) { + $scope.model.facility_id = [$scope.model.facility_id]; + } + + return $scope.model.facility_id; + }; + const determineEditUserModel = function() { // Edit a user that's not the current user. // $scope.model is the user object passed in by controller creating the Modal. // If $scope.model === {}, we're creating a new user. return Settings() .then(settings => { + $scope.permissions = settings.permissions; $scope.roles = settings.roles; $scope.allowTokenLogin = allowTokenLogin(settings); if (!$scope.model) { return $q.resolve({}); } + const facilityId = getFacilityId(); const tokenLoginData = $scope.model.token_login; const tokenLoginEnabled = tokenLoginData && { @@ -89,8 +109,8 @@ angular phone: $scope.model.phone, // FacilitySelect is what binds to the select, place is there to // compare to later to see if it's changed once we've run computeFields(); - facilitySelect: $scope.model.facility_id, - place: $scope.model.facility_id, + facilitySelect: facilityId, + place: facilityId, roles: getRoles($scope.model.roles), // ^ Same with contactSelect vs. contact contactSelect: $scope.model.contact_id, @@ -100,6 +120,23 @@ angular }); }; + const fetchDocsByIds = (ids) => { + return DB() + .allDocs({ keys: ids, include_docs: true }); + }; + + const usersPlaces = (ids) => { + return fetchDocsByIds(ids) + .then((docs) => processDocs(docs)) + .then((filteredDocs) => filteredDocs.map((doc) => doc._id)); + }; + + const processDocs = function (result) { + return result.rows + .filter((row) => row.doc && !row.value.deleted) + .map((row) => row.doc); + }; + this.setupPromise = determineEditUserModel() .then(model => { $scope.editUserModel = model; @@ -115,15 +152,16 @@ angular const personTypes = contactTypes.filter(type => type.person).map(type => type.id); Select2Search($('#edit-user-profile [name=contactSelect]'), personTypes); const placeTypes = contactTypes.filter(type => !type.person).map(type => type.id); - Select2Search($('#edit-user-profile [name=facilitySelect]'), placeTypes); + return usersPlaces($scope.editUserModel.facilitySelect).then(facilityIds => { + Select2Search($('#edit-user-profile [name=facilitySelect]'), placeTypes, { initialValue: facilityIds }); + }); }); const validateRequired = (fieldName, fieldDisplayName) => { if (!$scope.editUserModel[fieldName]) { - Translate.fieldIsRequired(fieldDisplayName) - .then(function(value) { - $scope.errors[fieldName] = value; - }); + Translate.fieldIsRequired(fieldDisplayName).then(function (value) { + $scope.errors[fieldName] = value; + }); return false; } return true; @@ -232,6 +270,24 @@ angular return true; }; + const validatePlacesPermission = () => { + if (!$scope.editUserModel.place || $scope.editUserModel.place.length <= 1) { + return true; + } + + const userHasPermission = chtDatasource.v1.hasPermissions( + ['can_have_multiple_places'], $scope.editUserModel.roles, $scope.permissions + ); + + if (!userHasPermission) { + $translate('permission.description.can_have_multiple_places.not_allowed').then(value => { + $scope.errors.multiFacility = value; + }); + } + return userHasPermission; + }; + + const isOnlineUser = (roles) => { if (!$scope.roles) { return true; @@ -254,24 +310,55 @@ angular return hasPlace && hasContact; }; + const validateFacilityHierarchy = () => { + const placeIds = $scope.editUserModel.place; + + if (!placeIds || placeIds.length === 1) { + return $q.resolve(true); + } + + return fetchDocsByIds(placeIds) + .then(result => { + const places = result.rows.map(row => row.doc); + const isSameHierarchy = ContactTypes.isSameContactType(places); + + if (!isSameHierarchy) { + $translate('permission.description.can_have_multiple_places.incompatible_place').then(value => { + $scope.errors.multiFacility = value; + }); + } + return isSameHierarchy; + }) + .catch(err => { + $log.error('Error validating facility hierarchy', err); + return false; + }); + }; + const validateContactIsInPlace = () => { - const placeId = $scope.editUserModel.place; + const placeIds = $scope.editUserModel.place; const contactId = $scope.editUserModel.contact; - if (!placeId || !contactId) { + if (!placeIds || !contactId) { return $q.resolve(true); } - return DB() - .get(contactId) - .then(function(contact) { - let parent = contact.parent; - let valid = false; - while (parent) { - if (parent._id === placeId) { - valid = true; - break; - } - parent = parent.parent; - } + + const getParent = (contactId) => { + return DB().get(contactId).then(contact => contact.parent); + }; + + const checkParent = (parent, placeIds) => { + if (!parent) { + return false; + } + if (placeIds.includes(parent._id)) { + return true; + } + return checkParent(parent.parent, placeIds); + }; + + return getParent(contactId) + .then(function (parent) { + const valid = checkParent(parent, placeIds); if (!valid) { $translate('configuration.user.place.contact').then(value => { $scope.errors.contact = value; @@ -371,9 +458,8 @@ angular }; const computeFields = () => { - $scope.editUserModel.place = $( - '#edit-user-profile [name=facilitySelect]' - ).val(); + const placeValue = $('#edit-user-profile [name=facilitySelect]').val(); + $scope.editUserModel.place = Array.isArray(placeValue) && placeValue.length === 0 ? null : placeValue; $scope.editUserModel.contact = $( '#edit-user-profile [name=contactSelect]' ).val(); @@ -449,7 +535,8 @@ angular validateRole() && validateContactAndFacility() && validatePasswordForEditUser() && - validateEmailAddress(); + validateEmailAddress() && + validatePlacesPermission(); if (!synchronousValidations) { $scope.setError(); @@ -458,6 +545,7 @@ angular const asynchronousValidations = $q .all([ + validateFacilityHierarchy(), validateContactIsInPlace(), validateTokenLogin(), ]) diff --git a/admin/src/js/services/auth.js b/admin/src/js/services/auth.js index c8f9b164d59..74fbd46cdb8 100644 --- a/admin/src/js/services/auth.js +++ b/admin/src/js/services/auth.js @@ -1,4 +1,5 @@ -const chtScriptApi = require('@medic/cht-script-api'); +const cht = require('@medic/cht-datasource'); +const chtDatasource = cht.getDatasource(cht.getRemoteDataContext()); angular.module('inboxServices').factory('Auth', function( @@ -36,7 +37,7 @@ angular.module('inboxServices').factory('Auth', return false; } - return chtScriptApi.v1.hasAnyPermission(permissionsGroupList, userCtx.roles, settings.permissions); + return chtDatasource.v1.hasAnyPermission(permissionsGroupList, userCtx.roles, settings.permissions); }) .catch(() => false); }; @@ -62,7 +63,7 @@ angular.module('inboxServices').factory('Auth', return false; } - return chtScriptApi.v1.hasPermissions(permissions, userCtx.roles, settings.permissions); + return chtDatasource.v1.hasPermissions(permissions, userCtx.roles, settings.permissions); }) .catch(() => false); }; diff --git a/admin/src/js/services/contact-types.js b/admin/src/js/services/contact-types.js index c063928886a..068a5c24396 100644 --- a/admin/src/js/services/contact-types.js +++ b/admin/src/js/services/contact-types.js @@ -49,6 +49,11 @@ angular.module('inboxServices').service('ContactTypes', function( */ getPlaceTypes: () => Settings().then(config => contactTypesUtils.getPlaceTypes(config)), + /** + * @returns {boolean} returns whether the provided places have the same contact_type + */ + isSameContactType: (places) => contactTypesUtils.isSameContactType(places), + /** * Returns a Promise to resolve all the configured person contact types */ diff --git a/admin/src/js/services/create-user.js b/admin/src/js/services/create-user.js index b0129901b05..4ac649194e1 100644 --- a/admin/src/js/services/create-user.js +++ b/admin/src/js/services/create-user.js @@ -1,7 +1,6 @@ (function () { 'use strict'; - const URL = '/api/v2/users'; angular.module('services').factory('CreateUser', function ( @@ -20,6 +19,7 @@ * @param {Object} updates Updates you wish to make */ const createSingleUser = (updates) => { + const URL = '/api/v3/users'; if (!updates.username) { return $q.reject('You must provide a username to create a user'); } @@ -43,6 +43,7 @@ * @param {Object} data content of the csv file */ const createMultipleUsers = (data) => { + const URL = '/api/v2/users'; $log.debug('CreateMultipleUsers', URL, data); return $http({ @@ -61,6 +62,6 @@ createMultipleUsers }; }); - + }() ); diff --git a/admin/src/js/services/select2-search.js b/admin/src/js/services/select2-search.js index 95fec28dd59..30ab2474873 100644 --- a/admin/src/js/services/select2-search.js +++ b/admin/src/js/services/select2-search.js @@ -6,13 +6,15 @@ angular.module('inboxServices').factory('Select2Search', function( $log, $q, + $timeout, $translate, ContactMuted, + DB, LineageModelGenerator, Search, Session, Settings - ) { + ) { //NoSONAR 'use strict'; 'ngInject'; @@ -26,18 +28,29 @@ angular.module('inboxServices').factory('Select2Search', return $(format.sender(row.doc, $translate)); }; - const defaultTemplateSelection = function(row) { - if (row.doc) { - return row.doc.name + (row.doc.muted ? ' (' + $translate.instant('contact.muted') + ')': ''); + const defaultTemplateSelection = (selection) => { + const formatRow = (row) => { + if (!row.doc) { + return row.text; + } + + let formatted = row.doc.name; + if (row.doc.muted) { + formatted += `(${$translate.instant('contact.muted')})`; + } + return formatted; + }; + if (Array.isArray(selection)) { + return selection.map((row) => formatRow(row)).join(', '); } - return row.text; + return formatRow(selection); }; const defaultSendMessageExtras = function(row) { return row; }; - return function(selectEl, _types, options) { + return function(selectEl, _types, options) { //NoSONAR options = options || {}; let currentQuery; @@ -114,10 +127,53 @@ angular.module('inboxServices').factory('Select2Search', }); }; - const resolveInitialValue = function(selectEl, initialValue) { + const addValue = (val) => { + if (!selectEl.children('option[value="' + val + '"]').length) { + selectEl.append($('')); selectEl.trigger('change'); + + return selectEl; }); selectEl.after(button); } @@ -185,17 +275,12 @@ angular.module('inboxServices').factory('Select2Search', e.params.data.id; if (docId) { - getDoc(docId).then(function(doc) { - selectEl.select2('data')[0].doc = doc; - selectEl.trigger('change'); - }) - .catch(err => $log.error('Select2 failed to get document', err)); + updateDocument(selectEl, docId); } }); } }; - initSelect2(selectEl); return resolveInitialValue(selectEl, initialValue); }; diff --git a/admin/src/templates/edit_user.html b/admin/src/templates/edit_user.html index 6c2aa32844b..ad5cd677e5c 100644 --- a/admin/src/templates/edit_user.html +++ b/admin/src/templates/edit_user.html @@ -55,13 +55,15 @@ {{errors.roles}} -
+
- + - {{errors.place}} - user.place.help + {{errors.place}} + {{errors.multiFacility}} + user.place.help
diff --git a/admin/tests/unit/controllers/edit-user.spec.js b/admin/tests/unit/controllers/edit-user.spec.js index 802cdd04b98..5960c6dc0b5 100644 --- a/admin/tests/unit/controllers/edit-user.spec.js +++ b/admin/tests/unit/controllers/edit-user.spec.js @@ -8,6 +8,7 @@ describe('EditUserCtrl controller', () => { let mockEditCurrentUser; let scope; let dbGet; + let dbAllDocs; let UpdateUser; let CreateUser; let UserSettings; @@ -15,12 +16,14 @@ describe('EditUserCtrl controller', () => { let Translate; let Settings; let userToEdit; + let user; let http; beforeEach(() => { module('adminApp'); dbGet = sinon.stub(); + dbAllDocs = sinon.stub(); UpdateUser = sinon.stub().resolves(); CreateUser = { createSingleUser: sinon.stub().resolves() @@ -29,10 +32,14 @@ describe('EditUserCtrl controller', () => { Settings = sinon.stub().resolves({ roles: { 'district-manager': { name: 'xyz', offline: true }, + 'community-health-assistant': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' }, supervisor: { name: 'qrt', offline: true }, 'national-manager': { name: 'national-manager', offline: false }, - } + }, + permissions: { + can_have_multiple_places: ['community-health-assistant'], + }, }); http = { get: sinon.stub() }; userToEdit = { @@ -41,7 +48,7 @@ describe('EditUserCtrl controller', () => { fullname: 'user.fullname', email: 'user@email.com', phone: 'user.phone', - facility_id: 'abc', + facility_id: ['abc'], contact_id: 'xyz', roles: [ 'district-manager', 'supervisor' ], language: 'zz', @@ -64,6 +71,7 @@ describe('EditUserCtrl controller', () => { 'DB', KarmaUtils.mockDB({ get: dbGet, + allDocs: dbAllDocs, }) ); $provide.value('UpdateUser', UpdateUser); @@ -171,6 +179,27 @@ describe('EditUserCtrl controller', () => { }); }); + describe('Initializing existing users', () => { + user = { + _id: 'user.id', + name: 'user.name', + fullname: 'user.fullname', + email: 'user@email.com', + phone: 'user.phone', + facility_id: 'abc', + contact_id: 'xyz', + roles: ['supervisor'], + language: 'zz', + }; + + it('converts string facility_id to Array ', () => { + return mockEditAUser(user).setupPromise.then(() => { + chai.expect(scope.editUserModel.facilitySelect).to.deep.equal(['abc']); + chai.expect(scope.editUserModel.facilitySelect).to.be.an('array'); + }); + }); + }); + describe('$scope.editUser', () => { it('username must be present', () => { return mockEditAUser(userToEdit) @@ -348,6 +377,24 @@ describe('EditUserCtrl controller', () => { }); }); + it('should allow only user with permission to have multiple places', () => { + return mockEditAUser(userToEdit) + .setupPromise.then(() => { + mockContact(userToEdit.contact_id); + mockFacility(['facility_id', 'facility_id_2']); + mockContactGet(userToEdit.contact_id); + translate.withArgs('permission.description.can_have_multiple_places.not_allowed') + .resolves('The person with selected role cannot have multiple places'); + + return scope.editUser(); + }) + .then(() => { + chai.expect(scope.errors.multiFacility).to.equal( + 'The person with selected role cannot have multiple places' + ); + }); + }); + it('user is updated', () => { mockContact(userToEdit.contact_id); @@ -363,7 +410,7 @@ describe('EditUserCtrl controller', () => { scope.editUserModel.fullname = 'fullname'; scope.editUserModel.email = 'email@email.com'; scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'facility_id'; + scope.editUserModel.facilitySelect = ['facility_id']; scope.editUserModel.contactSelect = 'contact_id'; scope.editUserModel.password = 'medic.1234'; scope.editUserModel.passwordConfirm = 'medic.1234'; @@ -397,6 +444,64 @@ describe('EditUserCtrl controller', () => { }); }); + it('user is updated with multiple places', () => { + mockContact(userToEdit.contact_id); + mockFacility(['facility_id', 'facility_id_2']); + mockContactGet(userToEdit.contact_id); + http.get.withArgs('/api/v1/users-info').resolves({ + data: { total_docs: 20000, warn_docs: 800, warn: false, limit: 10000 }, + }); + + dbAllDocs.resolves({ + rows: [ + { doc: { _id: 'facility_id' } }, + { doc: { _id: 'facility_id_2' } }, + ], + }); + + return mockEditAUser(userToEdit) + .setupPromise.then(() => { + scope.editUserModel.fullname = 'fullname'; + scope.editUserModel.email = 'email@email.com'; + scope.editUserModel.phone = 'phone'; + scope.editUserModel.facilitySelect = ['facility_id', 'facility_id_2']; + scope.editUserModel.contactSelect = 'contact_id'; + scope.editUserModel.password = 'medic.1234'; + scope.editUserModel.passwordConfirm = 'medic.1234'; + scope.editUserModel.roles = ['community-health-assistant']; + + return scope.editUser(); + }) + .then(() => { + chai.expect(UpdateUser.called).to.equal(true); + const updateUserArgs = UpdateUser.getCall(0).args; + + chai.expect(updateUserArgs[0]).to.equal('user.name'); + + const updates = updateUserArgs[1]; + chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname); + chai.expect(updates.email).to.equal(scope.editUserModel.email); + chai.expect(updates.phone).to.equal(scope.editUserModel.phone); + chai + .expect(updates.place) + .to.deep.equal(['facility_id', 'facility_id_2']); + chai.expect(updates.contact).to.equal(scope.editUserModel.contact_id); + chai.expect(updates.roles).to.deep.equal(scope.editUserModel.roles); + chai.expect(updates.password).to.deep.equal(scope.editUserModel.password); + chai.expect(http.get.callCount).to.equal(1); + chai.expect(http.get.args[0]).to.deep.equal([ + '/api/v1/users-info', + { + params: { + role: ['community-health-assistant'], + facility_id: scope.editUserModel.place, + contact_id: scope.editUserModel.contact, + }, + }, + ]); + }); + }); + it('sorts roles when saving', () => { mockContact(userToEdit.contact_id); mockFacility(userToEdit.facility_id); @@ -443,7 +548,7 @@ describe('EditUserCtrl controller', () => { scope.editUserModel.fullname = 'fullname'; scope.editUserModel.email = 'email@email.com'; scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'facility_id'; + scope.editUserModel.facilitySelect = ['facility_id']; scope.editUserModel.contactSelect = 'contact_id'; scope.editUserModel.password = 'medic.1234'; scope.editUserModel.passwordConfirm = 'medic.1234'; @@ -469,7 +574,7 @@ describe('EditUserCtrl controller', () => { it('should not save user if offline and is warned by users-info', () => { mockContact('new_contact_id'); - mockFacility('new_facility_id'); + mockFacility(['new_facility_id']); mockContactGet('new_facility_id'); http.get .withArgs('/api/v1/users-info') @@ -481,7 +586,7 @@ describe('EditUserCtrl controller', () => { scope.editUserModel.fullname = 'fullname'; scope.editUserModel.email = 'email@email.com'; scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'new_facility'; + scope.editUserModel.facilitySelect = ['new_facility']; scope.editUserModel.contactSelect = 'new_contact'; scope.editUserModel.password = 'medic.1234'; scope.editUserModel.passwordConfirm = 'medic.1234'; @@ -495,7 +600,7 @@ describe('EditUserCtrl controller', () => { chai.expect(http.get.callCount).to.equal(1); chai.expect(http.get.args[0]).to.deep.equal([ '/api/v1/users-info', - { params: { role: [ 'supervisor' ], facility_id: 'new_facility_id', contact_id: 'new_contact_id' }} + { params: { role: [ 'supervisor' ], facility_id: ['new_facility_id'], contact_id: 'new_contact_id' }} ]); chai.expect(scope.setError.callCount).to.equal(1); chai.expect(scope.setError.args[0]).to.deep.equal([ @@ -515,7 +620,7 @@ describe('EditUserCtrl controller', () => { it('should save user if offline and warned when user clicks on submit the 2nd time', () => { mockContact('new_contact_id'); - mockFacility('new_facility_id'); + mockFacility(['new_facility_id']); mockContactGet('new_facility_id'); http.get .withArgs('/api/v1/users-info') @@ -528,7 +633,7 @@ describe('EditUserCtrl controller', () => { scope.editUserModel.fullname = 'fullname'; scope.editUserModel.email = 'email@email.com'; scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'new_facility'; + scope.editUserModel.facilitySelect = ['new_facility']; scope.editUserModel.contactSelect = 'new_contact'; scope.editUserModel.password = 'medic.1234'; scope.editUserModel.passwordConfirm = 'medic.1234'; @@ -541,7 +646,7 @@ describe('EditUserCtrl controller', () => { chai.expect(http.get.callCount).to.equal(1); chai.expect(http.get.args[0]).to.deep.equal([ '/api/v1/users-info', - { params: { role: [ 'supervisor' ], facility_id: 'new_facility_id', contact_id: 'new_contact_id' }} + { params: { role: [ 'supervisor' ], facility_id: ['new_facility_id'], contact_id: 'new_contact_id' }} ]); chai.expect(translate.callCount).to.equal(1); diff --git a/admin/tests/unit/services/update-user.spec.js b/admin/tests/unit/services/update-user.spec.js index 1b5d0aba7d3..62d25185931 100644 --- a/admin/tests/unit/services/update-user.spec.js +++ b/admin/tests/unit/services/update-user.spec.js @@ -82,7 +82,7 @@ describe('CreateUser service', () => { chai.expect($http.callCount).to.equal(1); chai.expect($http.args[0][0]).to.deep.equal({ method: 'POST', - url: '/api/v2/users', + url: '/api/v3/users', data: {username: 'user', some: 'updates'}, headers: { 'Accept': 'application/json', diff --git a/api/.eslintrc b/api/.eslintrc index 93696957101..633c873c81f 100644 --- a/api/.eslintrc +++ b/api/.eslintrc @@ -14,7 +14,7 @@ { "allowModules": [ "@medic/bulk-docs-utils", - "@medic/cht-script-api", + "@medic/cht-datasource", "@medic/contact-types-utils", "@medic/contacts", "@medic/couch-request", diff --git a/api/package.json b/api/package.json index 04a30afd9ee..21507b6b2de 100644 --- a/api/package.json +++ b/api/package.json @@ -13,7 +13,8 @@ }, "scripts": { "toc": "doctoc --github --maxlevel 2 README.md", - "postinstall": "patch-package" + "postinstall": "patch-package", + "run-watch": "TZ=UTC nodemon --inspect=0.0.0.0:9229 --ignore 'build/static' --ignore 'build/public' --watch ./ --watch '../shared-libs/**/src/**' server.js -- --allow-cors" }, "dependencies": { "async": "^3.2.1", diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index 0431bc552f8..4962d4f1738 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -638,6 +638,9 @@ email.invalid = Invalid email address. empty = It looks like you sent an empty message, please try to resend. If you continue to have this problem please contact your supervisor. enketo.constraint.invalid = Value not allowed enketo.constraint.required = This field is required +enketo.drawwidget.annotation = annotation +enketo.drawwidget.drawing = drawing +enketo.drawwidget.signature = signature enketo.error.max_attachment_size = The uploaded files exceed the total size limit. Please upload smaller files. enketo.form.required = required enketo.filepicker.file = file @@ -787,7 +790,7 @@ instance.upgrade.error.get_upgrade = Error fetching upgrading progress instance.upgrade.error.version_fetch = Error fetching available versions instance.upgrade.feature_releases = Feature Releases Betas instance.upgrade.install = Install -instance.upgrade.interrupted = An unexpected server error caused the upgrade to be interrupted. +instance.upgrade.interrupted = An unexpected server error caused the upgrade to be interrupted. instance.upgrade.no_betas = There are no new betas you can upgrade to. instance.upgrade.no_branches = No build branches available. instance.upgrade.no_details = (details unavailable) @@ -1024,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Allowed to see report permission.description.can_view_users = Allowed to get a list of all configured users. permission.description.can_write_wealth_quintiles = Allowed to update contacts with their wealth quintiles. permission.description.can_upgrade = Allowed to upgrade the CHT Core Framework version via the API or admin interface. +permission.description.can_have_multiple_places.not_allowed = The selected roles do not have permission to be assigned multiple places. +permission.description.can_have_multiple_places.incompatible_place = The selected places must be of the same type (be at the same hierarchy level). permissions = Permissions person.field.alternate_phone = Alternative phone number person.field.code = Code diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 53c7caa1a76..019e7bdd94d 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -426,7 +426,7 @@ configuration.enable.token.login.phone = Se requiere un número de teléfono vá configuration.enable.token.login.refresh = Regenerar y reenviar SMS de inicio de sesión. configuration.enable.token.login.refresh.help = Deshabilitar o regenerar el SMS de inicio de sesión resultará en el cambio de la contraseña del usuario, lo que provocará que el usuario cierre la sesión. configuration.messagetest = Mensaje de prueba -configuration.permiso = Permiso +configuration.permission = Permiso configuration.permissions = Permisos configuration.role = Rol configuration.role.offline = Sin conexión @@ -638,6 +638,9 @@ email.invalid = Dirección de correo electrónico no válida. empty = El mensaje esta en blanco, por favor reenvielo. Si continua teniendo problemas, informe a su supervisor. enketo.constraint.invalid = Valor no permitido enketo.constraint.required = Este campo es obligatorio +enketo.drawwidget.annotation = anotacin +enketo.drawwidget.drawing = dibujo +enketo.drawwidget.signature = firma enketo.error.max_attachment_size = Los archivos subidos exceden el límite de tamaño total. Suba archivos más pequeños. enketo.form.required = requerido enketo.filepicker.file = archivo @@ -645,9 +648,9 @@ enketo.filepicker.placeholder = Haga clic aquí para cargar el archivo. (< {{max enketo.filepicker.notFound = No se pudo encontrar el archivo {{existing}} (omitir si ya lo envió y desea conservarlo). enketo.filepicker.waitingForPermissions = Esperando permisos de usuario. enketo.filepicker.resetWarning = Esto eliminará el {{item}}. ¿Seguro que quieres hacer esto? -enketo.filepicker.toollargeerror = Archivo demasiado grande (> {{maxSize}}) +enketo.filepicker.toolargeerror = Archivo demasiado grande (> {{maxSize}}) enketo.geopicker.accuracy = precisión (m) -enketo.geopicker.altitud = altitud (m) +enketo.geopicker.altitude = altitud (m) enketo.geopicker.closepolygon = cerrar polígono enketo.geopicker.kmlcoords = coordenadas KML enketo.geopicker.kmlpaste = pegar coordenadas KML aquí @@ -860,7 +863,7 @@ messages.n.report_accepted = Gracias {{contact.name}} por registrar a {{patient_ messages.n.validation.patient_name = {{\#patient_name}}El formato de registro es incorrecto, asegúrese que el mensaje comience con N seguido de un espacio y el nombre de la persona (máximo 30 caracteres).{{/patient_name}}{{^patient_name}}El formato de registro es incorrecto; asegúrese que el mensaje comience con N seguido de un espacio y el nombre de la persona.{{/patient_name}}. messages.off.report_accepted = No se enviarán más notificaciones sobre {{patient_name}} hasta que envíe 'ON {{patient_id}}'.{{\#chw_sms}} {{chw_sms}}{{/chw_sms}} messages.on.report_accepted = Las notificaciones para {{patient_name}} ({{patient_id}}) se han reactivado.{{\#chw_sms}} {{chw_sms}}{{/chw_sms}} -messages.p.report_accepted = Gracias por registrar el embarazo de {{patient_name}} ({{patient_id}}).{{\#expected_date}} Su FEP es {{\#date}}{{expected_date}}{{ /fecha}}{{/fecha_esperada}} +messages.p.report_accepted = Gracias por registrar el embarazo de {{patient_name}} ({{patient_id}}).{{\#expected_date}} Su FEP es {{\#date}}{{expected_date}}{{/date}}{{/expected_date}} messages.p.validation.last_menstrual_period = El formato de registro para {{patient_name}} es incorrecto, asegúrese de que FUM sea un número entre 2 y 42. messages.relay.chw_sms = {{\#chw_sms}}{{chw_sms}}{{/chw_sms}} messages.schedule.anc.checkin = \¿Dónde ocurrió el parto de {{patient_name}}? Responda con 'D {{patient_id}} F' para parto en un centro, 'D {{patient_id}} S' para parto en casa con partera capacitada, 'D {{patient_id}} NS' para parto en casa no especializado. @@ -878,7 +881,7 @@ messages.schedule.child.month_06 = Visite {{patient_name}} {{patient_id}} para r messages.schedule.child.month_07 = Visite {{patient_name}} {{patient_id}} para asegurarse de que estén al día con sus vacunas + verificar si hay signos de mala salud. Gracias\! messages.schedule.child.month_08 = Visite {{patient_name}} {{patient_id}} para recordar las vacunas pendientes y comprobar si hay signos de mala salud. Gracias\! messages.schedule.child.month_09 = Visite {{patient_name}} {{patient_id}} para asegurarse de que estén al día con sus vacunas + verificar si hay signos de mala salud. Gracias\! -messages.schedule.child.month_10 = Visite {{patient_name}} {{patient_id}} para asegurarse de que estén al día con sus vacunas + verificar si hay signos de mala salud. Gracias\ +messages.schedule.child.month_10 = Visite {{patient_name}} {{patient_id}} para asegurarse de que estén al día con sus vacunas + verificar si hay signos de mala salud. Gracias\! messages.schedule.child.month_11 = Visite {{patient_name}} {{patient_id}} para asegurarse de que estén al día con sus vacunas + verificar si hay signos de mala salud. Gracias\! messages.schedule.child.month_12 = Visite {{patient_name}} {{patient_id}} para recordar las vacunas pendientes y comprobar si hay signos de mala salud. Gracias\! messages.schedule.child.month_13 = Visite {{patient_name}} {{patient_id}} para asegurarse de que estén al día con sus vacunas + verificar si hay signos de mala salud. Gracias\! @@ -1024,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Ver informes que no t permission.description.can_view_users = Ver la lista de todos los usuarios configurados. permission.description.can_write_wealth_quintiles = Actualizar contactos con sus quintiles de riqueza. permission.description.can_upgrade = Actualizar la versión de CHT Core Framework a través de la API o en la Gestión de la Aplicación. +permission.description.can_have_multiple_places.not_allowed = Los roles seleccionados no tienen permiso para ser asignados a múltiples lugares. +permission.description.can_have_multiple_places.incompatible_place = Los lugares seleccionados deben ser del mismo tipo (ser del mismo nivel de jerarquía). permissions = Permisos person.field.alternate_phone = Número de teléfono alternativo person.field.code = Código diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index 0fb55c298ea..2f97e048a95 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -638,6 +638,9 @@ email.invalid = Adresse e-mail invalide. empty = Nous avons reçu un message vide. SVP réessayer et si vous continuez à avoir des problèmes contactez votre superviseur. enketo.constraint.invalid = Valeur non acceptée enketo.constraint.required = Ce champ est requis +enketo.drawwidget.annotation = annotation +enketo.drawwidget.drawing = dessiner +enketo.drawwidget.signature = signature enketo.error.max_attachment_size = Les fichiers téléchargés dépassent la limite de taille totale. Veuillez télécharger des fichiers plus petits. enketo.form.required = Requis enketo.filepicker.file = fichier @@ -1024,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Autorisé à voir les permission.description.can_view_users = Autorisé à obtenir une liste de tous les utilisateurs configurés. permission.description.can_write_wealth_quintiles = Autorisé à mettre à jour les contacts avec leurs quintiles de richesse. permission.description.can_upgrade = Autorisé à mettre à niveau la version de CHT Core Framework via l'API ou l'interface d'administration. +permission.description.can_have_multiple_places.not_allowed = Les rôles sélectionnés ne sont pas autorisés à se voir attribuer plusieurs places. +permission.description.can_have_multiple_places.incompatible_place = Les places sélectionnées doivent être du même type (être au même niveau hiérarchique). permissions = Permissions person.field.alternate_phone = Numéro de téléphone alternatif person.field.code = Code @@ -1121,20 +1126,20 @@ report.immunization_visit.vaccines_received.received_bcg = BCG report.immunization_visit.vaccines_received.received_cholera_1 = Choléra 1 report.immunization_visit.vaccines_received.received_cholera_2 = Choléra 2 report.immunization_visit.vaccines_received.received_cholera_3 = Choléra 3 -report.immunization_visit.vaccines_received.received_dpt_4 = -report.immunization_visit.vaccines_received.received_dpt_5 = -report.immunization_visit.vaccines_received.received_fipv_1 = -report.immunization_visit.vaccines_received.received_fipv_2 = +report.immunization_visit.vaccines_received.received_dpt_4 = Rappel DTP 1 +report.immunization_visit.vaccines_received.received_dpt_5 = Rappel DTP 2 +report.immunization_visit.vaccines_received.received_fipv_1 = VPI Fractionné 1 +report.immunization_visit.vaccines_received.received_fipv_2 = VPI Fractionné 2 report.immunization_visit.vaccines_received.received_flu = Grippe report.immunization_visit.vaccines_received.received_hep_a_1 = Hépatite A 1 report.immunization_visit.vaccines_received.received_hep_a_2 = Hépatite A 2 -report.immunization_visit.vaccines_received.received_hep_b = +report.immunization_visit.vaccines_received.received_hep_b = Hépatite B report.immunization_visit.vaccines_received.received_hpv_1 = HPV 1 report.immunization_visit.vaccines_received.received_hpv_2 = HPV 2 report.immunization_visit.vaccines_received.received_hpv_3 = HPV 3 -report.immunization_visit.vaccines_received.received_ipv_1 = -report.immunization_visit.vaccines_received.received_ipv_2 = -report.immunization_visit.vaccines_received.received_ipv_3 = +report.immunization_visit.vaccines_received.received_ipv_1 = Polio Désactivé 1 +report.immunization_visit.vaccines_received.received_ipv_2 = Polio Désactivé 2 +report.immunization_visit.vaccines_received.received_ipv_3 = Polio Désactivé 3 report.immunization_visit.vaccines_received.received_jap_enc = Encéphalite japonaise report.immunization_visit.vaccines_received.received_meningococcal_1 = Méningococcique 1 report.immunization_visit.vaccines_received.received_meningococcal_2 = Méningococcique 2 diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index 2757f177b37..0ad3076b698 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -624,6 +624,9 @@ email.invalid = empty = सन्देश​ खाली छ​ । कृपया फेरि प्रयास गर्नुहोला। enketo.constraint.invalid = enketo.constraint.required = +enketo.drawwidget.annotation = व्याख्या +enketo.drawwidget.drawing = चित्र +enketo.drawwidget.signature = हस्ताक्षर enketo.form.required = error.403.description = तपाईं यस पृष्ठ हेर्न अपर्याप्त विशेषाधिकार छ । आफ्नो विशेषाधिकार वृद्धि गर्न प्रशासकीय कुरा । error.403.title = त्रुटी शिर्षक @@ -946,6 +949,8 @@ permission.description.can_view_tasks_tab = permission.description.can_view_unallocated_data_records = permission.description.can_view_users = permission.description.can_write_wealth_quintiles = +permission.description.can_have_multiple_places.not_allowed = तपाईंले छानेको भूमिकाहरुलाई धेरै स्थानको प्रमुख बनाउन मिल्दैन. +permission.description.can_have_multiple_places.incompatible_place = तपाईंले छानेका स्थानहरु एकै प्रकारको हुनु पर्छ (एकै तहमा भएको)।. permissions = person.field.alternate_phone = वैकल्पिक फोन नम्बर person.field.code = कोड diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index 739585fb3d0..3ab4cb97ff1 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -640,6 +640,9 @@ email.invalid = Anwani ya barua pepe si sahihi empty = Yaonekana kama umetuma ujumbe mtupu, tafadhali jaribu kutuma tena. Ukiendelea kupata hitilafu hii tafadhali muarifu msimamizi wako. enketo.constraint.invalid = Tarakimu hairuhusiwi enketo.constraint.required = Hii sehemu inahitajika +enketo.drawwidget.annotation = maelezo +enketo.drawwidget.drawing = kuchora +enketo.drawwidget.signature = sahihi enketo.error.max_attachment_size = Faili zilizopakiwa zinazidi kikomo cha ukubwa wote. Tafadhali pakia faili ndogo. enketo.form.required = Inahitajika enketo.filepicker.file = faili @@ -1024,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Inaruhusiwa kuona rip permission.description.can_view_users = Inaruhusiwa kupata orodha ya watumiaji wote waliosanidiwa. permission.description.can_write_wealth_quintiles = Inaruhusiwa kusasisha watu na viwango vyao vya utajiri. permission.description.can_upgrade = Inaruhusiwa kuboresha toleo la CHT kupitia API au kiolesura cha msimamizi. +permission.description.can_have_multiple_places.not_allowed = Mtu aliye na majukumu yaliyochaguliwa hana ruhusa ya kupewa maeneo mengi. +permission.description.can_have_multiple_places.incompatible_place = Maeneo yaliyochaguliwa lazima yawe ya aina moja (yawe katika kiwango sawa cha uongozi). permissions = Ruhusa person.field.alternate_phone = Nambari ya simu mbadala person.field.code = Kanuni @@ -1324,7 +1329,7 @@ targets.pregnancy_registrations.title = Mimba mpya targets.this_month.subtitle = Mwezi huu targets.vaccines_given.title = Chanjo iliyopewa task.date = Tarehe ya kukamilisha -task.days.left = +task.days.left = {DAYS, plural, one{Imesalia siku 1} other{Zimesalia siku \#}} task.immunization_missing_visit.title = Ziara ya chanjo inakosekana task.list.complete = Hakuna jambo ingine task.overdue = Imepita siku ya kuwasilishwa diff --git a/api/src/auth.js b/api/src/auth.js index 1012247aa0c..508ffc81906 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -3,7 +3,8 @@ const _ = require('lodash'); const db = require('./db'); const environment = require('@medic/environment'); const config = require('./config'); -const { roles, users } = require('@medic/user-management')(config, db); +const dataContext = require('./services/data-context'); +const { roles, users } = require('@medic/user-management')(config, db, dataContext); const contentLengthRegex = /^content-length$/i; diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index dd0196d0a63..f522bcce7bf 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -8,7 +8,8 @@ const config = require('../config'); const privacyPolicy = require('../services/privacy-policy'); const logger = require('@medic/logger'); const db = require('../db'); -const { tokenLogin, roles, users } = require('@medic/user-management')(config, db); +const dataContext = require('../services/data-context'); +const { tokenLogin, roles, users } = require('@medic/user-management')(config, db, dataContext); const localeUtils = require('locale'); const cookie = require('../services/cookie'); const brandingService = require('../services/branding'); diff --git a/api/src/controllers/person.js b/api/src/controllers/person.js new file mode 100644 index 00000000000..d8712a2163c --- /dev/null +++ b/api/src/controllers/person.js @@ -0,0 +1,27 @@ +const { Person, Qualifier } = require('@medic/cht-datasource'); +const ctx = require('../services/data-context'); +const serverUtils = require('../server-utils'); +const auth = require('../auth'); + +const getPerson = ({ with_lineage }) => ctx.bind( + with_lineage === 'true' + ? Person.v1.getWithLineage + : Person.v1.get +); + +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' }); + } + 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); + }) + } +}; diff --git a/api/src/controllers/place.js b/api/src/controllers/place.js new file mode 100644 index 00000000000..869d2b625ee --- /dev/null +++ b/api/src/controllers/place.js @@ -0,0 +1,27 @@ +const { Place, Qualifier } = require('@medic/cht-datasource'); +const ctx = require('../services/data-context'); +const serverUtils = require('../server-utils'); +const auth = require('../auth'); + +const getPlace = ({ with_lineage }) => ctx.bind( + with_lineage === 'true' + ? Place.v1.getWithLineage + : Place.v1.get +); + +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' }); + } + 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); + }) + } +}; diff --git a/api/src/controllers/users.js b/api/src/controllers/users.js index 71ab5079b40..204178ea0ff 100644 --- a/api/src/controllers/users.js +++ b/api/src/controllers/users.js @@ -1,7 +1,8 @@ const _ = require('lodash'); const db = require('../db'); const config = require('../config'); -const { bulkUploadLog, roles, users } = require('@medic/user-management')(config, db); +const dataContext = require('../services/data-context'); +const { bulkUploadLog, roles, users } = require('@medic/user-management')(config, db, dataContext); const auth = require('../auth'); const logger = require('@medic/logger'); const serverUtils = require('../server-utils'); @@ -90,7 +91,7 @@ const getInfoUserCtx = req => { return { roles: userRoles, - facility_id: params.facility_id, + facility_id: Array.isArray(params.facility_id) ? params.facility_id : [params.facility_id], contact_id: params.contact_id, }; }; @@ -134,6 +135,47 @@ const convertUserListToV1 = (users=[]) => { return users; }; +const verifyUpdateRequest = async (req) => { + const username = req.params.username; + const credentials = auth.basicAuthCredentials(req); + + const basic = await basicAuthValid(credentials, username); + if (basic === false) { + // If you're passing basic auth we're going to validate it, even if we + // technicaly don't need to (because you already have a valid cookie and + // full permission). + // This is to maintain consistency in the personal change password UI: + // we want to validate the password you pass regardless of your permissions + return Promise.reject({ + message: 'Bad username / password', + code: 401, + }); + } + + const fullPermission = await hasFullPermission(req); + if (fullPermission) { + return { fullPermission }; + } + + const updatingSelf = await isUpdatingSelf(req, credentials, username); + if (!updatingSelf) { + return Promise.reject({ + message: 'You do not have permissions to modify this person', + code: 403, + }); + } + + const changingPassword = isChangingPassword(req); + if (_.isUndefined(basic) && changingPassword) { + return Promise.reject({ + message: 'You must authenticate with Basic Auth to modify your password', + code: 403, + }); + } + + return { fullPermission }; +}; + module.exports = { list: (req, res) => { return getUserList(req) @@ -148,72 +190,28 @@ module.exports = { .then(body => res.json(body)) .catch(err => serverUtils.error(err, req, res)); }, - update: (req, res) => { + update: async (req, res) => { if (_.isEmpty(req.body)) { return serverUtils.emptyJSONBodyError(req, res); } - const username = req.params.username; - const credentials = auth.basicAuthCredentials(req); - - return Promise.all([ - hasFullPermission(req), - isUpdatingSelf(req, credentials, username), - basicAuthValid(credentials, username), - isChangingPassword(req), - auth.getUserCtx(req), - ]) - .then( - ([ - fullPermission, - updatingSelf, - basic, - changingPassword, - requesterContext, - ]) => { - if (basic === false) { - // If you're passing basic auth we're going to validate it, even if we - // technicaly don't need to (because you already have a valid cookie and - // full permission). - // This is to maintain consistency in the personal change password UI: - // we want to validate the password you pass regardless of your permissions - return Promise.reject({ - message: 'Bad username / password', - code: 401, - }); - } - - if (!fullPermission) { - if (!updatingSelf) { - return Promise.reject({ - message: 'You do not have permissions to modify this person', - code: 403, - }); - } - - if (_.isUndefined(basic) && changingPassword) { - return Promise.reject({ - message: 'You must authenticate with Basic Auth to modify your password', - code: 403, - }); - } - } - - return users - .updateUser(username, req.body, !!fullPermission, getAppUrl(req)) - .then(result => { - const body = Object.keys(req.body).join(','); - logger.info( - `REQ ${req.id} - Updated user '${username}'. ` + - `Setting field(s) '${body}'. ` + - `Requested by '${requesterContext?.name}'.` - ); - return result; - }); - } - ) - .then(body => res.json(body)) - .catch(err => serverUtils.error(err, req, res)); + try { + const { fullPermission } = await verifyUpdateRequest(req); + const requesterContext = await auth.getUserCtx(req); + + const username = req.params.username; + const result = await users.updateUser(username, req.body, !!fullPermission, getAppUrl(req)); + + const body = Object.keys(req.body).join(','); + logger.info( + `REQ ${req.id} - Updated user '${username}'. ` + + `Setting field(s) '${body}'. ` + + `Requested by '${requesterContext?.name}'.` + ); + res.json(result); + } catch (err) { + serverUtils.error(err, req, res); + } }, delete: (req, res) => { auth @@ -284,5 +282,20 @@ module.exports = { serverUtils.error(error, req, res); } }, + }, + v3: { + create: async (req, res) => { + try { + await auth.check(req, ['can_edit', 'can_create_users']); + + const response = await users.createMultiFacilityUser(req.body, getAppUrl(req)); + res.json(response); + } catch (error) { + serverUtils.error(error, req, res); + } + }, + update: (req, res) => { + return module.exports.update(req, res); + }, } }; diff --git a/api/src/migrations/extract-person-contacts.js b/api/src/migrations/extract-person-contacts.js index 092e27d4640..af6e262cb53 100644 --- a/api/src/migrations/extract-person-contacts.js +++ b/api/src/migrations/extract-person-contacts.js @@ -2,8 +2,9 @@ const async = require('async'); const { promisify } = require('util'); const config = require('../config'); const db = require('../db'); +const dataContext = require('../services/data-context'); const logger = require('@medic/logger'); -const { people, places } = require('@medic/contacts')(config, db); +const { people, places } = require('@medic/contacts')(config, db, dataContext); // WARNING : THIS MIGRATION IS POTENTIALLY DESTRUCTIVE IF IT MESSES UP HALFWAY, SO GET YOUR SYSTEM // OFFLINE BEFORE RUNNING IT! diff --git a/api/src/routing.js b/api/src/routing.js index a1472e00938..1bebdcb1ff7 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -7,6 +7,7 @@ const environment = require('@medic/environment'); const resources = require('./resources'); const config = require('./config'); const db = require('./db'); +const dataContext = require('./services/data-context'); const path = require('path'); const auth = require('./auth'); const prometheusMiddleware = require('prometheus-api-metrics'); @@ -36,7 +37,9 @@ const exportData = require('./controllers/export-data'); const records = require('./controllers/records'); const forms = require('./controllers/forms'); const users = require('./controllers/users'); -const { people, places } = require('@medic/contacts')(config, db); +const person = require('./controllers/person'); +const place = require('./controllers/place'); +const { people, places } = require('@medic/contacts')(config, db, dataContext); const upgrade = require('./controllers/upgrade'); const settings = require('./controllers/settings'); const bulkDocs = require('./controllers/bulk-docs'); @@ -432,7 +435,9 @@ app.get('/api/v2/users/:username', users.v2.get); app.get('/api/v2/users', users.v2.list); app.postJson('/api/v1/users', users.create); app.postJsonOrCsv('/api/v2/users', users.v2.create); +app.postJson('/api/v3/users', users.v3.create); app.postJson('/api/v1/users/:username', users.update); +app.postJson('/api/v3/users/:username', users.v3.update); app.delete('/api/v1/users/:username', users.delete); app.get('/api/v1/users-info', authorization.handleAuthErrors, authorization.getUserSettings, users.info); @@ -462,6 +467,8 @@ app.postJson('/api/v1/places/:id', function(req, res) { .catch(err => serverUtils.error(err, req, res)); }); +app.get('/api/v1/place/:uuid', place.v1.get); + app.postJson('/api/v1/people', function(req, res) { auth .check(req, ['can_edit', 'can_create_people']) @@ -474,6 +481,8 @@ app.postJson('/api/v1/people', function(req, res) { .catch(err => serverUtils.error(err, req, res)); }); +app.get('/api/v1/person/:uuid', person.v1.get); + app.postJson('/api/v1/bulk-delete', bulkDocs.bulkDelete); // offline users are not allowed to hydrate documents via the hydrate API diff --git a/api/src/server-utils.js b/api/src/server-utils.js index 231e934e7af..a107431c8c1 100644 --- a/api/src/server-utils.js +++ b/api/src/server-utils.js @@ -130,4 +130,12 @@ module.exports = { }, wantsJSON, + + doOrError: (fn) => async (req, res) => { + try { + return await fn(req, res); + } catch (err) { + module.exports.error(err, req, res); + } + } }; diff --git a/api/src/services/authorization.js b/api/src/services/authorization.js index 0c35cec7e41..8e06cb7438a 100644 --- a/api/src/services/authorization.js +++ b/api/src/services/authorization.js @@ -75,10 +75,10 @@ const excludeSubjects = (authorizationContext, ...subjectIds) => { authorizationContext.subjectIds = _.without(authorizationContext.subjectIds, ...subjectIds); }; -// gets the depth of a contact, relative to the user's facility +// gets the depth of a contact, relative to the user's facilities const getContactDepth = (authorizationContext, contactsByDepth) => { const depthEntry = contactsByDepth.find(entry => { - return entry.key.length === 2 && entry.key[0] === authorizationContext.userCtx.facility_id; + return entry.key.length === 2 && authorizationContext.userCtx.facility_id.includes(entry.key[0]); }); return depthEntry && depthEntry.key[1]; }; @@ -187,13 +187,13 @@ const alwaysAllowCreate = doc => { const getContactsByDepthKeys = (userCtx, depth) => { const keys = []; - if (depth >= 0) { - for (let i = 0; i <= depth; i++) { - keys.push([ userCtx.facility_id, i ]); + for (const facilityId of userCtx.facility_id) { + if (depth >= 0) { + keys.push(...Array.from({ length: depth + 1 }).map((_, i) => [facilityId, i])); + } else { + // no configured depth limit + keys.push([ facilityId ]); } - } else { - // no configured depth limit - keys.push([ userCtx.facility_id ]); } return keys; @@ -361,9 +361,9 @@ const getScopedAuthorizationContext = (userCtx, scopeDocsCtx = []) => { * Method to ensure users don't see private reports submitted by their boss about the user's contact * @param {Object} userCtx User context object * @param {string} userCtx.contact_id the user's contact's uuid - * @param {string} userCtx.facility_id the user's place's uuid + * @param {[string]} userCtx.facility_id the user's places' uuids * @param {Object|undefined} userCtx.contact the user's contact - * @param {Object|undefined} userCtx.facility the user's place + * @param {Array|undefined} userCtx.facility the users' places * @param {string|undefined} subject report's subject * @param {string|undefined} submitter report's submitter * @param {boolean} isPrivate whether the report is private @@ -377,9 +377,9 @@ const isSensitive = (userCtx, subject, submitter, isPrivate, allowedSubmitter) = } const sensitiveSubjects = [ - ...registrationUtils.getSubjectIds(userCtx.facility), + ...(userCtx.facility?.map(facility => registrationUtils.getSubjectIds(facility)) || []).flat(), ...registrationUtils.getSubjectIds(userCtx.contact), - userCtx.facility_id, + ...userCtx.facility_id, userCtx.contact_id, ]; diff --git a/api/src/services/config-watcher.js b/api/src/services/config-watcher.js index 7cc585bda06..2d84bf24c20 100644 --- a/api/src/services/config-watcher.js +++ b/api/src/services/config-watcher.js @@ -14,6 +14,8 @@ const generateServiceWorker = require('../generate-service-worker'); const manifest = require('./manifest'); const config = require('../config'); const environment = require('@medic/environment'); +const dataContext = require('./data-context'); +const environment = require('../environment'); const extensionLibs = require('./extension-libs'); const MEDIC_DDOC_ID = '_design/medic'; @@ -42,7 +44,7 @@ const loadTranslations = () => { }; const initTransitionLib = () => { - const transitionsLib = require('@medic/transitions')(db, config); + const transitionsLib = require('@medic/transitions')(db, config, dataContext); // loadTransitions could throw errors when some transitions are misconfigured try { transitionsLib.loadTransitions(true); diff --git a/api/src/services/data-context.js b/api/src/services/data-context.js new file mode 100644 index 00000000000..18f46f263c2 --- /dev/null +++ b/api/src/services/data-context.js @@ -0,0 +1,5 @@ +const { getLocalDataContext } = require('@medic/cht-datasource'); +const db = require('../db'); +const config = require('../config'); + +module.exports = getLocalDataContext(config, db); diff --git a/api/tests/mocha/controllers/login.spec.js b/api/tests/mocha/controllers/login.spec.js index 204197ddf9c..a61e0657af6 100644 --- a/api/tests/mocha/controllers/login.spec.js +++ b/api/tests/mocha/controllers/login.spec.js @@ -14,7 +14,8 @@ const db = require('../../../src/db').medic; const translations = require('../../../src/translations'); const privacyPolicy = require('../../../src/services/privacy-policy'); const config = require('../../../src/config'); -const { tokenLogin, roles, users } = require('@medic/user-management')(config, db); +const dataContext = require('../../../src/services/data-context'); +const { tokenLogin, roles, users } = require('@medic/user-management')(config, db, dataContext); const template = require('../../../src/services/template'); const serverUtils = require('../../../src/server-utils'); diff --git a/api/tests/mocha/controllers/person.spec.js b/api/tests/mocha/controllers/person.spec.js new file mode 100644 index 00000000000..8d841e03793 --- /dev/null +++ b/api/tests/mocha/controllers/person.spec.js @@ -0,0 +1,158 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); +const { Person, Qualifier } = require('@medic/cht-datasource'); +const auth = require('../../../src/auth'); +const controller = require('../../../src/controllers/person'); +const dataContext = require('../../../src/services/data-context'); +const serverUtils = require('../../../src/server-utils'); + +describe('Person Controller', () => { + const userCtx = { hello: 'world' }; + let getUserCtx; + let isOnlineOnly; + let hasAllPermissions; + let dataContextBind; + let serverUtilsError; + let req; + let res; + + beforeEach(() => { + getUserCtx = sinon + .stub(auth, 'getUserCtx') + .resolves(userCtx); + isOnlineOnly = sinon.stub(auth, 'isOnlineOnly'); + hasAllPermissions = sinon.stub(auth, 'hasAllPermissions'); + dataContextBind = sinon.stub(dataContext, 'bind'); + serverUtilsError = sinon.stub(serverUtils, 'error'); + res = { + json: sinon.stub(), + }; + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + let personGet; + let personGetWithLineage; + + beforeEach(() => { + req = { + params: { uuid: 'uuid' }, + query: { } + }; + personGet = sinon.stub(); + personGetWithLineage = sinon.stub(); + dataContextBind + .withArgs(Person.v1.get) + .returns(personGet); + dataContextBind + .withArgs(Person.v1.getWithLineage) + .returns(personGetWithLineage); + }); + + afterEach(() => { + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a person', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const person = { name: 'John Doe' }; + personGet.resolves(person); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(personGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(personGetWithLineage.notCalled).to.be.true; + expect(res.json.calledOnceWithExactly(person)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('returns a person with lineage when the query parameter is set to "true"', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const person = { name: 'John Doe' }; + personGetWithLineage.resolves(person); + req.query.with_lineage = 'true'; + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Person.v1.getWithLineage)).to.be.true; + expect(personGet.notCalled).to.be.true; + expect(personGetWithLineage.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(res.json.calledOnceWithExactly(person)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('returns a person without lineage when the query parameter is set something else', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const person = { name: 'John Doe' }; + personGet.resolves(person); + req.query.with_lineage = '1'; + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(personGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(personGetWithLineage.notCalled).to.be.true; + expect(res.json.calledOnceWithExactly(person)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('returns a 404 error if person is not found', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + personGet.resolves(null); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(personGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(personGetWithLineage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly( + { status: 404, message: 'Person not found' }, + req, + res + )).to.be.true; + }); + + it('returns error if user does not have can_view_contacts permission', async () => { + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(false); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(personGet.notCalled).to.be.true; + expect(personGetWithLineage.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.get(req, res); + + expect(hasAllPermissions.notCalled).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(personGet.notCalled).to.be.true; + expect(personGetWithLineage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + }); + }); + }); +}); diff --git a/api/tests/mocha/controllers/place.spec.js b/api/tests/mocha/controllers/place.spec.js new file mode 100644 index 00000000000..8b851c24b65 --- /dev/null +++ b/api/tests/mocha/controllers/place.spec.js @@ -0,0 +1,158 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); +const { Place, Qualifier } = require('@medic/cht-datasource'); +const auth = require('../../../src/auth'); +const controller = require('../../../src/controllers/place'); +const dataContext = require('../../../src/services/data-context'); +const serverUtils = require('../../../src/server-utils'); + +describe('Place Controller', () => { + const userCtx = { hello: 'world' }; + let getUserCtx; + let isOnlineOnly; + let hasAllPermissions; + let dataContextBind; + let serverUtilsError; + let req; + let res; + + beforeEach(() => { + getUserCtx = sinon + .stub(auth, 'getUserCtx') + .resolves(userCtx); + isOnlineOnly = sinon.stub(auth, 'isOnlineOnly'); + hasAllPermissions = sinon.stub(auth, 'hasAllPermissions'); + dataContextBind = sinon.stub(dataContext, 'bind'); + serverUtilsError = sinon.stub(serverUtils, 'error'); + res = { + json: sinon.stub(), + }; + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + let placeGet; + let placeGetWithLineage; + + beforeEach(() => { + req = { + params: { uuid: 'uuid' }, + query: { } + }; + placeGet = sinon.stub(); + placeGetWithLineage = sinon.stub(); + dataContextBind + .withArgs(Place.v1.get) + .returns(placeGet); + dataContextBind + .withArgs(Place.v1.getWithLineage) + .returns(placeGetWithLineage); + }); + + afterEach(() => { + expect(getUserCtx.calledOnceWithExactly(req)).to.be.true; + expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + }); + + it('returns a place', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const place = { name: 'John Doe Castle' }; + placeGet.resolves(place); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Place.v1.get)).to.be.true; + expect(placeGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(placeGetWithLineage.notCalled).to.be.true; + expect(res.json.calledOnceWithExactly(place)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('returns a place with lineage when the query parameter is set to "true"', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const place = { name: 'John Doe Castle' }; + placeGetWithLineage.resolves(place); + req.query.with_lineage = 'true'; + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Place.v1.getWithLineage)).to.be.true; + expect(placeGet.notCalled).to.be.true; + expect(placeGetWithLineage.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(res.json.calledOnceWithExactly(place)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('returns a place without lineage when the query parameter is set something else', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + const place = { name: 'John Doe Castle' }; + placeGet.resolves(place); + req.query.with_lineage = '1'; + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Place.v1.get)).to.be.true; + expect(placeGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(placeGetWithLineage.notCalled).to.be.true; + expect(res.json.calledOnceWithExactly(place)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('returns a 404 error if place is not found', async () => { + isOnlineOnly.returns(true); + hasAllPermissions.returns(true); + placeGet.resolves(null); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Place.v1.get)).to.be.true; + expect(placeGet.calledOnceWithExactly(Qualifier.byUuid(req.params.uuid))).to.be.true; + expect(placeGetWithLineage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly( + { status: 404, message: 'Place not found' }, + req, + res + )).to.be.true; + }); + + it('returns error if user does not have can_view_contacts permission', async () => { + const error = { code: 403, message: 'Insufficient privileges' }; + isOnlineOnly.returns(true); + hasAllPermissions.returns(false); + + await controller.v1.get(req, res); + + expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(placeGet.notCalled).to.be.true; + expect(placeGetWithLineage.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.get(req, res); + + expect(hasAllPermissions.notCalled).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(placeGet.notCalled).to.be.true; + expect(placeGetWithLineage.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + }); + }); + }); +}); diff --git a/api/tests/mocha/controllers/users.spec.js b/api/tests/mocha/controllers/users.spec.js index 3b54827655b..a6a8f11bb7c 100644 --- a/api/tests/mocha/controllers/users.spec.js +++ b/api/tests/mocha/controllers/users.spec.js @@ -7,7 +7,8 @@ const serverUtils = require('../../../src/server-utils'); const purgedDocs = require('../../../src/services/purged-docs'); const config = require('../../../src/config'); const db = require('../../../src/db'); -const { roles, users } = require('@medic/user-management')(config, db); +const dataContext = require('../../../src/services/data-context'); +const { roles, users } = require('@medic/user-management')(config, db, dataContext); const replicationLimitLog = require('../../../src/services/replication-limit-log'); let req; @@ -446,7 +447,7 @@ describe('Users Controller', () => { roles.hasOnlineRole.returns(false); auth.hasAllPermissions.returns(true); const authContext = { - userCtx: { roles: ['some_role'], facility_id: req.query.facility_id }, + userCtx: { roles: ['some_role'], facility_id: [req.query.facility_id] }, contactsByDepthKeys: [['some_facility_id']], subjectIds: ['some_facility_id', 'a', 'b', 'c'] }; @@ -464,7 +465,7 @@ describe('Users Controller', () => { chai.expect(authorization.getAuthorizationContext.callCount).to.equal(1); chai.expect(authorization.getAuthorizationContext.args[0]).to.deep.equal([{ roles: ['some_role'], - facility_id: req.query.facility_id, + facility_id: [req.query.facility_id], contact_id: undefined }]); chai.expect(authorization.getDocsByReplicationKey.callCount).to.equal(1); @@ -521,7 +522,7 @@ describe('Users Controller', () => { chai.expect(authorization.getAuthorizationContext.callCount).to.equal(1); chai.expect(authorization.getAuthorizationContext.args[0]).to.deep.equal([{ roles: ['some_role'], - facility_id: req.query.facility_id, + facility_id: [req.query.facility_id], contact_id: req.query.contact_id }]); chai.expect(authorization.getDocsByReplicationKey.callCount).to.equal(1); @@ -650,7 +651,7 @@ describe('Users Controller', () => { roles.hasOnlineRole.returns(false); auth.hasAllPermissions.returns(true); const authContext = { - userCtx: { roles: ['role1', 'role2'], facility_id: req.query.facility_id }, + userCtx: { roles: ['role1', 'role2'], facility_id: [req.query.facility_id ]}, contactsByDepthKeys: [['some_facility_id']], subjectIds: ['some_facility_id', 'a', 'b', 'c'] }; @@ -664,7 +665,7 @@ describe('Users Controller', () => { return controller.info(req, res).then(() => { chai.expect(authorization.getAuthorizationContext.args[0]).to.deep.equal([{ roles: ['role1', 'role2'], - facility_id: 'some_facility_id', + facility_id: ['some_facility_id'], contact_id: undefined, }]); chai.expect(purgedDocs.getUnPurgedIds.callCount).to.equal(1); @@ -970,7 +971,7 @@ describe('Users Controller', () => { ]); chai.expect(auth.check.callCount).to.equal(1); chai.expect(auth.check.args[0]).to.deep.equal([req, ['can_edit', 'can_update_users']]); - chai.expect(auth.getUserCtx.callCount).to.equal(2); + chai.expect(auth.getUserCtx.callCount).to.equal(1); }); }); diff --git a/api/tests/mocha/migrations/extract-person-contacts.spec.js b/api/tests/mocha/migrations/extract-person-contacts.spec.js index 0d5f1de16f5..0f6abf4fb3a 100644 --- a/api/tests/mocha/migrations/extract-person-contacts.spec.js +++ b/api/tests/mocha/migrations/extract-person-contacts.spec.js @@ -2,7 +2,8 @@ const sinon = require('sinon'); const chai = require('chai'); const db = require('../../../src/db'); const config = require('../../../src/config'); -const { people, places } = require('@medic/contacts')(config, db); +const dataContext = require('../../../src/services/data-context'); +const { people, places } = require('@medic/contacts')(config, db, dataContext); const migration = require('../../../src/migrations/extract-person-contacts'); let createPerson; diff --git a/api/tests/mocha/server-utils.spec.js b/api/tests/mocha/server-utils.spec.js index 38163a3b8ed..939ecb8bb42 100644 --- a/api/tests/mocha/server-utils.spec.js +++ b/api/tests/mocha/server-utils.spec.js @@ -243,4 +243,31 @@ describe('Server utils', () => { }); }); + describe('doOrError', () => { + let serverUtilsError; + + beforeEach(() => { + serverUtilsError = sinon.stub(serverUtils, 'error'); + }); + + it('returns the function output when no error is thrown', async () => { + const fn = sinon.stub().resolves('result'); + + const result = await serverUtils.doOrError(fn)(req, res); + + chai.expect(result).to.equal('result'); + chai.expect(fn.calledOnceWithExactly(req, res)).to.be.true; + chai.expect(serverUtilsError.notCalled).to.be.true; + }); + + it('calls error when an error is thrown', async () => { + const error = new Error('error'); + const fn = sinon.stub().rejects(error); + + await serverUtils.doOrError(fn)(req, res); + + chai.expect(fn.calledOnceWithExactly(req, res)).to.be.true; + chai.expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + }); + }); }); diff --git a/api/tests/mocha/services/authorization.spec.js b/api/tests/mocha/services/authorization.spec.js index 8f6da8a2ba2..99667d8fb60 100644 --- a/api/tests/mocha/services/authorization.spec.js +++ b/api/tests/mocha/services/authorization.spec.js @@ -11,9 +11,19 @@ const { assert } = require('chai'); const userCtx = { name: 'user', contact_id: 'contact_id', - facility_id: 'facility_id', + facility_id: ['facility_id'], contact: { _id: 'contact_id', patient_id: 'contact_shortcode', name: 'contact', type: 'person' }, - facility: { _id: 'facility_id', place_id: 'facility_shortcode', name: 'health center', type: 'health_center' }, + facility: [{ _id: 'facility_id', place_id: 'facility_shortcode', name: 'health center', type: 'health_center' }], +}; +const userCtxMultiFacility = { + name: 'user', + contact_id: 'contact_id', + facility_id: ['facility1', 'facility2'], + contact: { _id: 'contact_id', patient_id: 'contact_shortcode', name: 'contact', type: 'person' }, + facility: [ + { _id: 'facility1', place_id: 'fac1', name: 'health center', type: 'health_center' }, + { _id: 'facility2', place_id: 'fac2', name: 'health center', type: 'health_center' } + ], }; const subjectIds = [1, 2, 3]; @@ -104,7 +114,7 @@ describe('Authorization service', () => { it('queries correct views with correct keys when depth is not infinite', () => { service.__get__('getDepth').returns({ contactDepth: 2, reportDepth: -1 }); return service - .getAuthorizationContext( {facility_id: 'facilityId' }) + .getAuthorizationContext( {facility_id: ['facilityId'] }) .then(() => { db.medic.query.callCount.should.equal(1); db.medic.query.args[0][0].should.equal('medic/contacts_by_depth'); @@ -115,10 +125,28 @@ describe('Authorization service', () => { }); }); + it('queries correct views with correct keys when depth is not infinite with multiple facilities', () => { + service.__get__('getDepth').returns({ contactDepth: 2, reportDepth: -1 }); + return service + .getAuthorizationContext( {facility_id: ['a', 'b', 'c'] }) + .then(() => { + db.medic.query.callCount.should.equal(1); + db.medic.query.args[0][0].should.equal('medic/contacts_by_depth'); + + db.medic.query.args[0][1].should.deep.equal({ + keys: [ + [ 'a', 0 ], [ 'a', 1 ], [ 'a', 2 ], + [ 'b', 0 ], [ 'b', 1 ], [ 'b', 2 ], + [ 'c', 0 ], [ 'c', 1 ], [ 'c', 2 ], + ] + }); + }); + }); + it('queries with correct keys when depth is infinite', () => { service.__get__('getDepth').returns({ contactDepth: -1, reportDepth: -1 }); return service - .getAuthorizationContext({ facility_id: 'facilityId' }) + .getAuthorizationContext({ facility_id: ['facilityId'] }) .then(() => { db.medic.query.callCount.should.equal(1); db.medic.query.args[0][0].should.equal('medic/contacts_by_depth'); @@ -126,6 +154,17 @@ describe('Authorization service', () => { }); }); + it('queries with correct keys when depth is infinite with multiple facilities', () => { + service.__get__('getDepth').returns({ contactDepth: -1, reportDepth: -1 }); + return service + .getAuthorizationContext({ facility_id: ['a', 'b', 'c'] }) + .then(() => { + db.medic.query.callCount.should.equal(1); + db.medic.query.args[0][0].should.equal('medic/contacts_by_depth'); + db.medic.query.args[0][1].should.deep.equal({ keys: [[ 'a' ], [ 'b' ], [ 'c' ]] }); + }); + }); + it('adds unassigned key if the user has required permissions', () => { auth.hasAllPermissions.returns(true); config.get.returns(true); @@ -146,7 +185,7 @@ describe('Authorization service', () => { auth.hasAllPermissions.returns(false); config.get.returns(false); return service - .getAuthorizationContext({ facility_id: 'aaa', name: 'peter' }) + .getAuthorizationContext({ facility_id: ['aaa'], name: 'peter' }) .then(result => { result.subjectIds.should.have.members([1, 2, '_all', 's1', 's2', 'org.couchdb.user:peter']); result.contactsByDepthKeys.should.deep.equal([['aaa', 0], ['aaa', 1], ['aaa', 2]]); @@ -158,6 +197,28 @@ describe('Authorization service', () => { }); }); + it('returns contactsByDepthKeys array, contact and report depths with multiple facilities', () => { + db.medic.query.withArgs('medic/contacts_by_depth').resolves({ + rows: [{ id: 1, key: 'key', value: 's1' }, { id: 2, key: 'key', value: 's2' }] + }); + service.__get__('getDepth').returns({ contactDepth: 2, reportDepth: -1 }); + auth.hasAllPermissions.returns(false); + config.get.returns(false); + return service + .getAuthorizationContext({ facility_id: ['a', 'b'], name: 'peter' }) + .then(result => { + result.subjectIds.should.have.members([1, 2, '_all', 's1', 's2', 'org.couchdb.user:peter']); + result.contactsByDepthKeys.should.deep.equal([ + ['a', 0], ['a', 1], ['a', 2], ['b', 0], ['b', 1], ['b', 2] + ]); + result.should.deep.include({ + contactDepth: 2, + reportDepth: -1, + subjectsDepth: {}, + }); + }); + }); + it('should compile subjectsDepth when using reportDepth', () => { db.medic.query.withArgs('medic/contacts_by_depth').resolves({ rows: [ @@ -171,7 +232,7 @@ describe('Authorization service', () => { auth.hasAllPermissions.returns(false); config.get.returns(false); return service - .getAuthorizationContext({ facility_id: 'aaa', name: 'peter' }) + .getAuthorizationContext({ facility_id: ['aaa'], name: 'peter' }) .then(result => { result.subjectIds.should.have.members(['1', '2', '3', '_all', 's1', 's2', 'org.couchdb.user:peter', 'aaa']); result.contactsByDepthKeys.should.deep.equal([['aaa', 0], ['aaa', 1], ['aaa', 2]]); @@ -190,6 +251,55 @@ describe('Authorization service', () => { }); }); + it('should compile subjectsDepth when using reportDepth and multiple facility', () => { + db.medic.query.withArgs('medic/contacts_by_depth').resolves({ + rows: [ + { id: 'aaa', key: ['aaa', 0], value: 'aaa' }, + { id: '1', key: ['aaa', 1], value: 's1' }, + { id: '2', key: ['aaa', 2], value: 's2' }, + { id: '3', key: ['aaa', 2], value: '3' }, + { id: 'bbb', key: ['bbb', 0], value: 'bbb' }, + { id: '11', key: ['bbb', 1], value: 's11' }, + { id: '22', key: ['bbb', 2], value: 's22' }, + { id: '33', key: ['bbb', 2], value: '33' }, + ] + }); + service.__get__('getDepth').returns({ contactDepth: 2, reportDepth: 1 }); + auth.hasAllPermissions.returns(false); + config.get.returns(false); + return service + .getAuthorizationContext({ facility_id: ['aaa', 'bbb'], name: 'peter' }) + .then(result => { + result.subjectIds.should.have.members([ + 'org.couchdb.user:peter', '_all', + '1', '2', '3', 's1', 's2', 'aaa', + '11', '22', '33', 's11', 's22', 'bbb', + ]); + result.contactsByDepthKeys.should.deep.equal([ + ['aaa', 0], ['aaa', 1], ['aaa', 2], + ['bbb', 0], ['bbb', 1], ['bbb', 2] + ]); + result.should.deep.include({ + contactDepth: 2, + reportDepth: 1, + subjectsDepth: { + 'aaa': 0, + '1': 1, + '2': 2, + '3': 2, + 's1': 1, + 's2': 2, + 'bbb': 0, + '11': 1, + '22': 2, + '33': 2, + 's11': 1, + 's22': 2, + }, + }); + }); + }); + it('should compile subjectsDepth when user has access to unassigned', () => { db.medic.query.withArgs('medic/contacts_by_depth').resolves({ rows: [ @@ -202,7 +312,7 @@ describe('Authorization service', () => { auth.hasAllPermissions.returns(true); config.get.returns(true); return service - .getAuthorizationContext({ facility_id: 'aaa', name: 'peter' }) + .getAuthorizationContext({ facility_id: ['aaa'], name: 'peter' }) .then(result => { result.subjectIds.should.have.members([ '1', '2', '_unassigned', '_all', 's1', 's2', 'org.couchdb.user:peter', 'aaa' @@ -223,6 +333,52 @@ describe('Authorization service', () => { }); }); + it('should compile subjectsDepth when user has access to unassigned with multiple facilities', () => { + db.medic.query.withArgs('medic/contacts_by_depth').resolves({ + rows: [ + { id: 'aaa', key: ['aaa', 0], value: 'aaa' }, + { id: '1', key: ['aaa', 1], value: 's1' }, + { id: '2', key: ['aaa', 2], value: 's2' }, + { id: 'bbb', key: ['bbb', 0], value: 'bbb' }, + { id: '11', key: ['bbb', 1], value: 's11' }, + { id: '22', key: ['bbb', 2], value: 's22' }, + ] + }); + service.__get__('getDepth').returns({ contactDepth: 3, reportDepth: 2 }); + auth.hasAllPermissions.returns(true); + config.get.returns(true); + return service + .getAuthorizationContext({ facility_id: ['aaa', 'bbb'], name: 'peter' }) + .then(result => { + result.subjectIds.should.have.members([ + '_unassigned', '_all', 'org.couchdb.user:peter', + '1', '2', 's1', 's2', 'aaa', + '11', '22', 's11', 's22', 'bbb' + ]); + result.contactsByDepthKeys.should.deep.equal([ + ['aaa', 0], ['aaa', 1], ['aaa', 2], ['aaa', 3], + ['bbb', 0], ['bbb', 1], ['bbb', 2], ['bbb', 3] + ]); + result.should.deep.include({ + contactDepth: 3, + reportDepth: 2, + subjectsDepth: { + 'aaa': 0, + '1': 1, + '2': 2, + 's1': 1, + 's2': 2, + 'bbb': 0, + '11': 1, + '22': 2, + 's11': 1, + 's22': 2, + '_unassigned': 0, + }, + }); + }); + }); + }); describe('getAllowedDocIds', () => { @@ -245,9 +401,9 @@ describe('Authorization service', () => { ]; const userCtx = { name: 'user', - facility_id: 'facility_id', + facility_id: ['facility_id'], contact_id: 'contact_id', - facility: { _id: 'facility_id', place_id: 'facility_sh' }, + facility: [{ _id: 'facility_id', place_id: 'facility_sh' }], contact: { _id: 'contact_id', patient_id: 'contact_sh' }, }; db.medic.query @@ -274,8 +430,7 @@ describe('Authorization service', () => { return service .getAllowedDocIds({ subjectIds, userCtx }) .then(result => { - result.length.should.equal(14); - result.should.deep.equal([ + result.should.have.members([ '_design/medic-client', 'org.couchdb.user:user', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'r9', 'r10', @@ -284,6 +439,55 @@ describe('Authorization service', () => { }); }); + it('merges results from view, except for sensitive, includes ddoc and user doc with multiple facilities', () => { + const subjectIds = [ + 'sbj1', 'sbj2', 'sbj3', 'sbj4', 'facility_id', 'contact_id', 'c1', 'c2', 'c3', 'c4', + 'facility_sh', 'contact_sh', + ]; + const userCtx = { + name: 'user', + facility_id: ['f1', 'f2'], + contact_id: 'contact_id', + facility: [{ _id: 'f1', place_id: 'f1_sh' }, { _id: 'f2', place_id: 'f2_sh' }], + contact: { _id: 'contact_id', patient_id: 'contact_sh' }, + }; + db.medic.query + .withArgs('medic/docs_by_replication_key') + .resolves({ rows: [ + { id: 'r1', key: 'sbj1', value: { submitter: 'c1' } }, + { id: 'r2', key: 'sbj3', value: { } }, + { id: 'r3', key: 'sbj2', value: { submitter: 'nurse'} }, + { id: 'r4', key: null, value: { submitter: 'c2' } }, + { id: 'r5', key: 'f1', value: {} }, + { id: 'r55', key: 'f2', value: {} }, + { id: 'r6', key: 'contact_id', value: {} }, + { id: 'r7', key: 'f1', value: { submitter: 'c-unknown', private: true } }, //sensitive + { id: 'r77', key: 'f2', value: { submitter: 'c-unknown', private: true } }, //sensitive + { id: 'r8', key: 'contact_id', value: { submitter: 'c-unknown', private: 'something' } }, //sensitive + { id: 'r7', key: 'f1_sh', value: { submitter: 'c-unknown', private: true } }, //sensitive + { id: 'r77', key: 'f1_sh', value: { submitter: 'c-unknown', private: true } }, //sensitive + { id: 'r8', key: 'contact_sh', value: { submitter: 'c-unknown', private: 'something' } }, //sensitive + { id: 'r9', key: 'f1', value: { submitter: 'c3' } }, + { id: 'r99', key: 'f2', value: { submitter: 'c3' } }, + { id: 'r10', key: 'contact_id', value: { submitter: 'c4' } }, + { id: 'r11', key: 'sbj3', value: { } }, + { id: 'r12', key: 'sbj4', value: { submitter: 'someone' } }, + { id: 'r13', key: false, value: { submitter: 'someone else' } }, + { id: 'r14', key: 'contact_id', value: { submitter: 'c-unknown', private: false } }, // not sensitive + ]}); + + return service + .getAllowedDocIds({ subjectIds, userCtx }) + .then(result => { + result.should.have.members([ + '_design/medic-client', 'org.couchdb.user:user', + 'r1', 'r2', 'r3', 'r4', + 'r5', 'r55', 'r6', 'r9', 'r99', 'r10', + 'r11', 'r12', 'r13', 'r14' + ]); + }); + }); + it('should not return duplicates', () => { const subjectIds = ['subject', 'contact', 'parent']; db.medic.query @@ -300,7 +504,7 @@ describe('Authorization service', () => { return service .getAllowedDocIds({ subjectIds, - userCtx: { name: 'user', facility_id: 'facility_id', contact_id: 'contact_id' } + userCtx: { name: 'user', facility_id: ['facility_id'], contact_id: 'contact_id' } }) .then(result => { result.should.deep.equal(['_design/medic-client', 'org.couchdb.user:user', 'r1', 'r2', 'r3']); @@ -325,7 +529,7 @@ describe('Authorization service', () => { return service .getAllowedDocIds({ subjectIds, - userCtx: { name: 'user', facility_id: 'facility_id', contact_id: 'contact_id' }, + userCtx: { name: 'user', facility_id: ['facility_id'], contact_id: 'contact_id' }, contactDepth: 3, reportDepth: -1, subjectsDepth: {}, @@ -359,7 +563,7 @@ describe('Authorization service', () => { return service .getAllowedDocIds({ subjectIds, - userCtx: { name: 'user', facility_id: 'facility_id', contact_id: 'contact_id' }, + userCtx: { name: 'user', facility_id: ['facility_id'], contact_id: 'contact_id' }, contactDepth: 2, reportDepth: 1, subjectsDepth: { 'parent': 0, 'contact': 1, 'subject': 2 }, @@ -391,7 +595,7 @@ describe('Authorization service', () => { return service .getAllowedDocIds({ subjectIds, - userCtx: { name: 'user', facility_id: 'parent', contact_id: 'contact' }, + userCtx: { name: 'user', facility_id: ['parent'], contact_id: 'contact' }, contactDepth: 1, reportDepth: 0, subjectsDepth: { 'parent': 0, 'contact': 1, 'place': 1 }, @@ -422,7 +626,7 @@ describe('Authorization service', () => { const ctx = { subjectIds, - userCtx: { name: 'user', facility_id: 'parent', contact_id: 'contact' }, + userCtx: { name: 'user', facility_id: ['parent'], contact_id: 'contact' }, subjectsDepth: { 'parent': 0, 'contact': 1, 'place': 1 }, }; @@ -482,7 +686,7 @@ describe('Authorization service', () => { ] }); - const ctx = { subjectIds, userCtx: { name: 'user', facility_id: 'facility_id', contact_id: 'contact_id' }}; + const ctx = { subjectIds, userCtx: { name: 'user', facility_id: ['facility_id'], contact_id: 'contact_id' }}; return service .getDocsByReplicationKey(ctx) .then(result => { @@ -504,6 +708,55 @@ describe('Authorization service', () => { }); }); + it('merges results from view, except for sensitive, includes ddoc and user doc with multi-facility', () => { + const subjectIds = [ 'sbj1', 'sbj2', 'sbj3', 'sbj4', 'f1', 'f2', 'contact_id', 'c1', 'c2', 'c3', 'c4' ]; + db.medic.query + .withArgs('medic/docs_by_replication_key') + .resolves({ + rows: [ + { id: 'r1', key: 'sbj1', value: { submitter: 'c1' } }, + { id: 'r2', key: 'sbj3', value: { } }, + { id: 'r3', key: 'sbj2', value: { submitter: 'nurse'} }, + { id: 'r4', key: null, value: { submitter: 'c2' } }, + { id: 'r5', key: 'f1', value: {} }, + { id: 'r55', key: 'f2', value: {} }, + { id: 'r6', key: 'contact_id', value: {} }, + { id: 'r7', key: 'f1', value: { submitter: 'c-unknown', private: true } }, //sensitive + { id: 'r77', key: 'f2', value: { submitter: 'c-unknown', private: true } }, //sensitive + { id: 'r8', key: 'contact_id', value: { submitter: 'c-unknown', private: 'something' } }, //sensitive + { id: 'r9', key: 'f1', value: { submitter: 'c3' } }, + { id: 'r99', key: 'f2', value: { submitter: 'c3' } }, + { id: 'r10', key: 'contact_id', value: { submitter: 'c4' } }, + { id: 'r11', key: 'sbj3', value: { } }, + { id: 'r12', key: 'sbj4', value: { submitter: 'someone' } }, + { id: 'r13', key: false, value: { submitter: 'someone else' } }, + { id: 'r14', key: 'contact_id', value: { submitter: 'c-unknown', private: false } }, // not sensitive + ] + }); + + const ctx = { subjectIds, userCtx: { name: 'user', facility_id: ['f1', 'f2'], contact_id: 'contact_id' }}; + return service + .getDocsByReplicationKey(ctx) + .then(result => { + result.should.have.deep.members([ + { id: 'r1', key: 'sbj1', value: { submitter: 'c1' } }, + { id: 'r2', key: 'sbj3', value: { } }, + { id: 'r3', key: 'sbj2', value: { submitter: 'nurse'} }, + { id: 'r4', key: null, value: { submitter: 'c2' } }, + { id: 'r5', key: 'f1', value: {} }, + { id: 'r55', key: 'f2', value: {} }, + { id: 'r6', key: 'contact_id', value: {} }, + { id: 'r9', key: 'f1', value: { submitter: 'c3' } }, + { id: 'r99', key: 'f2', value: { submitter: 'c3' } }, + { id: 'r10', key: 'contact_id', value: { submitter: 'c4' } }, + { id: 'r11', key: 'sbj3', value: { } }, + { id: 'r12', key: 'sbj4', value: { submitter: 'someone' } }, + { id: 'r13', key: false, value: { submitter: 'someone else' } }, + { id: 'r14', key: 'contact_id', value: { submitter: 'c-unknown', private: false } }, // not sensitive + ]); + }); + }); + it('should add all reports when reportDepth is not used', () => { const subjectIds = ['subject', 'contact', 'parent']; db.medic.query @@ -522,7 +775,7 @@ describe('Authorization service', () => { return service .getDocsByReplicationKey({ subjectIds, - userCtx: { name: 'user', facility_id: 'facility_id', contact_id: 'contact_id' }, + userCtx: { name: 'user', facility_id: ['facility_id'], contact_id: 'contact_id' }, contactDepth: 3, reportDepth: -1, subjectsDepth: {}, @@ -560,7 +813,7 @@ describe('Authorization service', () => { return service .getDocsByReplicationKey({ subjectIds, - userCtx: { name: 'user', facility_id: 'facility_id', contact_id: 'contact_id' }, + userCtx: { name: 'user', facility_id: ['facility_id'], contact_id: 'contact_id' }, contactDepth: 2, reportDepth: 1, subjectsDepth: { 'parent': 0, 'contact': 1, 'subject': 2 }, @@ -597,7 +850,7 @@ describe('Authorization service', () => { return service .getDocsByReplicationKey({ subjectIds, - userCtx: { name: 'user', facility_id: 'parent', contact_id: 'contact' }, + userCtx: { name: 'user', facility_id: ['parent'], contact_id: 'contact' }, contactDepth: 1, reportDepth: 0, subjectsDepth: { 'parent': 0, 'contact': 1, 'place': 1 }, @@ -613,6 +866,50 @@ describe('Authorization service', () => { ]); }); }); + + it('should check all entries for a report to verify valid depth multi-facility', () => { + const subjectIds = ['contact', 'facility1', 'facility2', 'place']; + db.medic.query + .withArgs('medic/docs_by_replication_key') + .resolves({ rows: + [ + { id: 'r1', key: 'place', value: { submitter: 'p', type: 'data_record' } }, // depth 1 + { id: 'r1', key: 'facility1', value: { submitter: 'p', type: 'data_record' } }, // depth 0 + { id: 'r11', key: 'place', value: { submitter: 'p', type: 'data_record' } }, // depth 1 + { id: 'r11', key: 'facility2', value: { submitter: 'p', type: 'data_record' } }, // depth 0 + { id: 'r2', key: 'place', value: { submitter: 'contact', type: 'data_record' } }, // depth 1 + { id: 'r2', key: 'facility1', value: { submitter: 'contact', type: 'data_record' } }, // depth 0 + { id: 'r22', key: 'place', value: { submitter: 'contact', type: 'data_record' } }, // depth 1 + { id: 'r22', key: 'facility2', value: { submitter: 'contact', type: 'data_record' } }, // depth 0 + { id: 'r3', key: 'contact', value: { submitter: 'contact', type: 'data_record' } }, // depth 1 + { id: 'r4', key: 'place', value: { submitter: 'p', type: 'data_record' } }, // depth 1 + { id: 'r5', key: 'place', value: { submitter: 'contact', type: 'data_record' } }, // depth 1 + ] + }); + + return service + .getDocsByReplicationKey({ + subjectIds, + userCtx: { name: 'user', facility_id: ['facility1', 'facility2'], contact_id: 'contact' }, + contactDepth: 1, + reportDepth: 0, + subjectsDepth: { 'facility1': 0, 'facility2': 0, 'contact': 1, 'place': 1 }, + }) + .then(result => { + result.should.have.deep.members([ + { id: 'r1', key: 'place', value: { submitter: 'p', type: 'data_record' } }, // depth 1 + { id: 'r1', key: 'facility1', value: { submitter: 'p', type: 'data_record' } }, // depth 0 + { id: 'r11', key: 'place', value: { submitter: 'p', type: 'data_record' } }, // depth 1 + { id: 'r11', key: 'facility2', value: { submitter: 'p', type: 'data_record' } }, // depth 0 + { id: 'r2', key: 'place', value: { submitter: 'contact', type: 'data_record' } }, // depth 1 + { id: 'r2', key: 'facility1', value: { submitter: 'contact', type: 'data_record' } }, // depth 0 + { id: 'r22', key: 'place', value: { submitter: 'contact', type: 'data_record' } }, // depth 1 + { id: 'r22', key: 'facility2', value: { submitter: 'contact', type: 'data_record' } }, // depth 0 + { id: 'r3', key: 'contact', value: { submitter: 'contact', type: 'data_record' } }, // depth 1 + { id: 'r5', key: 'place', value: { submitter: 'contact', type: 'data_record' } }, // depth 1 + ]); + }); + }); }); describe('filterAllowedDocIds', () => { @@ -702,7 +999,7 @@ describe('Authorization service', () => { id: 'user', type: 'user-settings', contact_id: 'contact-id', - facility_id: 'facility-id' + facility_id: ['facility-id'] }; const result = service.getViewResults(doc); result.couchDbUser.should.deep.equal(true); @@ -712,12 +1009,22 @@ describe('Authorization service', () => { describe('allowedDoc', () => { it('returns false when document does not generate a replication key', () => { service.allowedDoc(null, { userCtx }, { replicationKeys: null, contactsByDepth: null }).should.equal(false); + service + .allowedDoc(null, { userCtx: userCtxMultiFacility }, { replicationKeys: null, contactsByDepth: null }) + .should.equal(false); }); it('returns true for `allowed for all` docs', () => { service .allowedDoc(null, { userCtx }, { replicationKeys: [{ key: '_all', value: null }], contactsByDepth: null }) .should.equal(true); + service + .allowedDoc( + null, + { userCtx: userCtxMultiFacility }, + { replicationKeys: [{ key: '_all', value: null }], contactsByDepth: null } + ) + .should.equal(true); }); it('returns true when it is main ddoc or user contact', () => { @@ -731,20 +1038,37 @@ describe('Authorization service', () => { service .allowedDoc('org.couchdb.user:' + userCtx.name, { userCtx }, { replicationKeys: null, contactsByDepth: null }) .should.equal(true); + + service + .allowedDoc( + '_design/medic-client', + { userCtx: userCtxMultiFacility }, + { replicationKeys: [{ key: '_all', value: null}], contactsByDepth: null} + ) + .should.equal(true); + service + .allowedDoc( + 'org.couchdb.user:' + userCtx.name, + { userCtx: userCtxMultiFacility }, + { replicationKeys: null, contactsByDepth: null } + ) + .should.equal(true); }); describe('allowedContact', () => { + let facility; beforeEach(() => { viewResults = { replicationKeys: [{ key: 'a', value: {} }], contactsByDepth: [{ key: ['parent1'], value: 'patient_id' }], }; - feed = { userCtx, contactsByDepthKeys: [[userCtx.facility_id]], subjectIds }; + facility = userCtx.facility_id[0]; + feed = { userCtx, contactsByDepthKeys: [[facility]], subjectIds }; keysByDepth = { - 0: [[userCtx.facility_id, 0]], - 1: [[userCtx.facility_id, 0], [userCtx.facility_id, 1]], - 2: [[userCtx.facility_id, 0], [userCtx.facility_id, 1], [userCtx.facility_id, 2]], - 3: [[userCtx.facility_id, 0], [userCtx.facility_id, 1], [userCtx.facility_id, 2], [userCtx.facility_id, 3]] + 0: [[facility, 0]], + 1: [[facility, 0], [facility, 1]], + 2: [[facility, 0], [facility, 1], [facility, 2]], + 3: [[facility, 0], [facility, 1], [facility, 2], [facility, 3]] }; contact = 'contact'; }); @@ -753,18 +1077,18 @@ describe('Authorization service', () => { viewResults.contactsByDepth = [ { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 2], value: 'patient_id' } + { key: [facility], value: 'patient_id' }, { key: [facility, 2], value: 'patient_id' } ]; service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); viewResults.contactsByDepth = [ - { key: [userCtx.facility_id], value: null }, { key: [userCtx.facility_id, 0], value: null } + { key: [facility], value: null }, { key: [facility, 0], value: null } ]; service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); viewResults.contactsByDepth = [ { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 1], value: 'patient_id' } + { key: [facility], value: 'patient_id' }, { key: [facility, 1], value: 'patient_id' } ]; service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); @@ -772,7 +1096,7 @@ describe('Authorization service', () => { { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, { key: ['parent2'], value: 'patient_id' }, { key: ['parent2', 2], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 3], value: 'patient_id' } + { key: [facility], value: 'patient_id' }, { key: [facility, 3], value: 'patient_id' } ]; service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); }); @@ -799,7 +1123,7 @@ describe('Authorization service', () => { it('respects depth', () => { viewResults.contactsByDepth = [ - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 0], value: 'patient_id' }, + { key: [facility], value: 'patient_id' }, { key: [facility, 0], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' } ]; service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); @@ -812,7 +1136,7 @@ describe('Authorization service', () => { viewResults.contactsByDepth = [ { key: ['contact_id'], value: 'patient_id' }, { key: ['contact_id', 0], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 1], value: 'patient_id' }, + { key: [facility], value: 'patient_id' }, { key: [facility, 1], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 2], value: 'patient_id' } ]; service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); @@ -826,7 +1150,7 @@ describe('Authorization service', () => { viewResults.contactsByDepth = [ { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 2], value: 'patient_id' }, + { key: [facility], value: 'patient_id' }, { key: [facility, 2], value: 'patient_id' }, ]; service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); service @@ -843,7 +1167,7 @@ describe('Authorization service', () => { { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, { key: ['parent2'], value: 'patient_id' }, { key: ['parent2', 2], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 3], value: 'patient_id' }, + { key: [facility], value: 'patient_id' }, { key: [facility, 3], value: 'patient_id' }, ]; service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); service @@ -862,10 +1186,10 @@ describe('Authorization service', () => { it('should ignore report depth', () => { viewResults.contactsByDepth = [ - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 0], value: 'patient_id' }, + { key: [facility], value: 'patient_id' }, { key: [facility, 0], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, ]; - const ctx = { userCtx, subjectIds, contactsByDepthKeys: [[userCtx.facility_id]], reportDepth: 0 }; + const ctx = { userCtx, subjectIds, contactsByDepthKeys: [[facility]], reportDepth: 0 }; service.allowedDoc(contact, ctx, viewResults).should.deep.equal(true); @@ -876,7 +1200,7 @@ describe('Authorization service', () => { viewResults.contactsByDepth = [ { key: ['contact_id'], value: 'patient_id' }, { key: ['contact_id', 0], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 1], value: 'patient_id' }, + { key: [facility], value: 'patient_id' }, { key: [facility, 1], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 2], value: 'patient_id' }, ]; @@ -890,7 +1214,7 @@ describe('Authorization service', () => { viewResults.contactsByDepth = [ { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 2], value: 'patient_id' }, + { key: [facility], value: 'patient_id' }, { key: [facility, 2], value: 'patient_id' }, ]; ctx.contactsByDepthKeys = keysByDepth[0]; @@ -902,41 +1226,445 @@ describe('Authorization service', () => { }); }); - describe('allowedReport', () => { + describe('allowedContact multi-facility', () => { + let facility1; + let facility2; beforeEach(() => { - feed = { userCtx, contactsByDepthKeys: [[userCtx.facility_id]], subjectIds: [], reportDepth: -1 }; - report = 'report'; - }); - - it('returns true for reports with unknown subject and allowed submitter', () => { - feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', 'submitter' ]; - viewResults = { - replicationKeys: [{ key: false, value: { submitter: 'submitter', type: 'data_record' }}], - contactsByDepth: [], - }; - service.allowedDoc(report, feed, viewResults).should.equal(true); - }); - - it('returns false for reports with unknown subject and denied submitter', () => { - feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; viewResults = { - replicationKeys: [{ key: false, value: { submitter: 'submitter', type: 'data_record' }}], - contactsByDepth: [], + replicationKeys: [{ key: 'a', value: {} }], + contactsByDepth: [{ key: ['parent1'], value: 'patient_id' }], }; - service.allowedDoc(report, feed, viewResults).should.equal(false); - }); - - it('returns false for reports with denied subject and unknown submitter', () => { - feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; - viewResults = { - replicationKeys: [{ key: 'subject2', value: { type: 'data_record' }}], - contactsByDepth: [] + facility1 = userCtxMultiFacility.facility_id[0]; + facility2 = userCtxMultiFacility.facility_id[1]; + feed = { userCtx: userCtxMultiFacility, contactsByDepthKeys: [[facility1], [facility2]], subjectIds }; + keysByDepth = { + 0: [[facility1, 0], [facility2, 0]], + 1: [[facility1, 0], [facility1, 1], [facility2, 0], [facility2, 1]], + 2: [[facility1, 0], [facility1, 1], [facility1, 2], [facility2, 0], [facility2, 1], [facility2, 2]], + 3: [ + [facility1, 0], [facility1, 1], [facility1, 2], [facility1, 3], + [facility2, 0], [facility2, 1], [facility2, 2], [facility2, 3] + ] }; - service.allowedDoc(report, feed, viewResults).should.equal(false); + contact = 'contact'; }); - it('returns false for reports with denied subject and allowed submitter', () => { - feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; + it('returns true for valid contacts', () => { + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + { key: [facility1], value: 'patient_id' }, { key: [facility1, 2], value: 'patient_id' }, + ]; + service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); + + viewResults.contactsByDepth = [ + { key: [facility1], value: null }, { key: [facility1, 0], value: null } + ]; + service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: [facility2], value: 'patient_id' }, { key: [facility2, 1], value: 'patient_id' } + ]; + service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + { key: ['parent2'], value: 'patient_id' }, { key: ['parent2', 2], value: 'patient_id' }, + { key: [facility2], value: 'patient_id' }, { key: [facility2, 3], value: 'patient_id' } + ]; + service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); + }); + + it('returns false for not allowed contacts', () => { + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + { key: ['parent2'], value: 'patient_id' }, { key: ['parent2', 2], value: 'patient_id' } + ]; + service.allowedDoc(contact, feed, viewResults).should.equal(false); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + ]; + service.allowedDoc(contact, feed, viewResults).should.equal(false); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + ]; + service.allowedDoc(contact, feed, viewResults).should.equal(false); + }); + + it('respects depth', () => { + viewResults.contactsByDepth = [ + { key: [facility1], value: 'patient_id' }, { key: [facility1, 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' } + ]; + const ctx = { userCtx: userCtxMultiFacility, subjectIds }; + service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[0] }, viewResults) + .should.deep.equal(true); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[1] }, viewResults) + .should.deep.equal(true); + + viewResults.contactsByDepth = [ + { key: ['contact_id'], value: 'patient_id' }, { key: ['contact_id', 0], value: 'patient_id' }, + { key: [facility2], value: 'patient_id' }, { key: [facility2, 1], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 2], value: 'patient_id' } + ]; + service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[0] }, viewResults) + .should.equal(false); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[1] }, viewResults) + .should.deep.equal(true); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + { key: [facility1], value: 'patient_id' }, { key: [facility1, 2], value: 'patient_id' }, + ]; + service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[0] }, viewResults) + .should.equal(false); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[1] }, viewResults) + .should.equal(false); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[2] }, viewResults) + .should.deep.equal(true); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + { key: ['parent2'], value: 'patient_id' }, { key: ['parent2', 2], value: 'patient_id' }, + { key: [facility2], value: 'patient_id' }, { key: [facility2, 3], value: 'patient_id' }, + ]; + service.allowedDoc(contact, feed, viewResults).should.deep.equal(true); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[0] }, viewResults) + .should.equal(false); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[1] }, viewResults) + .should.equal(false); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[2] }, viewResults) + .should.equal(false); + service + .allowedDoc(contact, { ...ctx, contactsByDepthKeys: keysByDepth[3] }, viewResults) + .should.deep.equal(true); + }); + + it('should ignore report depth', () => { + viewResults.contactsByDepth = [ + { key: [facility1], value: 'patient_id' }, { key: [facility1, 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + ]; + const ctx = { + userCtx: userCtxMultiFacility, + subjectIds, + contactsByDepthKeys: [[facility1], [facility2]], + reportDepth: 0 + }; + + service.allowedDoc(contact, ctx, viewResults).should.deep.equal(true); + + ctx.contactsByDepthKeys = keysByDepth[0]; + service.allowedDoc(contact, ctx, viewResults).should.deep.equal(true); + ctx.contactsByDepthKeys = keysByDepth[1]; + service.allowedDoc(contact, ctx, viewResults).should.deep.equal(true); + + viewResults.contactsByDepth = [ + { key: ['contact_id'], value: 'patient_id' }, { key: ['contact_id', 0], value: 'patient_id' }, + { key: [facility1], value: 'patient_id' }, { key: [facility1, 1], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 2], value: 'patient_id' }, + ]; + + ctx.contactsByDepthKeys = keysByDepth[0]; + service.allowedDoc(contact, ctx, viewResults).should.deep.equal(false); + ctx.contactsByDepthKeys = keysByDepth[1]; + service.allowedDoc(contact, ctx, viewResults).should.deep.equal(true); + ctx.contactsByDepthKeys = keysByDepth[2]; + service.allowedDoc(contact, ctx, viewResults).should.deep.equal(true); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + { key: [facility2], value: 'patient_id' }, { key: [facility2, 2], value: 'patient_id' }, + ]; + + ctx.contactsByDepthKeys = keysByDepth[0]; + service.allowedDoc(contact, ctx, viewResults).should.deep.equal(false); + ctx.contactsByDepthKeys = keysByDepth[1]; + service.allowedDoc(contact, ctx, viewResults).should.deep.equal(false); + ctx.contactsByDepthKeys = keysByDepth[2]; + service.allowedDoc(contact, ctx, viewResults).should.deep.equal(true); + }); + }); + + describe('allowedReport', () => { + let facility; + beforeEach(() => { + facility = userCtx.facility_id[0]; + feed = { userCtx, contactsByDepthKeys: [[facility]], subjectIds: [], reportDepth: -1 }; + report = 'report'; + }); + + it('returns true for reports with unknown subject and allowed submitter', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', 'submitter' ]; + viewResults = { + replicationKeys: [{ key: false, value: { submitter: 'submitter', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + }); + + it('returns false for reports with unknown subject and denied submitter', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; + viewResults = { + replicationKeys: [{ key: false, value: { submitter: 'submitter', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + }); + + it('returns false for reports with denied subject and unknown submitter', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; + viewResults = { + replicationKeys: [{ key: 'subject2', value: { type: 'data_record' }}], + contactsByDepth: [] + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + }); + + it('returns false for reports with denied subject and allowed submitter', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; + viewResults = { + replicationKeys: [{ key: 'subject2', value: { submitter: 'contact', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + }); + + it('returns true for reports with allowed subject and unknown submitter', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; + viewResults = { + replicationKeys: [{ key: 'subject', value: { type: 'data_record' }}], + contactsByDepth: false, + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + }); + + it('returns true for reports with allowed subject, denied submitter and not sensitive', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; + viewResults = { + replicationKeys: [{ key: 'subject', value: { submitter: 'submitter', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + }); + + it('returns true for reports with allowed subject, allowed submitter and not sensitive', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; + viewResults = { + replicationKeys: [{ key: 'subject', value: { submitter: 'contact', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + }); + + it('returns false for reports with allowed subject, denied submitter and sensitive', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', userCtx.contact_id ]; + viewResults = { + replicationKeys: [{ + key: userCtx.contact_id, + value: { submitter: 'submitter', type: 'data_record', private: true } + }], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', userCtx.contact.patient_id ]; + viewResults = { + replicationKeys: [{ + key: userCtx.contact.patient_id, + value: { submitter: 'submitter', type: 'data_record', private: true } + }], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', ...userCtx.facility_id ]; + viewResults = { + replicationKeys: [{ + key: userCtx.facility_id, + value: { submitter: 'submitter', type: 'data_record', private: true } + }], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', userCtx.facility[0].place_id ]; + viewResults = { + replicationKeys: [{ + key: userCtx.facility[0].place_id, + value: { submitter: 'submitter', type: 'data_record', private: true } + }], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + }); + + it('returns true for reports with allowed subject, allowed submitter and about user`s contact or place', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', userCtx.contact_id ]; + viewResults = { + replicationKeys: [{ key: userCtx.contact_id, value: { submitter: 'contact', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', userCtx.contact.patient_id ]; + viewResults = { + replicationKeys: [{ key: userCtx.contact.patient_id, value: { submitter: 'contact', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', ...userCtx.facility_id ]; + viewResults = { + replicationKeys: [{ key: facility, value: { submitter: 'contact', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + + const facilityShortcode = userCtx.facility[0].place_id; + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', facilityShortcode ]; + viewResults = { + replicationKeys: [{ key: facilityShortcode, value: { submitter: 'contact', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + }); + + it('should return true for report over under replication depth', () => { + feed.reportDepth = 2; + feed.subjectIds = [ 'facility_id', 'contact_id', 'place', 'chw', 'patient' ]; + feed.subjectsDepth = { parent: 0, contact: 1, place: 1, chw: 2, patient: 3 }; + + viewResults = { + // depth 0 + replicationKeys: [{ key: facility, value: { submitter: 'contact_id', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + + viewResults = { + // depth 1 + replicationKeys: [{ key: 'place', value: { submitter: 'submitter', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + + viewResults = { + replicationKeys: [{ key: 'chw', value: { submitter: 'submitter', type: 'data_record' }}], // depth 2 + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + }); + + it('should return false for report over replication depth', () => { + feed.reportDepth = 2; + feed.subjectIds = [ 'facility_id', 'contact_id', 'place', 'chw', 'patient' ]; + feed.subjectsDepth = { facility_id: 0, contact_id: 1, place: 1, chw: 2, patient: 3 }; + + viewResults = { + replicationKeys: [{ key: 'patient', value: { submitter: 'submitter', type: 'data_record' }}], // depth 3 + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + }); + + it('should return true for report over replication depth submitted by user', () => { + feed.reportDepth = 2; + feed.subjectIds = [ 'facility_id', 'contact_id', 'place', 'chw', 'patient' ]; + feed.subjectsDepth = { facility_id: 0, contact_id: 1, place: 1, chw: 2, patient: 3 }; + + viewResults = { + replicationKeys: [{ key: 'patient', value: { submitter: 'contact_id', type: 'data_record' }}], // depth 3 + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + }); + + it('should return true for report with needs_signoff', () => { + feed.reportDepth = 0; + feed.subjectIds = [ 'facility_id', 'contact_id', 'place' ]; + feed.subjectsDepth = { facility_id: 0, contact_id: 1, place: 1 }; + + viewResults = { + replicationKeys: [ + // depth 1 + { key: 'place', value: { submitter: 'some_submitter', type: 'data_record' }}, + // depth 0 but "sensitive" + { key: 'facility_id', value: { submitter: 'some_submitter', type: 'data_record' }}, + ], + contactsByDepth: [], + }; + + service.allowedDoc(report, feed, viewResults).should.equal(true); + }); + }); + + describe('allowedReport multi-facility', () => { + let facility1; + let facility2; + beforeEach(() => { + facility1 = userCtxMultiFacility.facility_id[0]; + facility2 = userCtxMultiFacility.facility_id[2]; + feed = { + userCtx: userCtxMultiFacility, + contactsByDepthKeys: [[facility1], [facility2]], + subjectIds: [], + reportDepth: -1 + }; + report = 'report'; + }); + + it('returns true for reports with unknown subject and allowed submitter', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', 'submitter' ]; + viewResults = { + replicationKeys: [{ key: false, value: { submitter: 'submitter', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + }); + + it('returns false for reports with unknown subject and denied submitter', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; + viewResults = { + replicationKeys: [{ key: false, value: { submitter: 'submitter', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + }); + + it('returns false for reports with denied subject and unknown submitter', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; + viewResults = { + replicationKeys: [{ key: 'subject2', value: { type: 'data_record' }}], + contactsByDepth: [] + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + }); + + it('returns false for reports with denied subject and allowed submitter', () => { + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact' ]; viewResults = { replicationKeys: [{ key: 'subject2', value: { submitter: 'contact', type: 'data_record' }}], contactsByDepth: [], @@ -992,7 +1720,7 @@ describe('Authorization service', () => { }; service.allowedDoc(report, feed, viewResults).should.equal(false); - feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', userCtx.facility_id ]; + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', ...userCtx.facility_id ]; viewResults = { replicationKeys: [{ key: userCtx.facility_id, @@ -1002,10 +1730,21 @@ describe('Authorization service', () => { }; service.allowedDoc(report, feed, viewResults).should.equal(false); - feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', userCtx.facility.place_id ]; + const facility1sh = userCtxMultiFacility.facility[0].place_id; + const facility2sh = userCtxMultiFacility.facility[0].place_id; + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', facility1sh, facility2sh ]; + viewResults = { + replicationKeys: [{ + key: facility1sh, + value: { submitter: 'submitter', type: 'data_record', private: true } + }], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(false); + viewResults = { replicationKeys: [{ - key: userCtx.facility.place_id, + key: facility2sh, value: { submitter: 'submitter', type: 'data_record', private: true } }], contactsByDepth: [], @@ -1028,16 +1767,25 @@ describe('Authorization service', () => { }; service.allowedDoc(report, feed, viewResults).should.equal(true); - feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', userCtx.facility_id ]; + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', facility1, facility2 ]; + viewResults = { + replicationKeys: [{ key: facility1, value: { submitter: 'contact', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); + + const facility1sh = userCtxMultiFacility.facility[0].place_id; + const facility2sh = userCtxMultiFacility.facility[0].place_id; + + feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', facility1sh, facility2sh ]; viewResults = { - replicationKeys: [{ key: userCtx.facility_id, value: { submitter: 'contact', type: 'data_record' }}], + replicationKeys: [{ key: facility1sh, value: { submitter: 'contact', type: 'data_record' }}], contactsByDepth: [], }; service.allowedDoc(report, feed, viewResults).should.equal(true); - feed.subjectIds = [ 'subject1', 'contact1', 'subject', 'contact', userCtx.facility.place_id ]; viewResults = { - replicationKeys: [{ key: userCtx.facility.place_id, value: { submitter: 'contact', type: 'data_record' }}], + replicationKeys: [{ key: facility2sh, value: { submitter: 'contact', type: 'data_record' }}], contactsByDepth: [], }; service.allowedDoc(report, feed, viewResults).should.equal(true); @@ -1045,12 +1793,19 @@ describe('Authorization service', () => { it('should return true for report over under replication depth', () => { feed.reportDepth = 2; - feed.subjectIds = [ 'facility_id', 'contact_id', 'place', 'chw', 'patient' ]; - feed.subjectsDepth = { parent: 0, contact: 1, place: 1, chw: 2, patient: 3 }; + feed.subjectIds = [ facility1, facility2, 'contact_id', 'place', 'chw', 'patient' ]; + feed.subjectsDepth = { parent: 0, contact: 1, place: 1, chw: 2, patient: 3, [facility1]: 0, [facility2]: 0 }; + + viewResults = { + // depth 0 + replicationKeys: [{ key: facility1, value: { submitter: 'contact_id', type: 'data_record' }}], + contactsByDepth: [], + }; + service.allowedDoc(report, feed, viewResults).should.equal(true); viewResults = { // depth 0 - replicationKeys: [{ key: userCtx.facility_id, value: { submitter: 'contact_id', type: 'data_record' }}], + replicationKeys: [{ key: facility2, value: { submitter: 'contact_id', type: 'data_record' }}], contactsByDepth: [], }; service.allowedDoc(report, feed, viewResults).should.equal(true); @@ -1071,8 +1826,8 @@ describe('Authorization service', () => { it('should return false for report over replication depth', () => { feed.reportDepth = 2; - feed.subjectIds = [ 'facility_id', 'contact_id', 'place', 'chw', 'patient' ]; - feed.subjectsDepth = { facility_id: 0, contact_id: 1, place: 1, chw: 2, patient: 3 }; + feed.subjectIds = [ facility1, facility2, 'contact_id', 'place', 'chw', 'patient' ]; + feed.subjectsDepth = { [facility1]: 0, [facility2]: 0, contact_id: 1, place: 1, chw: 2, patient: 3 }; viewResults = { replicationKeys: [{ key: 'patient', value: { submitter: 'submitter', type: 'data_record' }}], // depth 3 @@ -1083,8 +1838,8 @@ describe('Authorization service', () => { it('should return true for report over replication depth submitted by user', () => { feed.reportDepth = 2; - feed.subjectIds = [ 'facility_id', 'contact_id', 'place', 'chw', 'patient' ]; - feed.subjectsDepth = { facility_id: 0, contact_id: 1, place: 1, chw: 2, patient: 3 }; + feed.subjectIds = [ facility1, facility2, 'contact_id', 'place', 'chw', 'patient' ]; + feed.subjectsDepth = { [facility1]: 0, [facility2]: 0, contact_id: 1, place: 1, chw: 2, patient: 3 }; viewResults = { replicationKeys: [{ key: 'patient', value: { submitter: 'contact_id', type: 'data_record' }}], // depth 3 @@ -1095,15 +1850,27 @@ describe('Authorization service', () => { it('should return true for report with needs_signoff', () => { feed.reportDepth = 0; - feed.subjectIds = [ 'facility_id', 'contact_id', 'place' ]; - feed.subjectsDepth = { facility_id: 0, contact_id: 1, place: 1 }; + feed.subjectIds = [ facility1, facility2, 'contact_id', 'place' ]; + feed.subjectsDepth = { [facility1]: 0, [facility2]: 0, contact_id: 1, place: 1 }; viewResults = { replicationKeys: [ // depth 1 { key: 'place', value: { submitter: 'some_submitter', type: 'data_record' }}, // depth 0 but "sensitive" - { key: 'facility_id', value: { submitter: 'some_submitter', type: 'data_record' }}, + { key: facility1, value: { submitter: 'some_submitter', type: 'data_record' }}, + ], + contactsByDepth: [], + }; + + service.allowedDoc(report, feed, viewResults).should.equal(true); + + viewResults = { + replicationKeys: [ + // depth 1 + { key: 'place', value: { submitter: 'some_submitter', type: 'data_record' }}, + // depth 0 but "sensitive" + { key: facility2, value: { submitter: 'some_submitter', type: 'data_record' }}, ], contactsByDepth: [], }; @@ -1177,14 +1944,16 @@ describe('Authorization service', () => { }); describe('updateContext', () => { + let facility; beforeEach(() => { + facility = userCtx.facility_id[0]; viewResults = { contactsByDepth: [{ key: ['parent1'], value: 'patient_id'}] }; - feed = { userCtx, contactsByDepthKeys: [[userCtx.facility_id]], subjectIds, subjectsDepth: {} }; + feed = { userCtx, contactsByDepthKeys: [[facility]], subjectIds, subjectsDepth: {} }; keysByDepth = { - 0: [[userCtx.facility_id, 0]], - 1: [[userCtx.facility_id, 0], [userCtx.facility_id, 1]], - 2: [[userCtx.facility_id, 0], [userCtx.facility_id, 1], [userCtx.facility_id, 2]], - 3: [[userCtx.facility_id, 0], [userCtx.facility_id, 1], [userCtx.facility_id, 2], [userCtx.facility_id, 3]] + 0: [[facility, 0]], + 1: [[facility, 0], [facility, 1]], + 2: [[facility, 0], [facility, 1], [facility, 2]], + 3: [[facility, 0], [facility, 1], [facility, 2], [facility, 3]] }; contact = 'contact'; }); @@ -1193,19 +1962,19 @@ describe('Authorization service', () => { viewResults.contactsByDepth = [ { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 2], value: 'patient_id' }, + { key: [facility], value: 'patient_id' }, { key: [facility, 2], value: 'patient_id' }, ]; service.updateContext(true, feed, viewResults).should.equal(true); viewResults.contactsByDepth = [ - { key: [userCtx.facility_id], value: null }, - { key: [userCtx.facility_id, 0], value: null }, + { key: [facility], value: null }, + { key: [facility, 0], value: null }, ]; service.updateContext(true, feed, viewResults).should.equal(true); viewResults.contactsByDepth = [ { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id'}, { key: [userCtx.facility_id, 1], value: 'patient_id' }, + { key: [facility], value: 'patient_id'}, { key: [facility, 1], value: 'patient_id' }, ]; service.updateContext(true, feed, viewResults).should.equal(false); @@ -1213,7 +1982,7 @@ describe('Authorization service', () => { { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, { key: ['parent2'], value: 'patient_id' }, { key: ['parent2', 2], value: 'patient_id' }, - { key: [userCtx.facility_id], value: 'patient_id' }, { key: [userCtx.facility_id, 3], value: 'patient_id' }, + { key: [facility], value: 'patient_id' }, { key: [facility, 3], value: 'patient_id' }, ]; service.updateContext(true, feed, viewResults).should.equal(false); }); @@ -1243,8 +2012,8 @@ describe('Authorization service', () => { viewResults.contactsByDepth = [ { key: ['new_contact_id'], value: 'new_patient_id' }, { key: ['new_contact_id', 0], value: 'new_patient_id' }, - { key: [userCtx.facility_id], value: 'new_patient_id' }, - { key: [userCtx.facility_id, 1], value: 'new_patient_id' } + { key: [facility], value: 'new_patient_id' }, + { key: [facility, 1], value: 'new_patient_id' } ]; service.updateContext(true, feed, viewResults).should.equal(true); @@ -1329,6 +2098,174 @@ describe('Authorization service', () => { }); }); }); + + describe('updateContext multi-facility', () => { + let facility1; + let facility2; + beforeEach(() => { + facility1 = userCtxMultiFacility.facility_id[0]; + facility2 = userCtxMultiFacility.facility_id[1]; + viewResults = { contactsByDepth: [{ key: ['parent1'], value: 'patient_id'}] }; + feed = { + userCtx: userCtxMultiFacility, + contactsByDepthKeys: [[facility1], [facility2]], + subjectIds: [1, 2, 3], + subjectsDepth: {} + }; + keysByDepth = { + 0: [[facility1, 0], [facility2, 0]], + 1: [[facility1, 0], [facility1, 1], [facility2, 0], [facility2, 1]], + 2: [[facility1, 0], [facility1, 1], [facility1, 2], [facility2, 0], [facility2, 1], [facility2, 2]], + 3: [ + [facility1, 0], [facility1, 1], [facility1, 2], [facility1, 3], + [facility2, 0], [facility2, 1], [facility2, 2], [facility2, 3] + ] + }; + contact = 'contact'; + }); + + it('returns nbr of new subjects for allowed contacts', () => { + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + { key: [facility1], value: 'patient_id' }, { key: [facility1, 2], value: 'patient_id' }, + ]; + service.updateContext(true, feed, viewResults).should.equal(true); + + viewResults.contactsByDepth = [ + { key: [facility1], value: null }, + { key: [facility1, 0], value: null }, + { key: [facility2], value: null }, + { key: [facility2, 0], value: null }, + ]; + service.updateContext(true, feed, viewResults).should.equal(true); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: [facility1], value: 'patient_id'}, { key: [facility1, 1], value: 'patient_id' }, + ]; + service.updateContext(true, feed, viewResults).should.equal(false); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + { key: ['parent2'], value: 'patient_id' }, { key: ['parent2', 2], value: 'patient_id' }, + { key: [facility2], value: 'patient_id' }, { key: [facility2, 3], value: 'patient_id' }, + ]; + service.updateContext(true, feed, viewResults).should.equal(false); + }); + + it('returns false for not allowed contacts', () => { + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + { key: ['parent2'], value: 'patient_id' }, { key: ['parent2', 2], value: 'patient_id' }, + ]; + service.updateContext(false, feed, viewResults).should.equal(false); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + ]; + service.updateContext(false, feed, viewResults).should.equal(false); + + viewResults.contactsByDepth = [ + { key: ['contact'], value: 'patient_id' }, { key: ['contact', 0], value: 'patient_id' }, + { key: ['parent1'], value: 'patient_id' }, { key: ['parent1', 1], value: 'patient_id' }, + ]; + service.updateContext(false, feed, viewResults).should.equal(false); + }); + + it('adds valid contact _id and reference to subjects list, while keeping them unique', () => { + feed.subjectIds = []; + viewResults.contactsByDepth = [ + { key: ['new_contact_id'], value: 'new_patient_id' }, + { key: ['new_contact_id', 0], value: 'new_patient_id' }, + { key: [facility1], value: 'new_patient_id' }, + { key: [facility1, 1], value: 'new_patient_id' } + ]; + + service.updateContext(true, feed, viewResults).should.equal(true); + feed.subjectIds.should.deep.equal(['new_patient_id', 'new_contact_id']); + feed.subjectsDepth.should.deep.equal({ new_patient_id: 1, new_contact_id: 1 }); + + service.updateContext(true, feed, viewResults).should.equal(false); + feed.subjectIds.should.deep.equal(['new_patient_id', 'new_contact_id']); + feed.subjectsDepth.should.deep.equal({ new_patient_id: 1, new_contact_id: 1 }); + + viewResults.contactsByDepth = [ + { key: ['second_new_contact_id'], value: 'second_patient_id' }, + { key: ['second_new_contact_id', 0], value: 'second_patient_id' }, + { key: ['parent1'], value: 'second_patient_id' }, + { key: ['parent1', 1], value: 'second_patient_id' }, + ]; + service.updateContext(false, feed, viewResults).should.equal(false); + feed.subjectIds.should.deep.equal(['new_patient_id', 'new_contact_id']); + feed.subjectsDepth.should.deep.equal({ new_patient_id: 1, new_contact_id: 1 }); + }); + + it('removes invalid contact _id and reference from subjects list', () => { + feed.subjectIds = ['person_id', 'person_id', 'contact_id', 'person_ref', 'contact_id', 'person_ref', 's']; + + viewResults.contactsByDepth = [ + { key: ['person_id'], value: 'person_ref' }, + { key: ['person_id', 0], value: 'person_ref' }, + { key: ['parent1'], value: 'person_ref' }, + { key: ['parent1', 1], value: 'person_ref' }, + ]; + + service.updateContext(false, feed, viewResults).should.equal(false); + feed.subjectIds.should.deep.equal(['contact_id', 'contact_id', 's']); + }); + + it('should assign correct depth to new subjects', () => { + feed.subjectIds = []; + + viewResults.contactsByDepth = [ + { key: ['person_id'], value: 'person_ref' }, + { key: ['person_id', 0], value: 'person_ref' }, + { key: ['clinic_id'], value: 'person_ref' }, + { key: ['clinic_id', 1], value: 'person_ref' }, + { key: ['hc_id'], value: 'person_ref' }, + { key: ['hc_id', 2], value: 'person_ref' }, + { key: [facility1], value: 'person_ref' }, + { key: [facility1, 3], value: 'person_ref' }, + ]; + service.updateContext(true, feed, viewResults).should.equal(true); + feed.subjectIds.should.have.deep.members(['person_id', 'person_ref']); + feed.subjectsDepth.should.deep.equal({ person_id: 3, person_ref: 3 }); + + viewResults.contactsByDepth = [ + { key: ['clinic_id'], value: 'clinic_ref' }, + { key: ['clinic_id', 0], value: 'clinic_ref' }, + { key: ['hc_id'], value: 'clinic_ref' }, + { key: ['hc_id', 1], value: 'clinic_ref' }, + { key: [facility2], value: 'clinic_ref' }, + { key: [facility2, 2], value: 'clinic_ref' }, + ]; + service.updateContext(true, feed, viewResults).should.equal(true); + feed.subjectIds.should.have.deep.members(['person_id', 'person_ref', 'clinic_id', 'clinic_ref']); + feed.subjectsDepth.should.deep.equal({ + person_id: 3, person_ref: 3, + clinic_id: 2, clinic_ref: 2, + }); + + viewResults.contactsByDepth = [ + { key: ['hc_id'], value: 'hc_ref' }, + { key: ['hc_id', 0], value: 'hc_ref' }, + { key: [facility2], value: 'hc_ref' }, + { key: [facility2, 1], value: 'hc_ref' }, + ]; + service.updateContext(true, feed, viewResults).should.equal(true); + feed.subjectIds.should.have.deep.members([ + 'person_id', 'person_ref', 'clinic_id', 'clinic_ref', 'hc_id', 'hc_ref' + ]); + feed.subjectsDepth.should.deep.equal({ + person_id: 3, person_ref: 3, + clinic_id: 2, clinic_ref: 2, + hc_id: 1, hc_ref: 1, + }); + }); + }); }); describe('filterAllowedDocs', () => { @@ -1354,7 +2291,7 @@ describe('Authorization service', () => { it('reiterates over remaining docs when authorization context receives new subjects', () => { const authzContext = { - userCtx: {}, + userCtx: { facility_id: ['a'] }, subjectIds: [], contactsByDepthKeys: [['a']], subjectsDepth: {}, @@ -2771,9 +3708,9 @@ describe('Authorization service', () => { it('should return false when subject is not sensitive', () => { const userCtx = { name: 'user', - facility_id: 'my_facility', + facility_id: ['my_facility'], contact_id: 'my_contact', - facility: { _id: 'my_facility', place_id: 'facility_shortcode' }, + facility: [{ _id: 'my_facility', place_id: 'facility_shortcode' }], contact: { _id: 'my_contact', patient_id: 'patient_shortcode' }, }; @@ -2791,9 +3728,9 @@ describe('Authorization service', () => { beforeEach(() => { userCtx = { name: 'user', - facility_id: 'my_facility', + facility_id: ['my_facility'], contact_id: 'my_contact', - facility: { _id: 'my_facility', place_id: 'facility_shortcode' }, + facility: [{ _id: 'my_facility', place_id: 'facility_shortcode' }], contact: { _id: 'my_contact', patient_id: 'patient_shortcode' }, }; }); diff --git a/api/tests/mocha/services/data-context.spec.js b/api/tests/mocha/services/data-context.spec.js new file mode 100644 index 00000000000..de0d5a2014c --- /dev/null +++ b/api/tests/mocha/services/data-context.spec.js @@ -0,0 +1,14 @@ +const dataSource = require('@medic/cht-datasource'); +const { expect } = require('chai'); +const db = require('../../../src/db'); +const config = require('../../../src/config'); +const dataContext = require('../../../src/services/data-context'); + +describe('Data context service', () => { + it('is initialized with the methods from the data context', () => { + const expectedDataContext = dataSource.getLocalDataContext(config, db); + + expect(dataContext.bind).is.a('function'); + expect(dataContext).excluding('bind').to.deep.equal(expectedDataContext); + }); +}); diff --git a/config/default/app_settings.json b/config/default/app_settings.json index 3eb440eaedf..29399fdd47f 100644 --- a/config/default/app_settings.json +++ b/config/default/app_settings.json @@ -283,6 +283,7 @@ "can_view_old_filter_and_search": [], "can_view_old_action_bar": [], "can_default_facility_filter": [], + "can_have_multiple_places": [], "can_export_devices_details": [ "national_admin" ] diff --git a/couchdb/docker-entrypoint.sh b/couchdb/docker-entrypoint.sh index 543911f6bf9..13164bb993a 100755 --- a/couchdb/docker-entrypoint.sh +++ b/couchdb/docker-entrypoint.sh @@ -128,6 +128,7 @@ if [ "$1" = '/opt/couchdb/bin/couchdb' ]; then chown -f couchdb:couchdb $CLUSTER_CREDENTIALS || true + # shellcheck disable=SC2145 # needs additional investigation about intention before I'm confident in changing su -c "ulimit -n 100000 && exec $@" couchdb else exec "$@" diff --git a/ddocs/.eslintrc b/ddocs/.eslintrc index fcdea031c61..c4d5c126164 100644 --- a/ddocs/.eslintrc +++ b/ddocs/.eslintrc @@ -7,6 +7,6 @@ "no-var": "off" }, "parserOptions": { - "ecmaVersion": 6, + "ecmaVersion": 5 } } diff --git a/ddocs/medic-db/medic-client/validate_doc_update.js b/ddocs/medic-db/medic-client/validate_doc_update.js index 93b7de99556..ce4bafdf834 100644 --- a/ddocs/medic-db/medic-client/validate_doc_update.js +++ b/ddocs/medic-db/medic-client/validate_doc_update.js @@ -112,7 +112,9 @@ function(newDoc, oldDoc, userCtx, secObj) { if (isDbAdmin(userCtx, secObj)) { return; } - if (userCtx.facility_id === newDoc._id) { + if (userCtx.facility_id === newDoc._id || + (Array.isArray(userCtx.facility_id) && userCtx.facility_id.includes(newDoc._id )) + ) { _err('You are not authorized to edit your own place'); } if (newDoc.type === 'form') { diff --git a/ddocs/medic-db/medic/validate_doc_update.js b/ddocs/medic-db/medic/validate_doc_update.js index 72309bb9e99..863d5e1ad59 100644 --- a/ddocs/medic-db/medic/validate_doc_update.js +++ b/ddocs/medic-db/medic/validate_doc_update.js @@ -78,7 +78,9 @@ function(newDoc, oldDoc, userCtx, secObj) { _err('You are not authorized to edit admin only docs'); } - if (userCtx.facility_id === newDoc._id) { + if (userCtx.facility_id === newDoc._id || + (Array.isArray(userCtx.facility_id) && userCtx.facility_id.includes(newDoc._id )) + ) { _err('You are not authorized to edit your own place'); } }; diff --git a/ddocs/users-db/users/views/users_by_field/map.js b/ddocs/users-db/users/views/users_by_field/map.js index a260317467c..24773065cef 100644 --- a/ddocs/users-db/users/views/users_by_field/map.js +++ b/ddocs/users-db/users/views/users_by_field/map.js @@ -3,6 +3,9 @@ function(doc) { emit(['contact_id', doc.contact_id]); } if (doc.facility_id) { - emit(['facility_id', doc.facility_id]); + var facilityIds = Array.isArray(doc.facility_id) ? doc.facility_id : [doc.facility_id]; + facilityIds.forEach(function(facilityId) { + emit(['facility_id', facilityId]); + }); } } diff --git a/ddocs/users-meta-db/users-meta/views/device_by_user/map.js b/ddocs/users-meta-db/users-meta/views/device_by_user/map.js index 425d19ebc7d..3edc3071c69 100644 --- a/ddocs/users-meta-db/users-meta/views/device_by_user/map.js +++ b/ddocs/users-meta-db/users-meta/views/device_by_user/map.js @@ -8,9 +8,12 @@ function(doc) { doc.metadata.month && doc.metadata.day ) { - const pad = number => number.toString().padStart(2, '0'); + var pad = function (number) { + return number.toString().padStart(2, '0'); + }; + emit([doc.metadata.user, doc.metadata.deviceId], { - date: `${doc.metadata.year}-${pad(doc.metadata.month)}-${pad(doc.metadata.day)}`, + date: doc.metadata.year + '-' + pad(doc.metadata.month) + '-' + pad(doc.metadata.day), id: doc._id, device: { userAgent: doc.device && doc.device.userAgent, diff --git a/ddocs/users-meta-db/users-meta/views/device_by_user/reduce.js b/ddocs/users-meta-db/users-meta/views/device_by_user/reduce.js index b84a11d566e..944557e9601 100644 --- a/ddocs/users-meta-db/users-meta/views/device_by_user/reduce.js +++ b/ddocs/users-meta-db/users-meta/views/device_by_user/reduce.js @@ -1,6 +1,6 @@ function (keys, values) { - let latest = { date: '1970-01-01' }; - values.forEach(function (value) { + var latest = { date: '1970-01-01' }; + values.forEach(function(value) { if (value.date > latest.date) { latest = value; } diff --git a/haproxy/Dockerfile b/haproxy/Dockerfile index 4584a7ec5a2..d0149d979f6 100644 --- a/haproxy/Dockerfile +++ b/haproxy/Dockerfile @@ -1,4 +1,4 @@ -FROM haproxy:2.6 +FROM haproxy:2.6.17 USER root RUN apt-get update && apt-get install luarocks gettext jq curl -y diff --git a/haproxy/tests/compose.yml b/haproxy/tests/compose.yml index a8fa4350ed0..9831aadf414 100644 --- a/haproxy/tests/compose.yml +++ b/haproxy/tests/compose.yml @@ -1,4 +1,3 @@ -version: "3.7" services: haproxy: build: diff --git a/nginx/ssl-install.sh b/nginx/ssl-install.sh index 56325651d70..542ca5b8c89 100755 --- a/nginx/ssl-install.sh +++ b/nginx/ssl-install.sh @@ -49,14 +49,14 @@ create_self_signed_ssl_certificate() mkdir -p /etc/nginx/private set_environment_variables_if_not_set openssl req -x509 -nodes -newkey rsa:4096 \ - -keyout $SSL_KEY_FILE_PATH -out $SSL_CERT_FILE_PATH -days 365 \ + -keyout "$SSL_KEY_FILE_PATH" -out "$SSL_CERT_FILE_PATH" -days 365 \ -subj "/emailAddress=$EMAIL/C=$COUNTRY/ST=$STATE/L=$LOCALITY/O=$ORGANISATION/OU=$DEPARTMENT/CN=$COMMON_NAME" return "$?" } generate_self_signed_cert(){ - if [ -f $SSL_CERT_FILE_PATH -a -f $SSL_KEY_FILE_PATH ]; then + if [ -f "$SSL_CERT_FILE_PATH" ] && [ -f "$SSL_KEY_FILE_PATH" ]; then echo "self signed SSL cert already exists." >&2 else create_self_signed_ssl_certificate \ @@ -66,7 +66,7 @@ generate_self_signed_cert(){ ensure_own_cert_exits(){ - if [ ! -f $SSL_CERT_FILE_PATH -a ! -f $SSL_KEY_FILE_PATH ]; then + if [ ! -f "$SSL_CERT_FILE_PATH" ] && [ ! -f "$SSL_KEY_FILE_PATH" ]; then echo "Please provide add your certificate ($SSL_CERT_FILE_PATH) and key ($SSL_KEY_FILE_PATH) in the /etc/nginx/private/ directory" exit 1 fi diff --git a/nginx/tests/ssl-install.bats b/nginx/tests/ssl-install.bats index c5e22a79677..598242aebab 100644 --- a/nginx/tests/ssl-install.bats +++ b/nginx/tests/ssl-install.bats @@ -1,22 +1,18 @@ +# shellcheck disable=SC2030,SC2031 # exports doesn't need to leave subshell setup() { load 'test_helper/bats-support/load' load 'test_helper/bats-assert/load' load '/app/bash-shellmock/shellmock' - # get the containing directory of this file - # use $BATS_TEST_FILENAME instead of ${BASH_SOURCE[0]} or $0, - # as those will point to the bats executable's location or the preprocessed file respectively - DIR="$( cd "$( dirname "$BATS_TEST_FILENAME" )" >/dev/null 2>&1 && pwd )" - #shellcheck + # shellcheck disable=SC1091 # not testing third party scripts . shellmock #create temp cert files base_temp_path="$TEST_TEMP_DIR/tmp/bats/etc/nginx/private" - mkdir -p $base_temp_path + mkdir -p "$base_temp_path" export SSL_CERT_FILE_PATH="$base_temp_path/cert.pem" export SSL_KEY_FILE_PATH="$base_temp_path/key.pem" - } teardown() @@ -28,7 +24,7 @@ teardown() rm -rf "$TEST_TEMP_DIR" fi - rm -rf $base_temp_path + rm -rf "$base_temp_path" } diff --git a/package-lock.json b/package-lock.json index f2f6c1b6598..8d073f2c1cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "medic", - "version": "4.9.0", + "version": "4.10.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "medic", - "version": "4.9.0", + "version": "4.10.0", "hasInstallScript": true, "license": "AGPL-3.0-only", "workspaces": [ @@ -31,6 +31,7 @@ "@commitlint/config-conventional": "^19.2.2", "@faker-js/faker": "^8.0.2", "@medic/eslint-config": "^1.1.0", + "@tsconfig/node20": "^20.1.4", "@types/chai": "^4.3.6", "@types/chai-as-promised": "^7.1.6", "@types/jquery": "^3.5.19", @@ -70,12 +71,14 @@ "eslint-plugin-compat": "^4.2.0", "eslint-plugin-couchdb": "^0.2.0", "eslint-plugin-jasmine": "^4.1.3", + "eslint-plugin-jsdoc": "^48.2.5", "eslint-plugin-json": "^3.1.0", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^6.1.1", "eurodigit": "^3.1.3", "express": "^4.19.2", + "fetch-cookie": "^3.0.1", "flat": "^6.0.0", "gaze": "^1.1.3", "husky": "^8.0.0", @@ -114,6 +117,7 @@ "rosie": "^2.1.0", "sass": "^1.67.0", "semver": "^7.5.4", + "shellcheck": "^2.2.0", "sinon": "^16.1.0", "tail": "^2.2.6", "typescript": "^5.3.3", @@ -4432,6 +4436,23 @@ "node": ">=10.0.0" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.43.0.tgz", + "integrity": "sha512-Q1CnsQrytI3TlCB1IVWXWeqUIPGVEKGaE7IbVdt13Nq/3i0JESAkQQERrfiQkmlpijl+++qyqPgaS31Bvc1jRQ==", + "dev": true, + "dependencies": { + "@types/eslint": "^8.56.5", + "@types/estree": "^1.0.5", + "@typescript-eslint/types": "^7.2.0", + "comment-parser": "1.4.1", + "esquery": "^1.5.0", + "jsdoc-type-pratt-parser": "~4.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.1.tgz", @@ -5316,8 +5337,8 @@ "resolved": "shared-libs/calendar-interval", "link": true }, - "node_modules/@medic/cht-script-api": { - "resolved": "shared-libs/cht-script-api", + "node_modules/@medic/cht-datasource": { + "resolved": "shared-libs/cht-datasource", "link": true }, "node_modules/@medic/contact-types-utils": { @@ -6875,6 +6896,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "dev": true + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -9522,6 +9549,11 @@ "node": ">=0.10.0" } }, + "node_modules/ansi-sequence-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz", + "integrity": "sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==" + }, "node_modules/ansi-styles": { "version": "3.2.1", "dev": true, @@ -9809,6 +9841,15 @@ "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", "dev": true }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -10341,7 +10382,6 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/base": { @@ -10668,6 +10708,12 @@ "dev": true, "license": "ISC" }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "dev": true + }, "node_modules/brace-expansion": { "version": "1.1.11", "dev": true, @@ -11032,6 +11078,22 @@ "isarray": "^1.0.0" } }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -11046,6 +11108,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "dev": true + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -11075,6 +11143,18 @@ "node": ">=0.2.0" } }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/builtin-status-codes": { "version": "3.0.0", "dev": true, @@ -13121,6 +13201,15 @@ "dev": true, "license": "MIT" }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/common-path-prefix": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", @@ -14481,6 +14570,25 @@ "node": ">=0.10" } }, + "node_modules/decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "dependencies": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/decompress-response": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", @@ -14493,6 +14601,190 @@ "node": ">=4" } }, + "node_modules/decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "dependencies": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar/node_modules/bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/decompress-tar/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-tar/node_modules/tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "dependencies": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "dependencies": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "dependencies": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-targz/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", + "dev": true, + "dependencies": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip/node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip/node_modules/get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "dev": true, + "dependencies": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress/node_modules/make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress/node_modules/make-dir/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -14666,11 +14958,12 @@ } }, "node_modules/define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -16510,6 +16803,63 @@ "npm": ">=6" } }, + "node_modules/eslint-plugin-jsdoc": { + "version": "48.2.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.5.tgz", + "integrity": "sha512-ZeTfKV474W1N9niWfawpwsXGu+ZoMXu4417eBROX31d7ZuOk8zyG66SO77DpJ2+A9Wa2scw/jRqBPnnQo7VbcQ==", + "dev": true, + "dependencies": { + "@es-joy/jsdoccomment": "~0.43.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "esquery": "^1.5.0", + "is-builtin-module": "^3.2.1", + "semver": "^7.6.1", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/eslint-plugin-json": { "version": "3.1.0", "dev": true, @@ -17751,15 +18101,37 @@ } }, "node_modules/fetch-cookie": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", - "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-3.0.1.tgz", + "integrity": "sha512-ZGXe8Y5Z/1FWqQ9q/CrJhkUD73DyBU9VF0hBQmEO/wPHe4A9PKTjplFDLeFX8aOsYypZUcX5Ji/eByn3VCVO3Q==", "dev": true, "dependencies": { - "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fetch-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" }, "engines": { - "node": ">=8" + "node": ">=6" + } + }, + "node_modules/fetch-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" } }, "node_modules/figgy-pudding": { @@ -17793,6 +18165,15 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "dev": true, @@ -18700,6 +19081,50 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -18733,6 +19158,22 @@ "node": ">=4" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -19986,6 +20427,21 @@ "version": "1.1.6", "license": "MIT" }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-callable": { "version": "1.2.3", "dev": true, @@ -20201,6 +20657,12 @@ "xtend": "^4.0.0" } }, + "node_modules/is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", + "dev": true + }, "node_modules/is-negative-zero": { "version": "2.0.1", "dev": true, @@ -21339,6 +21801,15 @@ "node": ">=12.0.0" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/jsdoc/node_modules/escape-string-regexp": { "version": "2.0.0", "dev": true, @@ -21471,8 +21942,7 @@ "node_modules/jsonc-parser": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==" }, "node_modules/jsonfile": { "version": "4.0.0", @@ -23073,6 +23543,11 @@ "integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=", "dev": true }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==" + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -23226,6 +23701,30 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -27620,6 +28119,18 @@ "node-fetch": "2.6.7" } }, + "node_modules/pouchdb-fetch/node_modules/fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "dev": true, + "dependencies": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pouchdb-json": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/pouchdb-json/-/pouchdb-json-7.3.1.tgz", @@ -27691,6 +28202,18 @@ "npm": ">=8.3.1" } }, + "node_modules/pouchdb-session-authentication/node_modules/fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "dev": true, + "dependencies": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pouchdb-session-authentication/node_modules/pouchdb-fetch": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/pouchdb-fetch/-/pouchdb-fetch-8.0.1.tgz", @@ -28089,6 +28612,12 @@ "node": ">=0.4.x" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -29049,6 +29578,29 @@ "inherits": "^2.0.1" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/roarr/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true + }, "node_modules/robots-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-2.4.0.tgz", @@ -29298,6 +29850,19 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "dependencies": { + "commander": "^2.8.1" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -29332,6 +29897,12 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true + }, "node_modules/semver-diff": { "version": "3.1.1", "dev": true, @@ -29554,6 +30125,12 @@ "dev": true, "license": "ISC" }, + "node_modules/set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, "node_modules/set-function-length": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", @@ -29667,6 +30244,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shellcheck": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/shellcheck/-/shellcheck-2.2.0.tgz", + "integrity": "sha512-rMt0WhmeqRrKMUqyTlkL6pd0zY27FRQMQWjQhpHMQETwG2ykc8gz+QGGtxHym4R2np646QgQAcq04sAEo3SWhA==", + "dev": true, + "dependencies": { + "decompress": "^4.2.1", + "global-agent": "^3.0.0" + }, + "bin": { + "shellcheck": "bin/shellcheck.js" + }, + "engines": { + "node": ">=18.4.0 || >=16.17.0" + } + }, "node_modules/shelljs": { "version": "0.7.8", "dev": true, @@ -29684,6 +30277,17 @@ "node": ">=0.11.0" } }, + "node_modules/shiki": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.7.tgz", + "integrity": "sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==", + "dependencies": { + "ansi-sequence-parser": "^1.1.0", + "jsonc-parser": "^3.2.0", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^8.0.0" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -30846,6 +31450,15 @@ "node": ">=4" } }, + "node_modules/strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "dependencies": { + "is-natural-number": "^4.0.1" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -31346,6 +31959,12 @@ "dev": true, "license": "MIT" }, + "node_modules/to-buffer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", + "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", + "dev": true + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -31696,11 +32315,63 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typedoc": { + "version": "0.25.13", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz", + "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==", + "dependencies": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.3", + "shiki": "^0.14.7" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/typescript": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -32178,6 +32849,16 @@ "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/url-parse-lax": { "version": "3.0.0", "dev": true, @@ -32896,6 +33577,16 @@ "dev": true, "license": "MIT" }, + "node_modules/vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==" + }, + "node_modules/vscode-textmate": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", + "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==" + }, "node_modules/vscode-uri": { "version": "3.0.2", "dev": true, @@ -35322,10 +36013,26 @@ "moment": "^2.29.1" } }, - "shared-libs/cht-script-api": { - "name": "@medic/cht-script-api", + "shared-libs/cht-datasource": { + "name": "@medic/cht-datasource", "version": "1.0.0", - "license": "Apache-2.0" + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@medic/contact-types-utils": "file:../contact-types-utils", + "@medic/logger": "file:../logger", + "typedoc": "^0.25.13" + } + }, + "shared-libs/cht-datasource2": { + "name": "@medic/cht-datasource2", + "version": "1.0.0", + "extraneous": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@medic/cht-datasource": "file:../cht-datasource" + } }, "shared-libs/contact-types-utils": { "name": "@medic/contact-types-utils", @@ -35337,6 +36044,7 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { + "@medic/cht-datasource": "file:../cht-datasource", "@medic/contact-types-utils": "file:../contact-types-utils", "@medic/lineage": "file:../lineage", "lodash": "^4.17.21", @@ -35495,6 +36203,7 @@ "version": "1.1.1", "license": "Apache-2.0", "dependencies": { + "@medic/cht-datasource": "file:../cht-datasource", "@medic/contact-types-utils": "file:../contact-types-utils", "@medic/contacts": "file:../contacts", "@medic/couch-request": "file:../couch-request", @@ -35541,6 +36250,7 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { + "@medic/cht-datasource": "file:../cht-datasource", "@medic/contacts": "file:../contacts", "@medic/lineage": "file:../lineage", "@medic/phone-number": "file:../phone-number", @@ -38563,6 +39273,20 @@ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true }, + "@es-joy/jsdoccomment": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.43.0.tgz", + "integrity": "sha512-Q1CnsQrytI3TlCB1IVWXWeqUIPGVEKGaE7IbVdt13Nq/3i0JESAkQQERrfiQkmlpijl+++qyqPgaS31Bvc1jRQ==", + "dev": true, + "requires": { + "@types/eslint": "^8.56.5", + "@types/estree": "^1.0.5", + "@typescript-eslint/types": "^7.2.0", + "comment-parser": "1.4.1", + "esquery": "^1.5.0", + "jsdoc-type-pratt-parser": "~4.0.0" + } + }, "@esbuild/aix-ppc64": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.1.tgz", @@ -39104,8 +39828,13 @@ "moment": "^2.29.1" } }, - "@medic/cht-script-api": { - "version": "file:shared-libs/cht-script-api" + "@medic/cht-datasource": { + "version": "file:shared-libs/cht-datasource", + "requires": { + "@medic/contact-types-utils": "file:../contact-types-utils", + "@medic/logger": "file:../logger", + "typedoc": "^0.25.13" + } }, "@medic/contact-types-utils": { "version": "file:shared-libs/contact-types-utils" @@ -39113,6 +39842,7 @@ "@medic/contacts": { "version": "file:shared-libs/contacts", "requires": { + "@medic/cht-datasource": "file:../cht-datasource", "@medic/contact-types-utils": "file:../contact-types-utils", "@medic/lineage": "file:../lineage", "lodash": "^4.17.21", @@ -39239,6 +39969,7 @@ "@medic/transitions": { "version": "file:shared-libs/transitions", "requires": { + "@medic/cht-datasource": "file:../cht-datasource", "@medic/contact-types-utils": "file:../contact-types-utils", "@medic/contacts": "file:../contacts", "@medic/couch-request": "file:../couch-request", @@ -39292,6 +40023,7 @@ "@medic/user-management": { "version": "file:shared-libs/user-management", "requires": { + "@medic/cht-datasource": "file:../cht-datasource", "@medic/contacts": "file:../contacts", "@medic/lineage": "file:../lineage", "@medic/phone-number": "file:../phone-number", @@ -40245,6 +40977,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "dev": true + }, "@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -42316,6 +43054,11 @@ "version": "2.1.1", "dev": true }, + "ansi-sequence-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz", + "integrity": "sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==" + }, "ansi-styles": { "version": "3.2.1", "dev": true, @@ -42510,6 +43253,12 @@ "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", "dev": true }, + "are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true + }, "arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -42904,8 +43653,7 @@ "dev": true }, "balanced-match": { - "version": "1.0.2", - "dev": true + "version": "1.0.2" }, "base": { "version": "0.11.2", @@ -43139,6 +43887,12 @@ "version": "1.0.0", "dev": true }, + "boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "dev": true + }, "brace-expansion": { "version": "1.1.11", "dev": true, @@ -43432,6 +44186,22 @@ "isarray": "^1.0.0" } }, + "buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "requires": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true + }, "buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -43442,6 +44212,12 @@ "version": "1.0.1", "dev": true }, + "buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "dev": true + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -43464,6 +44240,12 @@ "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", "dev": true }, + "builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true + }, "builtin-status-codes": { "version": "3.0.0", "dev": true @@ -44947,6 +45729,12 @@ "version": "2.20.3", "dev": true }, + "comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true + }, "common-path-prefix": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", @@ -45921,6 +46709,47 @@ "version": "0.2.0", "dev": true }, + "decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "requires": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true + } + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true + } + } + }, "decompress-response": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", @@ -45930,6 +46759,132 @@ "mimic-response": "^1.0.0" } }, + "decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "requires": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "dependencies": { + "bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true + }, + "tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "requires": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + } + } + } + }, + "decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "dependencies": { + "file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true + } + } + }, + "decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "dependencies": { + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true + } + } + }, + "decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", + "dev": true, + "requires": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "dependencies": { + "file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "dev": true + }, + "get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "dev": true, + "requires": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true + } + } + }, "deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -46053,11 +47008,12 @@ "dev": true }, "define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "requires": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } @@ -47591,6 +48547,47 @@ "integrity": "sha512-q8j8KnLH/4uwmPELFZvEyfEcuCuGxXScJaRdqHjOjz064GcfX6aoFbzy5VohZ5QYk2+WvoqMoqDSb9nRLf89GQ==", "dev": true }, + "eslint-plugin-jsdoc": { + "version": "48.2.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.5.tgz", + "integrity": "sha512-ZeTfKV474W1N9niWfawpwsXGu+ZoMXu4417eBROX31d7ZuOk8zyG66SO77DpJ2+A9Wa2scw/jRqBPnnQo7VbcQ==", + "dev": true, + "requires": { + "@es-joy/jsdoccomment": "~0.43.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "esquery": "^1.5.0", + "is-builtin-module": "^3.2.1", + "semver": "^7.6.1", + "spdx-expression-parse": "^4.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true + }, + "spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + } + } + }, "eslint-plugin-json": { "version": "3.1.0", "dev": true, @@ -48254,12 +49251,33 @@ } }, "fetch-cookie": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", - "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-3.0.1.tgz", + "integrity": "sha512-ZGXe8Y5Z/1FWqQ9q/CrJhkUD73DyBU9VF0hBQmEO/wPHe4A9PKTjplFDLeFX8aOsYypZUcX5Ji/eByn3VCVO3Q==", "dev": true, "requires": { - "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + }, + "dependencies": { + "tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + } + }, + "universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true + } } }, "figgy-pudding": { @@ -48282,6 +49300,12 @@ "flat-cache": "^3.0.4" } }, + "file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "dev": true + }, "file-uri-to-path": { "version": "1.0.0", "dev": true, @@ -48944,6 +49968,37 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, + "global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "requires": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "dependencies": { + "serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "requires": { + "type-fest": "^0.13.1" + } + }, + "type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true + } + } + }, "global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -48967,6 +50022,16 @@ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true }, + "globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "requires": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + } + }, "globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -49838,6 +50903,15 @@ "is-buffer": { "version": "1.1.6" }, + "is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "requires": { + "builtin-modules": "^3.3.0" + } + }, "is-callable": { "version": "1.2.3", "dev": true @@ -49975,6 +51049,12 @@ "xtend": "^4.0.0" } }, + "is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", + "dev": true + }, "is-negative-zero": { "version": "2.0.1", "dev": true @@ -50798,6 +51878,12 @@ } } }, + "jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true + }, "jsesc": { "version": "2.5.2", "dev": true @@ -50865,8 +51951,7 @@ "jsonc-parser": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==" }, "jsonfile": { "version": "4.0.0", @@ -52095,6 +53180,11 @@ "integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=", "dev": true }, + "lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==" + }, "magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -52210,6 +53300,23 @@ "version": "1.2.2", "dev": true }, + "matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "requires": { + "escape-string-regexp": "^4.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + } + } + }, "md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -55468,6 +56575,17 @@ "abort-controller": "3.0.0", "fetch-cookie": "0.11.0", "node-fetch": "2.6.7" + }, + "dependencies": { + "fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "dev": true, + "requires": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + } + } } }, "pouchdb-json": { @@ -55537,6 +56655,15 @@ "pouchdb-fetch": "^8.0.1" }, "dependencies": { + "fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "dev": true, + "requires": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + } + }, "pouchdb-fetch": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/pouchdb-fetch/-/pouchdb-fetch-8.0.1.tgz", @@ -55845,6 +56972,12 @@ "version": "0.2.1", "dev": true }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -56551,6 +57684,28 @@ "inherits": "^2.0.1" } }, + "roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "requires": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "dependencies": { + "sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true + } + } + }, "robots-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-2.4.0.tgz", @@ -56710,6 +57865,15 @@ } } }, + "seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "requires": { + "commander": "^2.8.1" + } + }, "select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -56744,6 +57908,12 @@ } } }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true + }, "semver-diff": { "version": "3.1.1", "dev": true, @@ -56924,6 +58094,12 @@ "version": "2.0.0", "dev": true }, + "set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, "set-function-length": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", @@ -57008,6 +58184,16 @@ "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", "dev": true }, + "shellcheck": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/shellcheck/-/shellcheck-2.2.0.tgz", + "integrity": "sha512-rMt0WhmeqRrKMUqyTlkL6pd0zY27FRQMQWjQhpHMQETwG2ykc8gz+QGGtxHym4R2np646QgQAcq04sAEo3SWhA==", + "dev": true, + "requires": { + "decompress": "^4.2.1", + "global-agent": "^3.0.0" + } + }, "shelljs": { "version": "0.7.8", "dev": true, @@ -57017,6 +58203,17 @@ "rechoir": "^0.6.2" } }, + "shiki": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.7.tgz", + "integrity": "sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==", + "requires": { + "ansi-sequence-parser": "^1.1.0", + "jsonc-parser": "^3.2.0", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^8.0.0" + } + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -57906,6 +59103,15 @@ "version": "3.0.0", "dev": true }, + "strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "requires": { + "is-natural-number": "^4.0.1" + } + }, "strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -58274,6 +59480,12 @@ "version": "1.0.1", "dev": true }, + "to-buffer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", + "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", + "dev": true + }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -58510,11 +59722,44 @@ "is-typedarray": "^1.0.0" } }, + "typedoc": { + "version": "0.25.13", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz", + "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==", + "requires": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.3", + "shiki": "^0.14.7" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==" + }, + "minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "typescript": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==" }, "typify-parser": { "version": "1.1.0", @@ -58845,6 +60090,16 @@ "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" }, + "url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "url-parse-lax": { "version": "3.0.0", "dev": true, @@ -59221,6 +60476,16 @@ "version": "5.0.0", "dev": true }, + "vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==" + }, + "vscode-textmate": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", + "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==" + }, "vscode-uri": { "version": "3.0.2", "dev": true diff --git a/package.json b/package.json index 247c87a8e22..a50740babaa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "medic", - "version": "4.9.0", + "version": "4.10.0", "private": true, "license": "AGPL-3.0-only", "repository": { @@ -19,14 +19,14 @@ "prepare": "husky install", "-- DEV BUILD SCRIPTS": "-----------------------------------------------------------------------------------------------", "build-ddocs": "mkdir -p build/ddocs && cp -r ddocs/* build/ddocs/ && node scripts/build/cli setDdocsVersion && node scripts/build/cli setBuildInfo && node ./scripts/build/ddoc-compile.js primary && mkdir -p api/build/ddocs && cp build/ddocs/*.json api/build/ddocs/", - "build-dev": "./scripts/build/build-prepare.sh && npm run build-webapp-dev && ./scripts/build/copy-static-files.sh", - "build-dev-watch": "npm run build-dev && cd webapp && npm run build -- --configuration=development --watch=true & node ./scripts/build/watch.js", + "build-dev": "./scripts/build/build-prepare.sh && npm run --prefix shared-libs/cht-datasource build && npm run build-webapp-dev && ./scripts/build/copy-static-files.sh", + "build-dev-watch": "npm run build-dev && (npm run --prefix shared-libs/cht-datasource build-watch & npm run --prefix webapp build-watch & node ./scripts/build/watch.js)", "build-documentation": "./scripts/build/build-documentation.sh", "build-webapp-dev": "cd webapp && npm run build -- --configuration=development && npm run compile", "build-cht-form": "./scripts/build/build-prepare.sh && cd webapp && npm run build:cht-form", "copy-api-resources": "cp -r api/src/public/* api/build/static/", - "dev-api": "./scripts/build/copy-static-files.sh && TZ=UTC nodemon --inspect=0.0.0.0:9229 --ignore 'api/build/static' --ignore 'api/build/public' --watch api --watch 'shared-libs/**/src/**' api/server.js -- --allow-cors", - "dev-sentinel": "TZ=UTC nodemon --inspect=0.0.0.0:9228 --watch sentinel --watch 'shared-libs/**/src/**' sentinel/server.js", + "dev-api": "./scripts/build/copy-static-files.sh && (npm run --prefix shared-libs/cht-datasource build-watch & npm run --prefix api run-watch)", + "dev-sentinel": "npm run --prefix shared-libs/cht-datasource build-watch & npm run --prefix sentinel run-watch", "local-images": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && node scripts/build/cli localDockerComposeFiles", "update-service-worker": "node scripts/build/cli updateServiceWorker", "-- DEV TEST SCRIPTS": "-----------------------------------------------------------------------------------------------", @@ -36,7 +36,9 @@ "integration-all-k3d-local": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && npm run ci-integration-all-k3d", "integration-sentinel-k3d-local": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && npm run ci-integration-sentinel-k3d", "integration-cht-form": "wdio run ./tests/integration/cht-form/wdio.conf.js", - "lint": "eslint --color --cache . && ./scripts/build/blank-link-check.sh", + "lint": "eslint --color --cache . && ./scripts/build/blank-link-check.sh && npm run lint-shell", + "lint-shell": "shellcheck $(./scripts/ci/list-shellscripts.sh)", + "lint-translations": "node scripts/ci/lint-translations.js", "test": "npm run lint && npm run unit && npm run integration-api", "unit": "node scripts/build/cli npmCiModules && npm run unit-webapp && npm run unit-admin && npm run unit-shared-lib && npm run unit-api && npm run unit-sentinel", "unit-admin": "node ./scripts/ci/run-karma.js", @@ -52,7 +54,7 @@ "wdio-local": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && wdio run ./tests/e2e/default/wdio.conf.js", "-- CI SCRIPTS ": "-----------------------------------------------------------------------------------------------", "build": "./scripts/build/build-ci.sh", - "ci-compile": "node scripts/ci/check-versions.js && node scripts/build/cli npmCiModules && npm run lint && npm run build && npm run integration-api && npm run unit && npm run unit-nginx && npm run unit-haproxy && npm run unit-haproxy-healthcheck", + "ci-compile": "node scripts/ci/check-versions.js && node scripts/build/cli npmCiModules && npm run lint && npm run unit-nginx && npm run unit-haproxy && npm run unit-haproxy-healthcheck && npm run build && npm run integration-api && npm run unit", "ci-integration-all": "mocha --config tests/integration/.mocharc-all.js", "ci-integration-all-k3d": "mocha --config tests/integration/.mocharc-k3d.js", "ci-integration-sentinel": "mocha --config tests/integration/.mocharc-sentinel.js", @@ -79,6 +81,7 @@ "@commitlint/config-conventional": "^19.2.2", "@faker-js/faker": "^8.0.2", "@medic/eslint-config": "^1.1.0", + "@tsconfig/node20": "^20.1.4", "@types/chai": "^4.3.6", "@types/chai-as-promised": "^7.1.6", "@types/jquery": "^3.5.19", @@ -118,12 +121,14 @@ "eslint-plugin-compat": "^4.2.0", "eslint-plugin-couchdb": "^0.2.0", "eslint-plugin-jasmine": "^4.1.3", + "eslint-plugin-jsdoc": "^48.2.5", "eslint-plugin-json": "^3.1.0", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^6.1.1", "eurodigit": "^3.1.3", "express": "^4.19.2", + "fetch-cookie": "^3.0.1", "flat": "^6.0.0", "gaze": "^1.1.3", "husky": "^8.0.0", @@ -162,6 +167,7 @@ "rosie": "^2.1.0", "sass": "^1.67.0", "semver": "^7.5.4", + "shellcheck": "^2.2.0", "sinon": "^16.1.0", "tail": "^2.2.6", "typescript": "^5.3.3", diff --git a/scripts/add-local-ip-certs-to-docker-4.x.sh b/scripts/add-local-ip-certs-to-docker-4.x.sh index 57de9488f7b..250db10c764 100755 --- a/scripts/add-local-ip-certs-to-docker-4.x.sh +++ b/scripts/add-local-ip-certs-to-docker-4.x.sh @@ -47,13 +47,13 @@ then exit fi -status=$(docker inspect --format="{{.State.Running}}" $container 2> /dev/null) +status=$(docker inspect --format="{{.State.Running}}" "$container" 2> /dev/null) if [ "$status" = "true" ]; then result="" if [ "$action" = "refresh" ]; then result="downloaded fresh local-ip.medicmobile.org" - docker exec -it $container bash -c "curl -s -o /etc/nginx/private/cert.pem https://local-ip.medicmobile.org/fullchain" - docker exec -it $container bash -c "curl -s -o /etc/nginx/private/key.pem https://local-ip.medicmobile.org/key" + docker exec -it "$container" bash -c "curl -s -o /etc/nginx/private/cert.pem https://local-ip.medicmobile.org/fullchain" + docker exec -it "$container" bash -c "curl -s -o /etc/nginx/private/key.pem https://local-ip.medicmobile.org/key" elif [ "$action" = "expire" ]; then result="installed expired local-ip.medicmobile.org" docker cp ./tls_certificates/local-ip-expired.crt "$container":/etc/nginx/private/cert.pem @@ -65,7 +65,7 @@ if [ "$status" = "true" ]; then fi if [ "$result" != "" ]; then - docker restart $container + docker restart "$container" echo "" echo "If just container name is shown above, a fresh local-ip.medicmobile.org certificate was ${result}." echo "" diff --git a/scripts/build/blank-link-check.sh b/scripts/build/blank-link-check.sh index dafdff464ca..fd6f9b1ef1b 100755 --- a/scripts/build/blank-link-check.sh +++ b/scripts/build/blank-link-check.sh @@ -6,7 +6,7 @@ echo "Checking for dangerous _blank links..." if (git grep -E 'target\\?="_blank"' -- webapp/src admin/src | grep -Ev 'target\\?="_blank" rel\\?="noopener noreferrer"' | grep -Ev '^\\s*//'); then echo 'ERROR: Links found with target="_blank" but no rel="noopener noreferrer" set. Please add required rel attribute.' - exit -1; + exit 1; else echo 'No dangerous links found'; fi diff --git a/scripts/build/watch.js b/scripts/build/watch.js index e5a108eb89c..47b618ac4f7 100644 --- a/scripts/build/watch.js +++ b/scripts/build/watch.js @@ -1,12 +1,14 @@ const Gaze = require('gaze').Gaze; const { spawn } = require('child_process'); -const rootdir = __dirname + '/../../'; +const path = require('path'); +const rootdir = path.resolve(__dirname, '../../'); const watchers = []; const DEBOUNCE = 10; // 10 ms const GAZE_OPTIONS = { - interval: 1000 // how often the target should be polled in milliseconds + interval: 1000, // how often the target should be polled in milliseconds + cwd: rootdir }; const configs = [ diff --git a/scripts/ci/lint-translations.js b/scripts/ci/lint-translations.js new file mode 100644 index 00000000000..68d5f6fd38e --- /dev/null +++ b/scripts/ci/lint-translations.js @@ -0,0 +1,36 @@ +const { checkTranslations, TranslationException } = require('@medic/translation-checker'); + +const SUPPORTED_LANGUAGES = [ 'en', 'es', 'fr', /*'ne',*/ 'sw' ]; // add ne once all missing translations added +const TRANSLATION_DIR = `${__dirname}/../../api/resources/translations`; +const TRANSLATION_OPTIONS = { + checkPlaceholders: true, + checkEmpties: true, + checkMessageformat: true, + checkMissing: true, + languages: SUPPORTED_LANGUAGES +}; + +const handleError = (e) => { + if (e instanceof TranslationException && !!e.errors) { + for (const err of e.errors) { + console.error(err.message); + } + return 1; + } + console.error(e); + return 2; +}; + +const run = async () => { + try { + const files = await checkTranslations(TRANSLATION_DIR, TRANSLATION_OPTIONS); + console.log(`Files checked: ${files}`); + console.log('Linting translation files passed'); + } catch (e) { + const exitCode = handleError(e); + process.exit(exitCode); + } +}; + +console.log('Linting translation files...'); +run(); diff --git a/scripts/ci/list-shellscripts.sh b/scripts/ci/list-shellscripts.sh new file mode 100755 index 00000000000..2b1942f06ae --- /dev/null +++ b/scripts/ci/list-shellscripts.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -eu -o pipefail +unset IFS + +function filter_ignored_files { + case $1 in + # Ignore third party packages + scripts/docker-helper/simple_curses.sh) ;; + */test_helper/bats-assert/*) ;; + */test_helper/bats-support/*) ;; + # Return the rest + *) + echo "$1" + ;; + esac +} + +for f in $(git ls-files); do + case $f in + *.bats | *.sh | *.bash) + filter_ignored_files "$f" + ;; + *) + # Check for shebang containing "bash" for scripts missing extension + if head -n1 "$f" | grep -Eq '#!/(.)+bash'; then + filter_ignored_files "$f" + fi + ;; + esac +done diff --git a/scripts/compress_and_archive_docker_logs.sh b/scripts/compress_and_archive_docker_logs.sh index 2c1896a6503..ceba682512c 100755 --- a/scripts/compress_and_archive_docker_logs.sh +++ b/scripts/compress_and_archive_docker_logs.sh @@ -44,7 +44,7 @@ docker ps> ${tmp}/docker_ps.log docker ps --format '{{ .Names }}' | xargs -I % sh -c "docker logs --since ${HOURS}h % > ${tmp}/%.log 2>&1" cd /tmp/cht-docker-log-tmp -tar -czf ${log_archive} * +tar -czf "${log_archive}" ./* rm /tmp/cht-docker-log-tmp/* diff --git a/scripts/deploy/cht-deploy b/scripts/deploy/cht-deploy index cf654d6ffd3..4b120b0e3a2 100755 --- a/scripts/deploy/cht-deploy +++ b/scripts/deploy/cht-deploy @@ -37,4 +37,5 @@ if [[ $1 != "-f" || -z $2 ]]; then fi # Pass command line arguments to invoke script +# shellcheck disable=SC2068 # wontfix script will be replaced "soon" invoke install $@ diff --git a/scripts/deploy/troubleshooting/describe-deployment b/scripts/deploy/troubleshooting/describe-deployment index 3da80b89817..c6f3c0aa661 100755 --- a/scripts/deploy/troubleshooting/describe-deployment +++ b/scripts/deploy/troubleshooting/describe-deployment @@ -20,9 +20,7 @@ fi NAMESPACE=$1 DEPLOYMENT=$2 -kubectl -n $NAMESPACE describe deployment $DEPLOYMENT - -if [ $? -ne 0 ]; then +if ! kubectl -n "$NAMESPACE" describe deployment "$DEPLOYMENT" ; then echo "An error occurred while trying to describe deployment $DEPLOYMENT in namespace $NAMESPACE. Please verify that the deployment and namespace exist and that you have permissions to view their contents." exit 1 fi diff --git a/scripts/deploy/troubleshooting/list-all-resources b/scripts/deploy/troubleshooting/list-all-resources index 7dad7aee84b..fbebf735dcc 100755 --- a/scripts/deploy/troubleshooting/list-all-resources +++ b/scripts/deploy/troubleshooting/list-all-resources @@ -18,9 +18,7 @@ fi NAMESPACE=$1 -kubectl -n $NAMESPACE get all - -if [ $? -ne 0 ]; then +if ! kubectl -n "$NAMESPACE" get all ; then echo "An error occurred while trying to retrieve resources for namespace $NAMESPACE. Please verify that the namespace exists and that you have permissions to view its contents." exit 1 fi diff --git a/scripts/deploy/troubleshooting/list-deployments b/scripts/deploy/troubleshooting/list-deployments index cf84d3bd0ab..57ff7dfd7e1 100755 --- a/scripts/deploy/troubleshooting/list-deployments +++ b/scripts/deploy/troubleshooting/list-deployments @@ -18,9 +18,8 @@ fi NAMESPACE=$1 -kubectl -n $NAMESPACE get deployments -if [ $? -ne 0 ]; then +if ! kubectl -n "$NAMESPACE" get deployments ; then echo "An error occurred while trying to retrieve deployments for namespace $NAMESPACE. Please verify that the namespace exists and that you have permissions to view its contents." exit 1 fi diff --git a/scripts/deploy/troubleshooting/restart-deployment b/scripts/deploy/troubleshooting/restart-deployment index bad0cee0eed..6f5e5cb29ac 100755 --- a/scripts/deploy/troubleshooting/restart-deployment +++ b/scripts/deploy/troubleshooting/restart-deployment @@ -21,11 +21,8 @@ fi NAMESPACE=$1 DEPLOYMENT=$2 -# Restart the deployment -kubectl -n $NAMESPACE rollout restart deployment/$DEPLOYMENT -# Check if the restart was successful -if [ $? -eq 0 ]; then +if kubectl -n "$NAMESPACE" rollout restart deployment/"$DEPLOYMENT" ; then echo "Successfully restarted deployment $DEPLOYMENT in namespace $NAMESPACE." else echo "Failed to restart deployment $DEPLOYMENT in namespace $NAMESPACE." diff --git a/scripts/deploy/troubleshooting/view-logs b/scripts/deploy/troubleshooting/view-logs index 72d1a45df51..0db85575ba2 100755 --- a/scripts/deploy/troubleshooting/view-logs +++ b/scripts/deploy/troubleshooting/view-logs @@ -21,11 +21,11 @@ fi NAMESPACE=$1 DEPLOYMENT=$2 SERVICE=${DEPLOYMENT#cht-} -POD_NAME=$(kubectl -n $NAMESPACE get pods -l cht.service=$SERVICE -o jsonpath="{.items[0].metadata.name}") +POD_NAME=$(kubectl -n "$NAMESPACE" get pods -l cht.service="$SERVICE" -o jsonpath="{.items[0].metadata.name}") if [ -z "$POD_NAME" ]; then echo "No Pods found for deployment $DEPLOYMENT in Namespace $NAMESPACE." exit 1 fi -kubectl -n $NAMESPACE logs $POD_NAME +kubectl -n "$NAMESPACE" logs "$POD_NAME" diff --git a/scripts/docker-helper-4.x/cht-docker-compose.sh b/scripts/docker-helper-4.x/cht-docker-compose.sh index 60055ec848b..b6a5085b549 100755 --- a/scripts/docker-helper-4.x/cht-docker-compose.sh +++ b/scripts/docker-helper-4.x/cht-docker-compose.sh @@ -56,11 +56,11 @@ get_compose_download_url() { } get_all_known_versions() { - curl -s "${stagingUrl}"/_design/builds/_view/releases\?descending\=true | tr -d \\n | grep -o "medic\:medic\:[A-Za-z0-9\.\_\/\-]*" | cut -f3 -d: | sort + curl -s "${stagingUrl}"/_design/builds/_view/releases\?descending=true | tr -d \\n | grep -o "medic\:medic\:[A-Za-z0-9\.\_\/\-]*" | cut -f3 -d: | sort } get_latest_version_string() { - curl -s "${stagingUrl}"/_design/builds/_view/releases\?limit\=1\&descending\=true | tr -d \\n | grep -o 'medic\:medic\:[0-9\.]*' | cut -f3 -d: + curl -s "${stagingUrl}"/_design/builds/_view/releases\?limit=1\&descending=true | tr -d \\n | grep -o 'medic\:medic\:[0-9\.]*' | cut -f3 -d: } create_compose_files() { @@ -72,8 +72,8 @@ create_compose_files() { mkdir -p "$homeDir/compose" curl -s -o "$homeDir/upgrade-service.yml" \ https://raw.githubusercontent.com/medic/cht-upgrade-service/main/docker-compose.yml - curl -s -o "$homeDir/compose/cht-core.yml" ${stagingUrlBase}/docker-compose/cht-core.yml - curl -s -o "$homeDir/compose/couchdb.yml" ${stagingUrlBase}/docker-compose/cht-couchdb.yml + curl -s -o "$homeDir/compose/cht-core.yml" "${stagingUrlBase}"/docker-compose/cht-core.yml + curl -s -o "$homeDir/compose/couchdb.yml" "${stagingUrlBase}"/docker-compose/cht-couchdb.yml echo -e "${green} done${noColor} " } @@ -118,10 +118,10 @@ get_lan_ip() { # Device "" does not exist. routerIP=$(ip r | grep default | head -n1 | awk '{print $3}') subnet=$(echo "$routerIP" | cut -d'.' -f1,2,3 ) - if [ -z $subnet ]; then + if [ -z "$subnet" ]; then subnet=127.0.0 fi - lanInterface=$(ip r | grep $subnet | grep default | head -n1 | cut -d' ' -f 5) + lanInterface=$(ip r | grep "$subnet" | grep default | head -n1 | cut -d' ' -f 5) lanAddress=$(ip a s "$lanInterface" | awk '/inet /{gsub(/\/.*/,"");print $2}' | head -n1) elif [ "$(required_apps_installed "system_profiler")" ];then subnet=$(netstat -rn| grep default | awk '{print $2}'|grep -Ev '^[a-f]' |cut -f1,2,3 -d'.') @@ -187,11 +187,11 @@ service_has_image_downloaded(){ else compose_path="${homeDir}/compose/cht-core.yml" fi - image=$(grep "${service}:" ${compose_path} | grep image | cut -f2,3 -d":" | xargs) + image=$(grep "${service}:" "${compose_path}" | grep image | cut -f2,3 -d":" | xargs) - imageDownloadName=$(docker image ls --format {{.Repository}}:{{.Tag}} -f "reference=${image}" 2>/dev/null) - if [ $imageDownloadName ];then - echo ${imageDownloadName} + imageDownloadName=$(docker image ls --format '{{.Repository}}:{{.Tag}}' -f "reference=${image}" 2>/dev/null) + if [ "$imageDownloadName" ];then + echo "${imageDownloadName}" else echo "NA" fi @@ -199,9 +199,9 @@ service_has_image_downloaded(){ service_has_container(){ service=$1 - container_name=$(docker ps -af "name=^${projectName}[-_]+.*[-_]+[0-9]" --format '{{.Names}}' | grep ${service} 2>/dev/null) - if [ $container_name ];then - echo ${container_name} + container_name=$(docker ps -af "name=^${projectName}[-_]+.*[-_]+[0-9]" --format '{{.Names}}' | grep "${service}" 2>/dev/null) + if [ "$container_name" ];then + echo "${container_name}" else echo "NA" fi @@ -210,8 +210,8 @@ service_has_container(){ container_status(){ contianer=$1 status=$(docker inspect --format="{{.State.Status}}" "$contianer" 2>/dev/null) - if [ $status ];then - echo ${status} + if [ "$status" ];then + echo "${status}" else echo "NA" fi @@ -242,9 +242,9 @@ get_system_and_docker_info(){ services="cht-upgrade-service haproxy healthcheck api sentinel nginx couchdb" IFS=' ' read -ra servicesArray <<<"$services" for service in "${servicesArray[@]}"; do - image=$(service_has_image_downloaded ${service}) - container=$(service_has_container ${service}) - status=$(container_status ${container}) + image=$(service_has_image_downloaded "${service}") + container=$(service_has_container "${service}") + status=$(container_status "${container}") info="${info}"$'\n'"${service} ${status} ${container} ${image}" done echo @@ -286,6 +286,7 @@ if [[ -n "${2-}" && -n $projectName ]]; then case $2 in "stop") echo "Stopping project \"${projectName}\"..." | tr -d '\n' + # shellcheck disable=SC2086 docker kill $containerIds 1>/dev/null echo -e "${green} done${noColor} " exit 0 @@ -295,7 +296,9 @@ if [[ -n "${2-}" && -n $projectName ]]; then if [[ -n $containerIds ]]; then echo "Removing project's docker containers..." | tr -d '\n' + # shellcheck disable=SC2086 docker kill $containerIds 1>/dev/null + # shellcheck disable=SC2086 docker rm $containerIds 1>/dev/null echo -e "${green} done${noColor} " else @@ -305,6 +308,7 @@ if [[ -n "${2-}" && -n $projectName ]]; then networks=$(docker network ls --filter "name=${projectName}" --quiet) if [[ -n $networks ]]; then echo "Removing project's docker networks..." | tr -d '\n' + # shellcheck disable=SC2086 docker network rm $networks 1>/dev/null echo -e "${green} done${noColor} " else @@ -314,6 +318,7 @@ if [[ -n "${2-}" && -n $projectName ]]; then volumes=$(docker volume ls --filter "name=${projectName}" --quiet) if [[ -n $volumes ]]; then echo "Removing project's docker volumes..." | tr -d '\n' + # shellcheck disable=SC2086 docker volume rm $volumes 1>/dev/null echo -e "${green} done${noColor} " else @@ -330,7 +335,7 @@ if [[ -n "${2-}" && -n $projectName ]]; then if [[ -f "${projectName}.env" ]]; then echo "Removing .env file in this directory..." | tr -d '\n' - rm ${projectName}.env + rm "${projectName}".env echo -e "${green} done${noColor} " else echo "No .env file found, skipping." @@ -351,13 +356,14 @@ if [[ -z "$projectName" ]]; then fi echo - read -p "Would you like to initialize a new project [y/N]? " yn + # thanks for the pr vs rp!! https://unix.stackexchange.com/a/677805 + read -rp "Would you like to initialize a new project [y/N]?" yn case $yn in [Yy]*) while [[ -z "$projectName" ]]; do preferredRelease=$(get_latest_version_string) echo - read -p "Do you want to run the latest CHT Core version (${preferredRelease}) [Y/n]? " runLatest + read -rp "Do you want to run the latest CHT Core version (${preferredRelease}) [Y/n]? " runLatest case $runLatest in [nN]*) allKnownVersions=$(get_all_known_versions) @@ -368,10 +374,10 @@ if [[ -z "$projectName" ]]; then done esac echo - read -p "How do you want to name the project? " projectName + read -rp "How do you want to name the project? " projectName projectName="${projectName//[^[:alnum:]]/_}" - projectName=$(echo $projectName | tr '[:upper:]' '[:lower:]') + projectName=$(echo "$projectName" | tr '[:upper:]' '[:lower:]') projectFile="$projectName.env" homeDir=$(get_home_dir "$projectName") if test -f "./$projectFile"; then @@ -412,11 +418,11 @@ fi source "./$projectFile" projectURL=$(get_local_ip_url "$(get_lan_ip)") -if [ ! -z ${DEBUG+x} ];then get_system_and_docker_info; fi +if [[ -n ${DEBUG+x} ]];then get_system_and_docker_info; fi echo "";echo "homedir: $homeDir" docker-compose --env-file "./$projectFile" --file "$homeDir/upgrade-service.yml" up --detach -if [ ! -z ${DEBUG+x} ];then get_system_and_docker_info; fi +if [[ -n ${DEBUG+x} ]];then get_system_and_docker_info; fi set +e echo "Starting project \"${projectName}\". First run takes a while. Will try for up to five minutes..." | tr -d '\n' @@ -425,7 +431,7 @@ nginxContainerId=$(get_nginx_container_id) running=$(is_nginx_running "$nginxContainerId") i=0 -if [ ! -z ${DEBUG+x} ];then get_system_and_docker_info; fi +if [[ -n ${DEBUG+x} ]];then get_system_and_docker_info; fi while [[ "$running" != "true" ]]; do if [[ $i -gt 300 ]]; then echo "" @@ -437,7 +443,7 @@ while [[ "$running" != "true" ]]; do exit 1 fi - if [ ! -z ${DEBUG+x} ];then + if [[ -n ${DEBUG+x} ]];then clear;get_system_and_docker_info else echo '.' | tr -d '\n' @@ -452,9 +458,9 @@ while [[ "$running" != "true" ]]; do running=$(is_nginx_running "$nginxContainerId") done -docker exec -it $nginxContainerId bash -c "curl -s -o /etc/nginx/private/cert.pem https://local-ip.medicmobile.org/fullchain" 2>/dev/null -docker exec -it $nginxContainerId bash -c "curl -s -o /etc/nginx/private/key.pem https://local-ip.medicmobile.org/key" 2>/dev/null -docker exec -it $nginxContainerId bash -c "nginx -s reload" 2>/dev/null +docker exec "$nginxContainerId" bash -c "curl -s -o /etc/nginx/private/cert.pem https://local-ip.medicmobile.org/fullchain" 2>/dev/null +docker exec "$nginxContainerId" bash -c "curl -s -o /etc/nginx/private/key.pem https://local-ip.medicmobile.org/key" 2>/dev/null +docker exec "$nginxContainerId" bash -c "nginx -s reload" 2>/dev/null echo "" echo "" @@ -474,5 +480,5 @@ echo "" echo -e "${green} Have a great day!${noColor} " echo "" -if [ ! -z ${DEBUG+x} ];then get_system_and_docker_info; fi +if [[ -n ${DEBUG+x} ]];then get_system_and_docker_info; fi set -e diff --git a/scripts/docker-helper/cht-docker-compose.sh b/scripts/docker-helper/cht-docker-compose.sh index e4bd66ebfed..75e9e645f0f 100755 --- a/scripts/docker-helper/cht-docker-compose.sh +++ b/scripts/docker-helper/cht-docker-compose.sh @@ -12,8 +12,8 @@ # # See https://github.com/medic/cht-core/issues/7218 for more info -# shellcheck disable=SC2046 -. $(dirname $0)/simple_curses.sh +# shellcheck disable=SC1091 +. "$(dirname "$0")"/simple_curses.sh # todo maybe check to see if docker is running? avoid this error: # Error response from daemon: dial unix docker.raw.sock: connect: connection refused @@ -30,10 +30,10 @@ get_lan_ip() { # Device "" does not exist. routerIP=$(ip r | grep default | head -n1 | awk '{print $3}') subnet=$(echo "$routerIP" | cut -d'.' -f1,2,3) - if [ -z $subnet ]; then + if [ -z "$subnet" ]; then subnet=127.0.0 fi - lanInterface=$(ip r | grep $subnet | grep default | head -n1 | cut -d' ' -f 5) + lanInterface=$(ip r | grep "$subnet" | grep default | head -n1 | cut -d' ' -f 5) lanAddress=$(ip a s "$lanInterface" | awk '/inet /{gsub(/\/.*/,"");print $2}' | head -n1) if [ -z "$lanAddress" ]; then lanAddress=127.0.0.1 @@ -108,7 +108,7 @@ cht_healthy(){ validate_env_file(){ envFile=$1 - if [ ! -f "$envFile" ] || [[ ! "$(file $envFile)" == *"ASCII text"* ]]; then + if [ ! -f "$envFile" ] || [[ ! "$(file "$envFile")" == *"ASCII text"* ]]; then echo "File not found or not a text file: $envFile" echo "" echo " Start CHT with: ./cht-docker-compose.sh -d up -e ../PATH_TO_CONFIG/.env_docker" @@ -150,7 +150,7 @@ get_images_count(){ IFS=' ' read -ra imagesArray <<< "$images" for image in "${imagesArray[@]}" do - if [ "$( docker image ls --format {{.Repository}}:{{.Tag}} | grep -c "${image}" )" -eq 1 ]; then + if [ "$( docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -c "${image}" )" -eq 1 ]; then (( result++ )) fi done @@ -297,13 +297,13 @@ log_iteration(){ last_msg=$(echo "$3" | tr -d '"') docker_call=$4 line_head="$(date) pid=\"$$\" count=\"${counter}\"" - load_now=$(echo $(get_load_avg)|cut -d" " -f 1) + load_now=$(get_load_avg | cut -d" " -f 1) # output the log in the same directory as the env file - log_location=$(dirname $envFile) + log_location=$(dirname "$envFile") logname="${log_location}/cht-docker-compose.log" - full_url=$(get_local_ip_url $lanAddress) + full_url=$(get_local_ip_url "$lanAddress") portIsOpen=$(port_open "$lanAddress" "$CHT_HTTPS") if [ "$portIsOpen" = "0" ]; then port_status='open' @@ -320,7 +320,7 @@ log_iteration(){ ssl_verify='no' fi - if [ $counter -eq 0 ]; then + if [ "$counter" -eq 0 ]; then echo "$(date) pid=\"$$\" \ item=\"end\" \ @@ -330,7 +330,7 @@ project_name=\"$COMPOSE_PROJECT_NAME\" \ fi - if [ $counter -eq 1 ]; then + if [ "$counter" -eq 1 ]; then echo "${line_head} \ item=\"start\" \ @@ -360,7 +360,7 @@ total_containers=\"$(get_global_running_container_count)\"\ logs="NA" fi - echo "${line_head} item=\"docker_logs\" container=\"${container}\" processes=\"$(get_container_process_count ${container})\" last_log=\"$logs\"" >& 1 >> $logname + echo "${line_head} item=\"docker_logs\" container=\"${container}\" processes=\"$(get_container_process_count "${container}")\" last_log=\"$logs\"" >& 1 >> "$logname" container_stat="${container}=\"${RUNNING}\" ${container_stat}" done @@ -375,7 +375,7 @@ docker_call=\"$docker_call\" \ last_msg=\"$last_msg\" \ load_now=\"$load_now\" \ $container_stat\ -" >& 1 >> $logname +" >& 1 >> "$logname" } @@ -435,7 +435,7 @@ main (){ loadAvg=$(get_load_avg) chtVersion="NA" - log_iteration $counter $reboot_count "${last_action}" $docker_action + log_iteration "$counter" "$reboot_count" "${last_action}" "$docker_action" (( counter++ )) # if we're exiting, call down or destroy and quit proper @@ -463,7 +463,7 @@ main (){ # derive overall healthy if [ -z "$appStatus" ] && [ -z "$health" ] && [ "$self_signed" = "0" ] && [ "$expired_cert" = "0" ]; then overAllHealth="Good" - elif [[ "$sleepFor" > 0 ]]; then + elif [[ "$sleepFor" -gt 0 ]]; then overAllHealth="Booting..." else overAllHealth="!= Bad =!" @@ -535,7 +535,7 @@ main (){ (( reboot_count++ )) fi - if [[ "$sleepFor" > 0 ]] && [ -n "$health" ]; then + if [[ "$sleepFor" -gt 0 ]] && [ -n "$health" ]; then window "Attempt number $reboot_count / $MAX_REBOOTS to boot $COMPOSE_PROJECT_NAME" "yellow" "100%" append "Waiting $sleepFor..." endwin @@ -590,7 +590,7 @@ main (){ fi # if we're here, we're happy! Show happy sign and exit next iteration via exitNext - script_path1=$(dirname $0) + script_path1=$(dirname "$0") last_action=" :) " window "Successfully started ${COMPOSE_PROJECT_NAME} " "green" "100%" append "login: medic" @@ -605,4 +605,4 @@ main (){ } -main_loop -t .5 $@ +main_loop -t .5 "$@" diff --git a/scripts/docker-helper/docker-status.sh b/scripts/docker-helper/docker-status.sh index 5516f4cc734..482f43a6488 100755 --- a/scripts/docker-helper/docker-status.sh +++ b/scripts/docker-helper/docker-status.sh @@ -2,8 +2,8 @@ # Helper script to show running docker containers and related docker resources -# shellcheck disable=SC2046 -. $(dirname $0)/simple_curses.sh +# shellcheck disable=SC1091 +. "$(dirname "$0")"/simple_curses.sh main (){ @@ -47,4 +47,4 @@ main (){ } -main_loop -t 1.2 $@ \ No newline at end of file +main_loop -t 1.2 "$@" diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 5eace11cd14..ba6c5325e6e 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@medic/translation-checker": "^1.0.1", + "@medic/translation-checker": "^1.1.0", "@octokit/core": "^5.2.0", "@octokit/plugin-paginate-graphql": "^ v4.0.1", "chalk": "^5.3.0", @@ -1646,9 +1646,9 @@ "dev": true }, "node_modules/@medic/translation-checker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@medic/translation-checker/-/translation-checker-1.0.1.tgz", - "integrity": "sha512-yMCqW6EgDvGMBCnwaFWe+J1GgAddK9U2xsoXyCsrRS47JoRTxGTfL8LIybgTX1/vUEVRH0ZS0dt1+AAUUZ3MGQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@medic/translation-checker/-/translation-checker-1.1.0.tgz", + "integrity": "sha512-QwFE6TtVR9RHlpCXMNQ3YXXFTYd7ZJBEE+4tJ9G1uFJO/P1tutuZXm0Demc59WnKHZ7GNt/yiT0GeN+vKEx4lA==", "dependencies": { "iso-639-1": "^2.1.4", "messageformat": "^2.3.0", @@ -11727,9 +11727,9 @@ "dev": true }, "@medic/translation-checker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@medic/translation-checker/-/translation-checker-1.0.1.tgz", - "integrity": "sha512-yMCqW6EgDvGMBCnwaFWe+J1GgAddK9U2xsoXyCsrRS47JoRTxGTfL8LIybgTX1/vUEVRH0ZS0dt1+AAUUZ3MGQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@medic/translation-checker/-/translation-checker-1.1.0.tgz", + "integrity": "sha512-QwFE6TtVR9RHlpCXMNQ3YXXFTYd7ZJBEE+4tJ9G1uFJO/P1tutuZXm0Demc59WnKHZ7GNt/yiT0GeN+vKEx4lA==", "requires": { "iso-639-1": "^2.1.4", "messageformat": "^2.3.0", diff --git a/scripts/package.json b/scripts/package.json index e77ca19b4bb..827c9307695 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -4,7 +4,7 @@ "description": "", "main": "bundle_templates.js", "dependencies": { - "@medic/translation-checker": "^1.0.1", + "@medic/translation-checker": "^1.1.0", "@octokit/core": "^5.2.0", "@octokit/plugin-paginate-graphql": "^ v4.0.1", "chalk": "^5.3.0", diff --git a/scripts/poe/.env.example b/scripts/poe/.env.example deleted file mode 100644 index 9cf2e3070b3..00000000000 --- a/scripts/poe/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -DEBUG = false -POE_API_URL = https://api.poeditor.com/v2 -POE_API_TOKEN = -POE_PROJECT_ID = 33025 -SLACK_WEBHOOK_URL = diff --git a/scripts/poe/.eslintrc b/scripts/poe/.eslintrc deleted file mode 100644 index c627043f0b1..00000000000 --- a/scripts/poe/.eslintrc +++ /dev/null @@ -1,51 +0,0 @@ -{ - "parserOptions": { - "ecmaVersion": 2018, - "sourceType": "module" - }, - "env": { - "jasmine": true, - "jest": true, - "node": true, - "mocha": true, - "browser": false, - "es6": true, - "builtin": true - }, - "globals": {}, - "rules": { - "block-scoped-var": 2, - "camelcase": 2, - "comma-style": [2, "last"], - "curly": [2, "all"], - "dot-notation": [2, { "allowKeywords": true }], - "eqeqeq": [2, "allow-null" ], - "global-strict": [0, "never"], - "guard-for-in": 2, - "new-cap": 2, - "no-bitwise": 2, - "no-caller": 2, - "no-cond-assign": [2, "except-parens"], - "no-debugger": 2, - "no-empty": 2, - "no-eval": 2, - "no-extend-native": 2, - "no-extra-parens": 2, - "no-irregular-whitespace": 2, - "no-iterator": 2, - "no-loop-func": 2, - "no-new": 2, - "no-proto": 2, - "no-script-url": 2, - "no-sequences": 2, - "no-shadow": 2, - "no-undef": 2, - "no-unused-vars": 2, - "no-with": 2, - "quotes": [2, "single"], - "semi": 2, - "strict": 2, - "valid-typeof": 2, - "wrap-iife": [2, "inside"] - } -} diff --git a/scripts/poe/.gitignore b/scripts/poe/.gitignore deleted file mode 100644 index 4c49bd78f1d..00000000000 --- a/scripts/poe/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.env diff --git a/scripts/poe/README.md b/scripts/poe/README.md deleted file mode 100644 index 893a944badf..00000000000 --- a/scripts/poe/README.md +++ /dev/null @@ -1,32 +0,0 @@ -poe cli -------- - -### Setup -``` -npm ci -cp .env.example .env -npm link -``` - -### Define your api key and project id -``` -vi .env -``` - -### Upload translation file -``` -// Uploads translation with tag provided in ../../package.json -poe import ./messages-en.properties - -// Uploads translation with specific tag -poe import ./messages-en.properties 3.5.0 -``` - -# Download Translation file(s) -``` -// Downloads latest translations (no tag) -poe export . - -// Downloads latest translations for given tag -poe export . 3.5.0 -``` diff --git a/scripts/poe/cli.js b/scripts/poe/cli.js deleted file mode 100755 index 80afc2a180c..00000000000 --- a/scripts/poe/cli.js +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node -require('dotenv').config({ path: __dirname + '/.env' }); - -const Spinner = require('clui').Spinner; -const inquirer = require('./lib/inquirer'); -const poe = require('./lib/poe'); -const other = require('./lib/other'); -const banner = require('./lib/banner'); -const usage = require('./lib/usage'); -const config = require('./config.json'); -const {capitalize} = require('./lib/utils'); -const {version} = require('../../package.json'); -const log = require('loglevel'); - -const POE_CMDS = ['import', 'export']; -const OTHER_CMDS = ['slacktest', 'version']; -const ALL_CMDS = POE_CMDS.concat(OTHER_CMDS); -const POE_FUNS = {import: 'upload', export: 'download'}; - -log.setDefaultLevel(process.env.DEBUG === 'true' ? 'debug' : 'info'); - -const options = async (args) => { - if (args.length > 1) { - const opts = config[args[0]]; - opts.file = args[1]; - opts.api_token = process.env.POE_API_TOKEN; // eslint-disable-line camelcase - opts.id = process.env.POE_PROJECT_ID; - if (args[0] === 'import') { - // Tags the import with ../../package.json version or the extra arg - opts.tags = args.length > 2 ? [args[2]] : [version]; - } else if (args[0] === 'export' && args.length > 2) { - // Exports using a specific tag or just gets the latest (no tag) - opts.tags = args[2]; - } - return opts; - } - banner.show(args[0]); - const {...opts} = await inquirer.ask(args[0]); - return opts; -}; - -const run = async (args) => { - const cmd = args.length && ALL_CMDS.includes(args[0]) && args[0]; - if (!cmd) { - usage.show(ALL_CMDS); - } else { - if (POE_CMDS.includes(cmd)) { - const opts = await options(args); - const spinner = new Spinner(`${capitalize(cmd)}ing translations...`); - spinner.start(); - let failed; - try { - await poe[POE_FUNS[cmd]](opts); - } catch (ex) { - console.log(ex); - failed = true; - } finally { - spinner.stop(); - if (failed) { - process.exit(1); - } else { - console.log('\ndone.'); - } - } - } else { - other[cmd](); - } - } -}; - -run(process.argv.slice(2)); diff --git a/scripts/poe/config.json b/scripts/poe/config.json deleted file mode 100644 index cd7daaa4b39..00000000000 --- a/scripts/poe/config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "import": { - "updating": "terms_translations", - "overwrite": 1, - "sync_terms": 1, - "fuzzy_trigger": 1 - }, - "export": { - "language": "all", - "type": "properties" - } -} diff --git a/scripts/poe/jest-lint.config.js b/scripts/poe/jest-lint.config.js deleted file mode 100644 index c7cabbea50f..00000000000 --- a/scripts/poe/jest-lint.config.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - displayName: 'lint:eslint', - runner: 'jest-runner-eslint', - testMatch: [ - '/tests/**/*.js', - '/lib/**/*.js' - ], - testPathIgnorePatterns: [ - ] -}; diff --git a/scripts/poe/jest-test.config.js b/scripts/poe/jest-test.config.js deleted file mode 100644 index ac3e6dcba7a..00000000000 --- a/scripts/poe/jest-test.config.js +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = { - displayName: 'jest:test', - testEnvironment: 'node', - testPathIgnorePatterns: [ - 'bootstrap', 'mocks', 'docs', 'tests/translations' - ], - testMatch: [ - '/tests/**/*.js' - ], - collectCoverageFrom: [ - '/lib/**/*.{js}' - ], - coverageThreshold: { - global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100 - } - } -}; diff --git a/scripts/poe/lib/banner.js b/scripts/poe/lib/banner.js deleted file mode 100644 index 462cdde0ef0..00000000000 --- a/scripts/poe/lib/banner.js +++ /dev/null @@ -1,12 +0,0 @@ -const chalk = require('chalk'); -const figlet = require('figlet'); - -module.exports = { - show: (text) => { - const color = text.toLowerCase() === 'import' ? 'yellow' : 'green'; - console.log(chalk[color]( - figlet.textSync(`poe-${text}`, - { horizontalLayout: 'default' }) - )); - } -}; diff --git a/scripts/poe/lib/get.js b/scripts/poe/lib/get.js deleted file mode 100644 index 2ca433b091e..00000000000 --- a/scripts/poe/lib/get.js +++ /dev/null @@ -1,5 +0,0 @@ -const req = require('request'); -const {promisify} = require('util'); -const get = promisify(req.get); - -module.exports = get; diff --git a/scripts/poe/lib/inquirer.js b/scripts/poe/lib/inquirer.js deleted file mode 100644 index 55c2a91acec..00000000000 --- a/scripts/poe/lib/inquirer.js +++ /dev/null @@ -1,138 +0,0 @@ -const {mmVersion} = require('./utils'); -const inquirer = require('inquirer'); -const {validTranslations, validDirectory} = require('./validate'); - -const apiTokenQuestion = { - name: 'api_token', type: 'input', - message: 'Enter poeditor\'s api key:', - default: process.env.POE_API_TOKEN || '', - validate: function( value ) { - return value.length ? true : 'Please enter poeditor\'s api key.'; - } -}; - -const projectIdQuestion = { - name: 'id', type: 'input', - message: 'Enter poeditor\'s project id:', - default: process.env.POE_PROJECT_ID || '', - validate: function(value) { - return value.length ? true : 'Please enter poeditor\'s project id.'; - } -}; - -const questions = { - export: [ - apiTokenQuestion, - projectIdQuestion, - { - name: 'language', - type: 'list', - message: 'Select the language to download.', - choices: ['all', 'en'], - default: 'all' - }, - { - name: 'type', - type: 'list', - message: 'Select the file format.', - choices: [ - 'po', 'pot', 'mo', 'xls', 'csv', 'resw', 'resx', 'android_strings', 'apple_strings', 'xliff', - 'properties', 'key_value_json', 'json', 'xmb', 'xtb' - ], - default: 'properties' - }, - { - name: 'file', type: 'input', - message: 'Enter the relative path to the download directory', - default: '.', - validate: function( value ) { - return validDirectory(value) ? true : 'Please enter the download directory.'; - } - }, - { - name: 'filters', - type: 'list', - message: 'Filter results by:', - choices: ['none', 'translated', 'untranslated', 'fuzzy', 'not_fuzzy', - 'automatic', 'not_automatic', 'proofread', 'not_proofread'], - default: 'none' - }, - { - name: 'tags', - type: 'string', - message: 'Filter results by tags.\nYou can use either a string for a single tag or a json array for one ' + - 'or multiple tags.', - default: `${mmVersion()}` - } - ], - import: [ - apiTokenQuestion, - projectIdQuestion, - { - name: 'updating', type: 'list', - message: 'Select the type of UPDATING.', - choices: ['terms', 'terms_translations', 'translations'], - default: 'terms_translations' - }, - { - name: 'file', type: 'input', - message: 'Enter the relative path to the translation file', - default: './messages-en.properties', - validate: function( value ) { - return validTranslations(value) ? true : 'Please enter the translation file.'; - } - }, - { - name: 'overwrite', type: 'list', - message: 'Do you want to overwrite translations?', - choices: ['yes', 'no'], - default: 'yes' - }, - { - name: 'sync_terms', type: 'list', - message: 'Do you want to sync terms?\nSet to yes if you want to sync your terms \n(terms that are not found in ' + - 'the uploaded file will be deleted from project and the new ones added). \nIgnored if updating = translations.', - choices: ['yes', 'no'], - default: 'yes' - }, - { - name: 'tags', type: 'input', - message: 'Do you want to add tags?\n\nAvailable when updating terms or terms_translations; you can use the ' + - 'following keys: \ - \n"all" - for all the imported terms, \ - \n"new" - for the terms which aren\'t already in the project, \ - \n"obsolete" - for the terms which are in the project but not in the imported file and \ - \n"overwritten_translations" - for the terms for which translations change. \ - \n\nexamples: \ - \n# If not specified, the tags are set by default to all terms. \ - \ntags=["name-of-tag", "name-of-another-tag"] \ - \ntags={"all": "name-of-tag"} \ - \ntags={"all": "name-of-tag", "new": ["name-of-tag"], "obsolete": ["name-of-tag", "name-of-another-tag"]}\n\n', - default: `{"all": ["${mmVersion()}"]}` - }, - { - name: 'fuzzy_trigger', type: 'list', - message: 'Fuzzy trigger?\nSet it to yes to mark corresponding translations from the other languages as fuzzy ' + - 'for the updated values', - choices: ['yes', 'no'], - default: 'yes' - } - ] -}; - -module.exports = { - ask: async (arg) => { - const values = await inquirer.prompt(questions[arg]); - ['overwrite', 'sync_terms', 'fuzzy_trigger'].forEach(key => { - if (values[key]) { - values[key] = values[key] === 'yes' ? 1 : 0; - } - }); - ['filters', 'tags'].forEach(key => { - if (['', 'none'].includes(values[key])) { - delete values[key]; - } - }); - return values; - } -}; diff --git a/scripts/poe/lib/other.js b/scripts/poe/lib/other.js deleted file mode 100644 index 83a1f69a967..00000000000 --- a/scripts/poe/lib/other.js +++ /dev/null @@ -1,12 +0,0 @@ -const pkg = require('../package.json'); -const slack = require('./slack')(process.env.SLACK_WEBHOOK_URL); - -module.exports = { - slacktest: () => { - slack.send('Hello from POE translation scripts'); - console.log('Message sent. Check your slack channel.'); - }, - version: () => { - console.log(pkg.version); - } -}; diff --git a/scripts/poe/lib/poe.js b/scripts/poe/lib/poe.js deleted file mode 100644 index 13602bc2c5e..00000000000 --- a/scripts/poe/lib/poe.js +++ /dev/null @@ -1,89 +0,0 @@ -const {save, mmVersion, error} = require('./utils'); -const queryString = require('querystring'); -const post = require('./post'); -const {readStream} = require('./read'); -const { - validTranslations, - validDirectory, - validatePlaceHolders -} = require('./validate'); -const slack = require('./slack')(process.env.SLACK_WEBHOOK_URL); - -const upload = async (opts) => { - if (validTranslations(opts.file)) { - opts.language = opts.file.split('-').pop().split('.')[0]; - const form = {file: readStream(`${process.cwd()}/${opts.file}`)}; - delete opts.file; - Object.keys(opts).forEach((key) => form[key] = opts[key]); - try { - let res = await post({ - headers: { 'Content-Type': 'multipart/form-data; charset=UTF-8' }, - uri: `${process.env.POE_API_URL}/projects/upload`, - formData: form}); - res = JSON.parse(res.body); - console.log(opts.language); - if (res.response.code !== '200') { - error('Unable to upload translation.'); - console.log(res.response); - } else { - console.log(res.result); - slack.send(`Translations ${mmVersion()}: ${JSON.stringify(res.result)}`); - } - return res.response; - } catch (err) { - console.log(err); - } - } -}; - -const download = async (opts) => { - if (validDirectory(opts.file)) { - const dir = `${process.cwd()}/${opts.file}`; - delete opts.file; - const langs = opts.language === 'all' ? await languages(opts) : [opts.language]; - const downloads = langs.map(async (lang) => { - opts.language = lang; - const form = queryString.stringify(opts); - const res = await post({ - headers: { - 'Content-Length': form.length, - 'Content-Type': 'application/x-www-form-urlencoded' - }, - url: `${process.env.POE_API_URL}/projects/export`, - body: form}); - const {response, result} = JSON.parse(res.body); - if (response.code !== '200') { - console.log(response); - process.exit(1); - } - const file = `messages-${lang}.properties`; - console.log(`\t${lang} saved to ${dir}/${file}`); - await save(result.url, `${dir}/${file}`); - return response; - }); - await Promise.all(downloads); - if (! await validatePlaceHolders(langs, dir)) { - throw new Error('Invalid placeholders or "messageformat" messages!'); - } - } -}; - -const languages = async (opts) => { - const form = queryString.stringify(opts); - const res = await post({ - headers: { - 'Content-Length': form.length, - 'Content-Type': 'application/x-www-form-urlencoded' - }, - url: `${process.env.POE_API_URL}/languages/list`, - body: form}); - console.log(res.body); - const {result} = JSON.parse(res.body); - return result.languages.map(lang => lang.code); -}; - -module.exports = { - upload: async (opts) => upload(opts), - download: async (opts) => download(opts), - languages: async (opts) => languages(opts) -}; diff --git a/scripts/poe/lib/post.js b/scripts/poe/lib/post.js deleted file mode 100644 index 015cc45f69e..00000000000 --- a/scripts/poe/lib/post.js +++ /dev/null @@ -1,5 +0,0 @@ -const req = require('request'); -const {promisify} = require('util'); -const post = promisify(req.post); - -module.exports = post; diff --git a/scripts/poe/lib/read.js b/scripts/poe/lib/read.js deleted file mode 100644 index 4ddd0d153ae..00000000000 --- a/scripts/poe/lib/read.js +++ /dev/null @@ -1,6 +0,0 @@ -const fs = require('fs'); -const readStream = fs.createReadStream; - -module.exports = { - readStream: readStream -}; diff --git a/scripts/poe/lib/slack.js b/scripts/poe/lib/slack.js deleted file mode 100644 index 6e5539f71f9..00000000000 --- a/scripts/poe/lib/slack.js +++ /dev/null @@ -1,15 +0,0 @@ -const post = require('./post'); -const opts = {url: '', json: {}}; - -module.exports = (url) => { - opts.url = url; - return { - send: async (msg) => { - if (url && url.length) { - opts.json.text = msg; - return await post(opts); - } - console.log('Slack channel not defined (.env). Unable to notify.'); - } - }; -}; diff --git a/scripts/poe/lib/usage.js b/scripts/poe/lib/usage.js deleted file mode 100644 index 9ee8d54c6b9..00000000000 --- a/scripts/poe/lib/usage.js +++ /dev/null @@ -1,29 +0,0 @@ -const commandLineUsage = require('command-line-usage'); - -const sections = [ - { - header: 'poe', - content: 'poeditor cli.' - }, - { - header: 'Options', - content: '$ poe ' - }, - { - header: 'Command List', - content: [ - { name: 'import', summary: 'Import translation.' }, - { name: 'export', summary: 'Export translation(s).' }, - { name: 'slacktest', summary: 'Test slack interface.' }, - { name: 'version', summary: 'poe cli version.' }, - ] - } -]; - -const usage = commandLineUsage(sections); - -module.exports = { - show: () => { - console.log(usage); - } -}; diff --git a/scripts/poe/lib/utils.js b/scripts/poe/lib/utils.js deleted file mode 100644 index 45456f21700..00000000000 --- a/scripts/poe/lib/utils.js +++ /dev/null @@ -1,44 +0,0 @@ -const fs = require('fs'); -const chalk = require('chalk'); -const get = require('./get'); -const pkg = require('../../../package.json'); - -module.exports = { - capitalize: (string) => { - return string.charAt(0).toUpperCase() + string.slice(1); - }, - error: (msg) => { - console.log(`${chalk.red('Error: ')}${msg}`); - }, - warn: (msg) => { - console.warn(`${chalk.yellow('Warning: ')}${msg}`); - }, - info: (msg) => { - console.warn(`${chalk.green('Info: ')}${msg}`); - }, - log: (msg) => { - console.log(msg); - }, - mkdir: (dir) => { - if (!fs.existsSync(dir)){ - fs.mkdirSync(dir); - } - return fs.existsSync(dir); - }, - mmVersion: () => { - return pkg.version; - }, - save: async (fileUrl, filePath) => { - const res = await get({url: fileUrl, encoding: 'utf-8'}); - /* - - Sort file content alpahabetically in ascending order - - Get rid of comments (lines starting with #) - */ - const out = res.body - .split('\n') - .filter(line => !line.trim().startsWith('#')) - .sort() - .join('\n'); - return fs.writeFileSync(filePath, out); - } -}; diff --git a/scripts/poe/lib/validate.js b/scripts/poe/lib/validate.js deleted file mode 100644 index ed16110d2e1..00000000000 --- a/scripts/poe/lib/validate.js +++ /dev/null @@ -1,102 +0,0 @@ -const utils = require('./utils'); -const fs = require('fs'); -const { - checkTranslations, - TranslationException -} = require('@medic/translation-checker'); - -const fileExists = (fpath) => { - const file = `${process.cwd()}/${fpath}`; - const valid = fs.existsSync(file); - if (!valid) { - utils.error(`Unable to find your translation file:\n${file}`); - } - return valid; -}; - -const hasValidName = (fpath) => { - const valid = fpath.indexOf('-') >= 0 && fpath.indexOf('.') >= 0; - if (!valid) { - utils.error(`Unexpected filename: ${fpath}`); - utils.log('Please rename to -.'); - } - return valid; -}; - -const validTranslations = (fpath) => { - return fileExists(fpath) && hasValidName(fpath); -}; - -const validDirectory = (fpath) => { - const valid = utils.mkdir(fpath); - if (!valid) { - utils.error(`Unable to access directory ${fpath}`); - } - return valid; -}; - -const validatePlaceHolders = async (langs, dir) => { - let formatErrorsFound = 0; - let placeholderErrorsFound = 0; - let emptiesFound = 0; - try { - await checkTranslations( - dir, - { - checkPlaceholders: true, - checkEmpties: true, - checkMessageformat: true, - languages: langs.concat('ex') - } - ); - } catch (err) { - if (err instanceof TranslationException) { - if (!err.errors) { - return utils.error('Exception checking translations:', err.message); - } - for (const e of err.errors) { - switch (e.error) { - case 'cannot-access-dir': - return utils.log('Could not find custom translations dir:', dir); - case 'missed-placeholder': - case 'wrong-placeholder': - placeholderErrorsFound++; - utils.error(e.message); - break; - case 'empty-message': - emptiesFound++; - break; - case 'wrong-messageformat': - formatErrorsFound++; - utils.error(e.message); - break; - case 'wrong-file-name': - utils.warn(e.message); - break; - default: // No more know options, just in case ... - utils.error(e.message); - } - } - if (emptiesFound > 0) { - utils.info(`Found ${emptiesFound} empty translations trying to compile`); - } - if (formatErrorsFound > 0 || placeholderErrorsFound > 0) { - let errMsg = `Found ${formatErrorsFound + placeholderErrorsFound} errors trying to compile`; - if (placeholderErrorsFound > 0) { - errMsg += '\nYou can use messages-ex.properties to add placeholders missing from the reference context.'; - } - utils.error(errMsg); - return false; - } - } else { - throw err; - } - } - return true; -}; - -module.exports = { - validTranslations, - validDirectory, - validatePlaceHolders -}; diff --git a/scripts/poe/messages-ex.properties b/scripts/poe/messages-ex.properties deleted file mode 100644 index 0f717c820e1..00000000000 --- a/scripts/poe/messages-ex.properties +++ /dev/null @@ -1,61 +0,0 @@ -contact.primary_contact_name = {{name || 'ninguno'}} {{name || ' '}} -messages.schedule.child.month_01 = {{contact.name}} -messages.schedule.child.month_02 = {{contact.name}} -messages.schedule.child.month_03 = {{contact.name}} -messages.schedule.child.month_04 = {{contact.name}} -messages.schedule.child.month_05 = {{contact.name}} -messages.schedule.child.month_06 = {{contact.name}} -messages.schedule.child.month_07 = {{contact.name}} -messages.schedule.child.month_08 = {{contact.name}} -messages.schedule.child.month_09 = {{contact.name}} -messages.schedule.child.month_10 = {{contact.name}} -messages.schedule.child.month_11 = {{contact.name}} -messages.schedule.child.month_12 = {{contact.name}} -messages.schedule.child.month_13 = {{contact.name}} -messages.schedule.child.month_14 = {{contact.name}} -messages.schedule.child.month_15 = {{contact.name}} -messages.schedule.child.month_16 = {{contact.name}} -messages.schedule.child.month_17 = {{contact.name}} -messages.schedule.child.month_18 = {{contact.name}} -messages.schedule.child.month_19 = {{contact.name}} -messages.schedule.child.month_20 = {{contact.name}} -messages.schedule.child.month_21 = {{contact.name}} -messages.schedule.child.month_22 = {{contact.name}} -messages.schedule.child.month_23 = {{contact.name}} -messages.schedule.child.month_24 = {{contact.name}} -messages.schedule.child.month_25 = {{contact.name}} -messages.schedule.child.month_26 = {{contact.name}} -messages.schedule.child.month_27 = {{contact.name}} -messages.schedule.child.month_28 = {{contact.name}} -messages.schedule.child.month_29 = {{contact.name}} -messages.schedule.child.month_30 = {{contact.name}} -messages.schedule.child.month_31 = {{contact.name}} -messages.schedule.child.month_32 = {{contact.name}} -messages.schedule.child.month_33 = {{contact.name}} -messages.schedule.child.month_34 = {{contact.name}} -messages.schedule.child.month_35 = {{contact.name}} -messages.schedule.child.month_36 = {{contact.name}} -messages.schedule.child.month_37 = {{contact.name}} -messages.schedule.child.month_38 = {{contact.name}} -messages.schedule.child.month_39 = {{contact.name}} -messages.schedule.child.month_40 = {{contact.name}} -messages.schedule.child.month_41 = {{contact.name}} -messages.schedule.child.month_42 = {{contact.name}} -messages.schedule.child.month_43 = {{contact.name}} -messages.schedule.child.month_44 = {{contact.name}} -messages.schedule.child.month_45 = {{contact.name}} -messages.schedule.child.month_46 = {{contact.name}} -messages.schedule.child.month_47 = {{contact.name}} -messages.schedule.child.month_48 = {{contact.name}} -messages.schedule.child.month_49 = {{contact.name}} -messages.schedule.child.month_50 = {{contact.name}} -messages.schedule.child.month_51 = {{contact.name}} -messages.schedule.child.month_52 = {{contact.name}} -messages.schedule.child.month_53 = {{contact.name}} -messages.schedule.child.month_54 = {{contact.name}} -messages.schedule.child.month_55 = {{contact.name}} -messages.schedule.child.month_56 = {{contact.name}} -messages.schedule.child.month_57 = {{contact.name}} -messages.schedule.child.month_58 = {{contact.name}} -messages.schedule.child.month_59 = {{contact.name}} -messages.schedule.child.month_60 = {{contact.name}} diff --git a/scripts/poe/tests/banner.js b/scripts/poe/tests/banner.js deleted file mode 100644 index 372478f5d90..00000000000 --- a/scripts/poe/tests/banner.js +++ /dev/null @@ -1,10 +0,0 @@ -const banner = require('../lib/banner'); - -describe('banner', () => { - test('show', () => { - console = { log: jest.fn() }; - expect(console.log).not.toHaveBeenCalled(); - banner.show('something'); - expect(console.log).toHaveBeenCalled(); - }); -}); diff --git a/scripts/poe/tests/poe-export.js b/scripts/poe/tests/poe-export.js deleted file mode 100644 index 7028400a3d7..00000000000 --- a/scripts/poe/tests/poe-export.js +++ /dev/null @@ -1,52 +0,0 @@ -const poe = require('../lib/poe'); -const post = require('../lib/post'); -const {validDirectory} = require('../lib/validate'); - -jest.mock('fs'); -jest.mock('../lib/post'); -jest.mock('../lib/utils'); -jest.mock('../lib/validate'); - -const exportArg = { - id: '123', - language: 'all', - type: 'properties', - file: 'poe/translations', - tags: '3.4.0' -}; - -const langsResponse = JSON.stringify({result: {languages: [{code: 'en'}]}}); -const filesResponse = JSON.stringify({response: {code: '200'}, result: []}); - -const expectedLangOptions = { - body: 'id=123&language=all&type=properties&tags=3.4.0', - headers: { - 'Content-Length': 46, - 'Content-Type': 'application/x-www-form-urlencoded' - }, - url: 'http://poe/languages/list' -}; - -describe('poe', () => { - test('download', async () => { - process = { - cwd: () => '/Users/simon', - env: {POE_API_URL: 'http://poe'} - }; - console = { - log: jest.fn(), - error: jest.fn() - }; - validDirectory.mockResolvedValueOnce(true); - post.mockResolvedValueOnce({body: langsResponse}); - post.mockResolvedValueOnce({body: filesResponse}); - expect(poe.download(exportArg)).rejects.toEqual(new Error('Invalid placeholders!')); - expect(post).toHaveBeenCalledWith(expectedLangOptions); - }); - - test('successful download', async () => { - expect(console.log).toHaveBeenCalledWith( - '\ten saved to /Users/simon/poe/translations/messages-en.properties' - ); - }); -}); diff --git a/scripts/poe/tests/poe-import.js b/scripts/poe/tests/poe-import.js deleted file mode 100644 index 63ef58bffed..00000000000 --- a/scripts/poe/tests/poe-import.js +++ /dev/null @@ -1,52 +0,0 @@ -const poe = require('../lib/poe'); -const post = require('../lib/post'); -const {readStream} = require('../lib/read'); -const {validTranslations} = require('../lib/validate'); - -jest.mock('fs'); -jest.mock('../lib/post'); -jest.mock('../lib/read'); -jest.mock('../lib/validate'); - -const importArg = { - id: '123', - file: 'translations/messages-en.properties' -}; - -const response = JSON.stringify({result: {total: 177}, response: {code: '200'}}); - -const expectedOptions = { - formData: { - file: undefined, // this is ok since we are not mocking the stream. - id: '123', - language: 'en' - }, - headers: { - 'Content-Type': 'multipart/form-data; charset=UTF-8' - }, - 'uri': 'http://poe/projects/upload' -}; - -describe('poe', () => { - test('import', async () => { - process = { - cwd: () => '/Users/simon', - env: {POE_API_URL: 'http://poe'} - }; - console = { - log: jest.fn() - }; - validTranslations.mockResolvedValueOnce(true); - post.mockResolvedValueOnce({body: response}); - await poe.upload(importArg); - expect(readStream).toHaveBeenCalledWith( - '/Users/simon/translations/messages-en.properties' - ); - expect(post).toHaveBeenCalledWith(expectedOptions); - expect(console.log.mock.calls).toEqual([ - ['en'], - [{total: 177}], - ['Slack channel not defined (.env). Unable to notify.'] - ]); - }); -}); diff --git a/scripts/poe/tests/poe-languages.js b/scripts/poe/tests/poe-languages.js deleted file mode 100644 index 06538e4b113..00000000000 --- a/scripts/poe/tests/poe-languages.js +++ /dev/null @@ -1,29 +0,0 @@ -const poe = require('../lib/poe'); -const post = require('../lib/post'); - -jest.mock('../lib/post'); - -const arg = { id1: '123', id2: '456' }; -const response = JSON.stringify({result: {languages: [{code: 'en'}]}}); -const expectedOptions = { - body: 'id1=123&id2=456', - headers: { - 'Content-Length': 15, - 'Content-Type': 'application/x-www-form-urlencoded' - }, - url: 'http://poe/languages/list' -}; - -describe('poe', () => { - beforeEach(() => { - console = { log: jest.fn() }; - process = { env: {POE_API_URL: 'http://poe'} }; - }); - - test('languages', async () => { - post.mockResolvedValueOnce({body: response}); - await poe.languages(arg); - expect(post).toHaveBeenCalledWith(expectedOptions); - expect(console.log).toHaveBeenCalledTimes(1); - }); -}); diff --git a/scripts/poe/tests/slack.js b/scripts/poe/tests/slack.js deleted file mode 100644 index 4b8fde512f5..00000000000 --- a/scripts/poe/tests/slack.js +++ /dev/null @@ -1,15 +0,0 @@ -const slack = require('../lib/slack'); -const {post} = require('request'); - -jest.mock('request'); - -describe('slack', () => { - test('send', () => { - post.mockResolvedValueOnce(null); - slack('someurl').send('hello'); - expect(post).toHaveBeenCalledWith( - {'json': {'text': 'hello'}, 'url': 'someurl'}, - expect.any(Function) - ); - }); -}); diff --git a/scripts/poe/tests/sorting.js b/scripts/poe/tests/sorting.js deleted file mode 100644 index 961156c511b..00000000000 --- a/scripts/poe/tests/sorting.js +++ /dev/null @@ -1,22 +0,0 @@ -const path = require('path'); -const fs = require('fs'); -const {save} = require('../lib/utils'); -const get = require('../lib/get'); -const fPath = path.resolve(__dirname, 'translations', 'sorting-en.properties'); - -jest.mock('../lib/get'); - -afterEach(() => { - fs.unlinkSync(fPath); -}); - -describe('save', () => { - test('sorting', async () => { - get.mockResolvedValueOnce({body: 'yesterday:yes\ntoday:no'}); - await save('http://poe', fPath); - const content = fs.readFileSync(fPath).toString(); - const lines = content.split('\n'); - expect(lines[0]).toEqual('today:no'); - expect(lines[1]).toEqual('yesterday:yes'); - }); -}); diff --git a/scripts/poe/tests/translations/badname-en b/scripts/poe/tests/translations/badname-en deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/scripts/poe/tests/translations/badname.properties b/scripts/poe/tests/translations/badname.properties deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/scripts/poe/tests/translations/good-en.properties b/scripts/poe/tests/translations/good-en.properties deleted file mode 100644 index af52594a21e..00000000000 --- a/scripts/poe/tests/translations/good-en.properties +++ /dev/null @@ -1,2 +0,0 @@ -one=one -two=dos diff --git a/scripts/poe/tests/translations/matching-placeholders/messages-en.properties b/scripts/poe/tests/translations/matching-placeholders/messages-en.properties deleted file mode 100644 index f94c6c59f65..00000000000 --- a/scripts/poe/tests/translations/matching-placeholders/messages-en.properties +++ /dev/null @@ -1,6 +0,0 @@ -Accept\ plain-text\ messages = Accept plain-text messages -Number\ of\ facilities = {{number}} places -Number\ of\ form\ types = {{number}} form types -Omitting\ one\ placeholder = {{one}} and {{two}} -No\ placeholder\ in\ reference\ language = Not using a placeholder -Multiple\ placeholders\ in\ different\ order = {{one}} {{two}} {{two}} {{three}} \ No newline at end of file diff --git a/scripts/poe/tests/translations/matching-placeholders/messages-ex.properties b/scripts/poe/tests/translations/matching-placeholders/messages-ex.properties deleted file mode 100644 index b621b7702cb..00000000000 --- a/scripts/poe/tests/translations/matching-placeholders/messages-ex.properties +++ /dev/null @@ -1 +0,0 @@ -No\ placeholder\ in\ reference\ language = {{decimal}} \ No newline at end of file diff --git a/scripts/poe/tests/translations/matching-placeholders/messages-sw.properties b/scripts/poe/tests/translations/matching-placeholders/messages-sw.properties deleted file mode 100644 index 624024e0307..00000000000 --- a/scripts/poe/tests/translations/matching-placeholders/messages-sw.properties +++ /dev/null @@ -1,6 +0,0 @@ -Accept\ plain-text\ messages = itikia ujumbe wa kawaida -Number\ of\ facilities = {{number}} mahali -Number\ of\ form\ types = {{number}} ya aina fomu -Omitting\ one\ placeholder = {{one}} -No\ placeholder\ in\ reference\ language = Using {{decimal}} -Multiple\ placeholders\ in\ different\ order = {{three}} {{one}} {{two}} diff --git a/scripts/poe/tests/translations/non-matching-placeholders/messages-en.properties b/scripts/poe/tests/translations/non-matching-placeholders/messages-en.properties deleted file mode 100644 index 162d702658e..00000000000 --- a/scripts/poe/tests/translations/non-matching-placeholders/messages-en.properties +++ /dev/null @@ -1,3 +0,0 @@ -Accept\ plain-text\ messages = Accept plain-text messages -Number\ of\ facilities = {{number}} places -Number\ of\ form\ types = {{number}} form types \ No newline at end of file diff --git a/scripts/poe/tests/translations/non-matching-placeholders/messages-ex.properties b/scripts/poe/tests/translations/non-matching-placeholders/messages-ex.properties deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/scripts/poe/tests/translations/non-matching-placeholders/messages-sw.properties b/scripts/poe/tests/translations/non-matching-placeholders/messages-sw.properties deleted file mode 100644 index 0a1f161207d..00000000000 --- a/scripts/poe/tests/translations/non-matching-placeholders/messages-sw.properties +++ /dev/null @@ -1,4 +0,0 @@ -Accept\ plain-text\ messages = itikia ujumbe wa kawaida -Number\ of\ facilities = {{nambari}} mahali -Number\ of\ form\ types = {{nambari}} ya aina fomu -Empty\ text = diff --git a/scripts/poe/tests/usage.js b/scripts/poe/tests/usage.js deleted file mode 100644 index e96d0a306b2..00000000000 --- a/scripts/poe/tests/usage.js +++ /dev/null @@ -1,10 +0,0 @@ -const usage = require('../lib/usage'); - -describe('usage', () => { - test('show', () => { - console = { log: jest.fn() }; - expect(console.log).not.toHaveBeenCalled(); - usage.show(); - expect(console.log).toHaveBeenCalled(); - }); -}); diff --git a/scripts/poe/tests/utils.js b/scripts/poe/tests/utils.js deleted file mode 100644 index 645ca19f3e9..00000000000 --- a/scripts/poe/tests/utils.js +++ /dev/null @@ -1,11 +0,0 @@ -const utils = require('../lib/utils'); - -describe('utils', () => { - test('capitalize', () => { - expect(utils.capitalize('simon')).toBe('Simon'); - }); - - test('mmVersion', () => { - expect(utils.mmVersion().length).toBeTruthy(); - }); -}); diff --git a/scripts/poe/tests/validate.js b/scripts/poe/tests/validate.js deleted file mode 100644 index fa21ea779c9..00000000000 --- a/scripts/poe/tests/validate.js +++ /dev/null @@ -1,67 +0,0 @@ -const {validTranslations, validDirectory, validatePlaceHolders} = require('../lib/validate'); -const valid = validTranslations; -const path = require('path'); -const utils = require('../lib/utils'); - -describe('validate', () => { - - const originalLog = utils.log; - const originalInfo = utils.info; - const originalError = utils.error; - - beforeEach(() => { - utils.log = jest.fn(); - utils.info = jest.fn(); - utils.error = jest.fn(); - }); - - afterEach(() => { - utils.log = originalLog; - utils.info = originalInfo; - utils.error = originalError; - }); - - test('translation file exists', () => { - expect(valid('tests/messages-en.properties')).toBeFalsy(); - }); - - test('translation file name', () => { - expect(valid('tests/translation/badname.properties')).toBeFalsy(); - expect(valid('tests/translation/badname-en')).toBeFalsy(); - }); - - test('good translation content', () => { - expect(valid('tests/translations/good-en.properties')).toBeTruthy(); - }); - - test('directory', () => { - expect(validDirectory('tests/translations')).toBeTruthy(); - }); - - test('successful matching translation placeholders', async () => { - const matchingPlaceholderDir = path.resolve(__dirname, 'translations', 'matching-placeholders'); - await validatePlaceHolders(['en', 'sw'], matchingPlaceholderDir); - expect(utils.error).toHaveBeenCalledTimes(0); - }); - - test('error on non-matching translation placeholders', async () => { - const nonMatchingPlaceholderDir = path.resolve(__dirname, 'translations', 'non-matching-placeholders'); - await validatePlaceHolders(['en', 'sw'], nonMatchingPlaceholderDir); - expect(utils.info).toHaveBeenCalledWith( - 'Found 1 empty translations trying to compile' - ); - expect(utils.error).toHaveBeenCalledWith( - 'Cannot compile \'sw\' translation with key \'Number of facilities\' has placeholders ' + - 'that do not match any in the base translation provided' - ); - expect(utils.error).toHaveBeenCalledWith( - 'Cannot compile \'sw\' translation with key \'Number of form types\' has placeholders ' + - 'that do not match any in the base translation provided' - ); - expect(utils.error).toHaveBeenCalledWith( - 'Found 2 errors trying to compile\n' + - 'You can use messages-ex.properties to add placeholders missing from the reference context.' - ); - }); - -}); diff --git a/scripts/release-notes/index.js b/scripts/release-notes/index.js index 489168a9eb4..81229d21387 100644 --- a/scripts/release-notes/index.js +++ b/scripts/release-notes/index.js @@ -8,6 +8,7 @@ const octokit = new ExtendedOctokit({ }); const OWNER = 'medic'; +const BOTS = ['dependabot[bot]']; const argv = minimist(process.argv.slice(2)); if (argv.help) { @@ -100,6 +101,7 @@ const getCommitsForRelease = async (release, milestoneBranch) => queryRepoPagina nodes { oid messageHeadline + author { user { login, name, url } } associatedPullRequests(first: 50) { nodes { milestone { id } @@ -159,13 +161,10 @@ const findCommitsWithoutMilestone = async (commitsForRelease) => { return commitsWithoutMilestone; }; -const validateCommits = async () => { +const validateCommits = async (commitsForRelease) => { if (argv['skip-commit-validation']) { return; } - const latestReleaseName = await getLatestReleaseName(); - const milestoneBranch = await getMilestoneBranch(); - const commitsForRelease = await getCommitsForRelease(latestReleaseName, milestoneBranch); const commitsWithoutMilestone = await findCommitsWithoutMilestone(commitsForRelease); if (commitsWithoutMilestone.length) { @@ -179,7 +178,6 @@ Some commits included in the release are not associated with a milestone. Commit Commits: `); commitsWithoutMilestone.forEach(commit => console.error(`- ${commit.oid}: ${commit.messageHeadline}`)); - throw new Error('Some commits are in an invalid state. Use --skip-commit-validation to ignore this check.'); } }; @@ -231,7 +229,6 @@ const validateIssues = issues => { console.error(JSON.stringify(errors, null, 2)); throw new Error('Some issues are in an invalid state'); } - return issues; }; const filterIssues = issues => { @@ -260,11 +257,29 @@ const format = issue => `- [#${issue.number}](${issue.url}): ${issue.title}\n`; const formatAll = issues => issues.length ? issues.map(format).join('') : 'None.\n'; -const outputGroups = (groups) => { - return groups.map(group => `### ${group.title}\n\n${formatAll(group.issues)}\n`).join(''); +const formatGroups = (groups) => { + return groups + .map(group => `### ${group.title}\n\n${formatAll(group.issues)}\n`) + .join(''); +}; + +const formatCommits = (commits) => { + const ignoreLogins = BOTS; + const lines = []; + for (const commit of commits) { + const login = commit.author?.user?.login; + if (login && !ignoreLogins.includes(login)) { + ignoreLogins.push(login); + const user = commit.author.user; + const name = user.name || user.login; + const profileUrl = user.url; + lines.push(`- [${name}](${profileUrl})`); + } + } + return lines.join('\n'); }; -const output = ({ warnings, types }) => { +const output = ({ warnings, types }, commits) => { console.log(` --- title: "${MILESTONE_NAME} release notes" @@ -281,22 +296,40 @@ Check the repository for the [latest known issues](https://github.com/medic/cht- ## Upgrade notes -${outputGroups(warnings)} +${formatGroups(warnings)} ## Highlights <<< TODO >>> ## And more... -${outputGroups(types)}`); +${formatGroups(types)} + +## Contributors + +Thanks to all who committed changes for this release! + +${formatCommits(commits)} +`); +}; + +const getCommits = async () => { + const latestReleaseName = await getLatestReleaseName(); + const milestoneBranch = await getMilestoneBranch(); + const commitsForRelease = await getCommitsForRelease(latestReleaseName, milestoneBranch); + validateCommits(commitsForRelease); + return commitsForRelease; +}; + +const getIssues = async () => { + const milestoneNumber = await getMilestoneNumber(); + const issues = await getMilestoneIssues(milestoneNumber); + await validateIssues(issues); + const filtered = await filterIssues(issues); + const grouped = await groupIssues(filtered); + return grouped; }; -Promise.resolve() - .then(validateCommits) - .then(getMilestoneNumber) - .then(getMilestoneIssues) - .then(validateIssues) - .then(filterIssues) - .then(groupIssues) - .then(output) +Promise.all([ getIssues(), getCommits() ]) + .then(([ issues, commits ]) => output(issues, commits)) .catch(console.error); diff --git a/sentinel/.eslintrc b/sentinel/.eslintrc index 7e536c8bf48..a71108b32bb 100644 --- a/sentinel/.eslintrc +++ b/sentinel/.eslintrc @@ -13,7 +13,7 @@ { "allowModules": [ "@medic/bulk-docs-utils", - "@medic/cht-script-api", + "@medic/cht-datasource", "@medic/contact-types-utils", "@medic/contacts", "@medic/couch-request", diff --git a/sentinel/package-lock.json b/sentinel/package-lock.json index da10e2af93a..a068ead10ef 100644 --- a/sentinel/package-lock.json +++ b/sentinel/package-lock.json @@ -38,7 +38,7 @@ "npm": ">=10.2.4" } }, - "../shared-libs/cht-script-api": { + "../shared-libs/cht-datasource": { "version": "1.0.0", "extraneous": true, "license": "Apache-2.0" diff --git a/sentinel/package.json b/sentinel/package.json index 533be5e131c..5dd95948dca 100644 --- a/sentinel/package.json +++ b/sentinel/package.json @@ -7,6 +7,9 @@ "node": ">=20.11.0", "npm": ">=10.2.4" }, + "scripts": { + "run-watch": "TZ=UTC nodemon --inspect=0.0.0.0:9228 --watch server.js --watch 'src/**' --watch '../shared-libs/**/src/**' --watch '../shared-libs/**/dist/**' server.js" + }, "dependencies": { "async": "^3.2.4", "bikram-sambat": "^1.7.0", diff --git a/sentinel/src/config.js b/sentinel/src/config.js index 8614e05c1a0..b5469a65cd8 100644 --- a/sentinel/src/config.js +++ b/sentinel/src/config.js @@ -77,7 +77,7 @@ const initConfig = () => { }; const initTransitionLib = () => { - transitionsLib = require('@medic/transitions')(db, module.exports); + transitionsLib = require('@medic/transitions')(db, module.exports, require('./data-context')); }; module.exports = { diff --git a/sentinel/src/data-context.js b/sentinel/src/data-context.js new file mode 100644 index 00000000000..53e48e2bfa8 --- /dev/null +++ b/sentinel/src/data-context.js @@ -0,0 +1,5 @@ +const { getLocalDataContext } = require('@medic/cht-datasource'); +const db = require('./db'); +const config = require('./config'); + +module.exports = getLocalDataContext(config, db); diff --git a/sentinel/src/lib/purging.js b/sentinel/src/lib/purging.js index 9bbf4e37181..0773cf4a8e8 100644 --- a/sentinel/src/lib/purging.js +++ b/sentinel/src/lib/purging.js @@ -1,11 +1,12 @@ const config = require('../config'); const registrationUtils = require('@medic/registration-utils'); const serverSidePurgeUtils = require('@medic/purging-utils'); -const chtScriptApi = require('@medic/cht-script-api'); +const cht = require('@medic/cht-datasource'); const logger = require('@medic/logger'); const environment = require('@medic/environment'); const { performance } = require('perf_hooks'); const db = require('../db'); +const dataContext = require('../data-context'); const moment = require('moment'); const TASK_EXPIRATION_PERIOD = 60; // days @@ -16,6 +17,7 @@ const VIEW_LIMIT = 100 * 1000; const MAX_BATCH_SIZE = 20 * 1000; const MIN_BATCH_SIZE = 5 * 1000; const MAX_BATCH_SIZE_REACHED = 'max_size_reached'; + let contactsBatchSize = MAX_CONTACT_BATCH_SIZE; let skippedContacts = []; @@ -347,7 +349,7 @@ const getDocsToPurge = (purgeFn, groups, roles) => { group.contact, group.reports, group.messages, - chtScriptApi, + cht.getDatasource(dataContext), permissionSettings ); if (!validPurgeResults(idsToPurge)) { @@ -455,13 +457,14 @@ const purgeUnallocatedRecords = async (roles, purgeFn) => { const getIdsToPurge = (rolesHashes, rows) => { const toPurge = {}; + const datasource = cht.getDatasource(dataContext); rows.forEach(row => { const doc = row.doc; rolesHashes.forEach(hash => { toPurge[hash] = toPurge[hash] || {}; const purgeIds = doc.form ? - purgeFn({ roles: roles[hash] }, {}, [doc], [], chtScriptApi, permissionSettings) : - purgeFn({ roles: roles[hash] }, {}, [], [doc], chtScriptApi, permissionSettings); + purgeFn({ roles: roles[hash] }, {}, [doc], [], datasource, permissionSettings) : + purgeFn({ roles: roles[hash] }, {}, [], [doc], datasource, permissionSettings); if (!validPurgeResults(purgeIds)) { return; diff --git a/sentinel/tests/unit/lib/purging.spec.js b/sentinel/tests/unit/lib/purging.spec.js index b8b31b7ad9c..006e406d73e 100644 --- a/sentinel/tests/unit/lib/purging.spec.js +++ b/sentinel/tests/unit/lib/purging.spec.js @@ -11,7 +11,7 @@ const registrationUtils = require('@medic/registration-utils'); const config = require('../../../src/config'); const purgingUtils = require('@medic/purging-utils'); const db = require('../../../src/db'); -const chtScriptApi = require('@medic/cht-script-api'); +const chtDatasource = require('@medic/cht-datasource'); let service; let clock; @@ -2532,13 +2532,14 @@ describe('ServerSidePurge', () => { const purgeDbChanges = sinon.stub().resolves({ results: [] }); sinon.stub(db, 'get').returns({ changes: purgeDbChanges, bulkDocs: sinon.stub() }); sinon.stub(config, 'get').returns({ can_export_messages: [ 1 ]}); - sinon.stub(chtScriptApi.v1, 'hasPermissions'); + const mockDatasource = { v1: { hasPermissions: sinon.stub() } }; + sinon.stub(chtDatasource, 'getDatasource').returns(mockDatasource); return service.__get__('batchedContactsPurge')(roles, purgeFunction).then(() => { - chai.expect(chtScriptApi.v1.hasPermissions.args[0]).to.deep.equal( + chai.expect(mockDatasource.v1.hasPermissions.args[0]).to.deep.equal( [ 'can_export_messages', [ 1, 2, 3 ], { can_export_messages: [ 1 ] } ] ); - chai.expect(chtScriptApi.v1.hasPermissions.args[1]).to.deep.equal( + chai.expect(mockDatasource.v1.hasPermissions.args[1]).to.deep.equal( [ 'can_export_messages', [ 4, 5, 6 ], { can_export_messages: [ 1 ] } ] ); }); @@ -2558,13 +2559,14 @@ describe('ServerSidePurge', () => { const purgeDbChanges = sinon.stub().resolves({ results: [] }); sinon.stub(db, 'get').returns({ changes: purgeDbChanges, bulkDocs: sinon.stub() }); sinon.stub(config, 'get').returns({ can_export_messages: [ 1 ]}); - sinon.stub(chtScriptApi.v1, 'hasAnyPermission'); + const mockDatasource = { v1: { hasAnyPermission: sinon.stub() } }; + sinon.stub(chtDatasource, 'getDatasource').returns(mockDatasource); return service.__get__('batchedContactsPurge')(roles, purgeFunction).then(() => { - chai.expect(chtScriptApi.v1.hasAnyPermission.args[0]).to.deep.equal( + chai.expect(mockDatasource.v1.hasAnyPermission.args[0]).to.deep.equal( [ ['can_export_messages', 'can_edit'], [ 1, 2, 3 ], { can_export_messages: [ 1 ] } ] ); - chai.expect(chtScriptApi.v1.hasAnyPermission.args[1]).to.deep.equal( + chai.expect(mockDatasource.v1.hasAnyPermission.args[1]).to.deep.equal( [ ['can_export_messages', 'can_edit'], [ 4, 5, 6 ], { can_export_messages: [ 1 ] } ] ); }); diff --git a/shared-libs/cht-datasource/.eslintrc.js b/shared-libs/cht-datasource/.eslintrc.js new file mode 100644 index 00000000000..061b1e55a75 --- /dev/null +++ b/shared-libs/cht-datasource/.eslintrc.js @@ -0,0 +1,50 @@ +module.exports = { + overrides: [ + { + files: ['*.ts'], + extends: [ + // https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/src/configs/strict-type-checked.ts + 'plugin:@typescript-eslint/strict-type-checked', + // https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/src/configs/stylistic-type-checked.ts + 'plugin:@typescript-eslint/stylistic-type-checked', + 'plugin:jsdoc/recommended-typescript-error', + 'plugin:compat/recommended', + ], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'jsdoc', 'compat'], + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname + }, + settings: { + jsdoc: { + contexts: [ + 'VariableDeclaration', + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + 'TSEnumDeclaration', + 'TSMethodSignature' + ] + } + }, + rules: { + ['@typescript-eslint/explicit-module-boundary-types']: ['error', { allowedNames: ['getDatasource'] }], + ['@typescript-eslint/no-confusing-void-expression']: ['error', { ignoreArrowShorthand: true }], + ['@typescript-eslint/no-empty-interface']: ['error', { allowSingleExtends: true }], + ['@typescript-eslint/no-namespace']: 'off', + ['@typescript-eslint/no-non-null-assertion']: 'off', + ['jsdoc/require-jsdoc']: ['error', { + require: { + ArrowFunctionExpression: true, + ClassDeclaration: true, + ClassExpression: true, + FunctionDeclaration: true, + FunctionExpression: true, + MethodDefinition: true, + }, + publicOnly: true, + }] + } + } + ] +}; diff --git a/shared-libs/cht-datasource/.mocharc.js b/shared-libs/cht-datasource/.mocharc.js new file mode 100644 index 00000000000..a13ab2feaf0 --- /dev/null +++ b/shared-libs/cht-datasource/.mocharc.js @@ -0,0 +1,7 @@ +const chaiAsPromised = require('chai-as-promised'); +const chai = require('chai'); +chai.use(chaiAsPromised); + +module.exports = { + require: 'ts-node/register' +}; diff --git a/shared-libs/cht-datasource/README.md b/shared-libs/cht-datasource/README.md new file mode 100644 index 00000000000..bc35948cf51 --- /dev/null +++ b/shared-libs/cht-datasource/README.md @@ -0,0 +1,30 @@ +# CHT Datasource + +The CHT Datasource library is intended to be agnostic and simple. It provides a versioned API from feature modules. + +See the TSDoc in [the code](./src/index.ts) for more information about using the API. + +## Development + +Functionality in cht-datasource is provided via two implementations. The [`local` adapter](./src/local) leverages the provided PouchDB instances for data interaction. This is intended for usage in cases where offline functionality is required (like webapp for offline users) or direct access to the Couch database is guaranteed (like api and sentinel). The [`remote` adapter](./src/remote) functions by proxying requests directly to the api server via HTTP. This is intended for usage in cases where connectivity to the api server is guaranteed (like in admin or webapp for online users). + +### Building cht-datasource + +The transpiled JavaScript code is generated in the [`dist` directory](./dist). The library is automatically built when running `npm ci` (either within the `cht-datasource` directory or from the root level). To manually build the library, run `npm run build`. To automatically re-build the library when any of the source files change, run `npm run build-watch`. + +The root level `build-dev-watch`, `dev-api`, and `dev-sentinel` scripts will automatically watch for changes in the cht-datasource code and rebuild the library. + +### Adding a new API + +When adding a new API to cht-datasource (whether it is a new concept or just a new interaction with an existing concept), the implementation must be completed at four levels: + +1) Implement the interaction in the [`local`](./src/local) and [`remote`](./src/remote) adapters. +2) Expose a unified interface for the interaction from the relevant top-level [concept module](./src). +3) Expose the new concept interaction by adding it to the datasource returned from the [index.ts](./src/index.ts). +4) Implement the necessary endpoint(s) in [api](../../api) to support the new interaction (these are the endpoints called by the remote adapter code). + +### Updating functionality + +Only passive changes should be made to the versioned public API's exposed by cht-datasource. Besides their usage in cht-core, these API's are available to custom configuration code for things like purging, tasks, targets, etc. If a non-passive change is needed, it should be made on a new version of the API. + +The previous version of the functionality should be marked as `@deprecated` and, where possible, all usages in the cht-core code should be updated to use the new API. diff --git a/shared-libs/cht-script-api/package-lock.json b/shared-libs/cht-datasource/package-lock.json similarity index 68% rename from shared-libs/cht-script-api/package-lock.json rename to shared-libs/cht-datasource/package-lock.json index b0c6f726b16..2a4be92f4f5 100644 --- a/shared-libs/cht-script-api/package-lock.json +++ b/shared-libs/cht-datasource/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@medic/cht-script-api", + "name": "@medic/cht-datasource", "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "@medic/cht-script-api", + "name": "@medic/cht-datasource", "version": "1.0.0", "license": "Apache-2.0" } diff --git a/shared-libs/cht-datasource/package.json b/shared-libs/cht-datasource/package.json new file mode 100644 index 00000000000..2b3a9b815f8 --- /dev/null +++ b/shared-libs/cht-datasource/package.json @@ -0,0 +1,23 @@ +{ + "name": "@medic/cht-datasource", + "version": "1.0.0", + "description": "Provides an API for the CHT data model", + "main": "dist/index.js", + "files": [ + "/dist" + ], + "scripts": { + "postinstall": "rm -rf dist && npm run build", + "build": "tsc -p tsconfig.build.json", + "build-watch": "tsc --watch -p tsconfig.build.json", + "test": "nyc --nycrcPath='../nyc.config.js' mocha \"test/**/*\"", + "gen-docs": "typedoc ./src/index.ts" + }, + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@medic/contact-types-utils": "file:../contact-types-utils", + "@medic/logger": "file:../logger", + "typedoc": "^0.25.13" + } +} diff --git a/shared-libs/cht-script-api/src/auth.js b/shared-libs/cht-datasource/src/auth.js similarity index 87% rename from shared-libs/cht-script-api/src/auth.js rename to shared-libs/cht-datasource/src/auth.js index 69253e9c0a3..99cefa06b9c 100644 --- a/shared-libs/cht-script-api/src/auth.js +++ b/shared-libs/cht-datasource/src/auth.js @@ -65,6 +65,22 @@ const verifyParameters = (permissions, userRoles, chtPermissionsSettings) => { return true; }; +const normalizePermissions = (permissions) => { + if (permissions && typeof permissions === 'string') { + return [permissions]; + } + return permissions; +}; + +const checkAdminPermissions = (disallowedGroupList, permissions, userRoles) => { + if (disallowedGroupList.every(permissions => permissions.length)) { + debug('Disallowed permission(s) found for admin', permissions, userRoles); + return false; + } + // Admin has the permissions automatically. + return true; +}; + /** * Verify if the user's role has the permission(s). * @param permissions {string | string[]} Permission(s) to verify @@ -73,9 +89,7 @@ const verifyParameters = (permissions, userRoles, chtPermissionsSettings) => { * @return {boolean} */ const hasPermissions = (permissions, userRoles, chtPermissionsSettings) => { - if (permissions && typeof permissions === 'string') { - permissions = [ permissions ]; - } + permissions = normalizePermissions(permissions); if (!verifyParameters(permissions, userRoles, chtPermissionsSettings)) { return false; @@ -84,12 +98,7 @@ const hasPermissions = (permissions, userRoles, chtPermissionsSettings) => { const { allowed, disallowed } = groupPermissions(permissions); if (isAdmin(userRoles)) { - if (disallowed.length) { - debug('Disallowed permission(s) found for admin', permissions, userRoles); - return false; - } - // Admin has the permissions automatically. - return true; + return checkAdminPermissions([disallowed], permissions, userRoles); } const hasDisallowed = !checkUserHasPermissions(disallowed, userRoles, chtPermissionsSettings, false); @@ -135,12 +144,7 @@ const hasAnyPermission = (permissionsGroupList, userRoles, chtPermissionsSetting }); if (isAdmin(userRoles)) { - if (disallowedGroupList.every(permissions => permissions.length)) { - debug('Disallowed permission(s) found for admin', permissionsGroupList, userRoles); - return false; - } - // Admin has the permissions automatically. - return true; + return checkAdminPermissions(disallowedGroupList, permissionsGroupList, userRoles); } const hasAnyPermissionGroup = permissionsGroupList.some((permissions, i) => { diff --git a/shared-libs/cht-datasource/src/index.ts b/shared-libs/cht-datasource/src/index.ts new file mode 100644 index 00000000000..1ff1e485bc1 --- /dev/null +++ b/shared-libs/cht-datasource/src/index.ts @@ -0,0 +1,91 @@ +/** + * CHT datasource. + * + * This module provides a simple API for interacting with CHT data. To get started, obtain a {@link DataContext}. Then + * use the context to perform data operations. There are two different usage modes available for performing the same + * operations. + * @example Get Data Context: + * import { getRemoteDataContext, getLocalDataContext } from '@medic/cht-datasource'; + * + * const dataContext = isOnlineOnly + * ? getRemoteDataContext(...) + * : getLocalDataContext(...); + * @example Declarative usage mode: + * import { Person, Qualifier } from '@medic/cht-datasource'; + * + * const getPerson = Person.v1.get(dataContext); + * // Or + * const getPerson = dataContext.bind(Person.v1.get); + * + * const myUuid = 'my-uuid'; + * const myPerson = await getPerson(Qualifier.byUuid(uuid)); + * @example Imperative usage mode: + * import { getDatasource } from '@medic/cht-datasource'; + * + * const datasource = getDatasource(dataContext); + * const myUuid = 'my-uuid'; + * const myPerson = await datasource.v1.person.getByUuid(myUuid); + */ +import { hasAnyPermission, hasPermissions } from './auth'; +import { assertDataContext, DataContext } from './libs/data-context'; +import * as Person from './person'; +import * as Place from './place'; +import * as Qualifier from './qualifier'; + +export { Nullable, NonEmptyArray } from './libs/core'; +export { DataContext } from './libs/data-context'; +export { getLocalDataContext } from './local'; +export { getRemoteDataContext } from './remote'; +export * as Person from './person'; +export * as Place from './place'; +export * as Qualifier from './qualifier'; + +/** + * Returns the source for CHT data. + * @param ctx the current data context + * @returns the CHT datasource API + * @throws Error if the provided context is invalid + */ +export const getDatasource = (ctx: DataContext) => { + assertDataContext(ctx); + return { + v1: { + hasPermissions, + hasAnyPermission, + place: { + /** + * Returns a place by its UUID. + * @param uuid the UUID of the place to retrieve + * @returns the place or `null` if no place is found for the UUID + * @throws Error if no UUID is provided + */ + getByUuid: (uuid: string) => ctx.bind(Place.v1.get)(Qualifier.byUuid(uuid)), + + /** + * Returns a place by its UUID along with the place's parent lineage. + * @param uuid the UUID of the place to retrieve + * @returns the place or `null` if no place is found for the UUID + * @throws Error if no UUID is provided + */ + getByUuidWithLineage: (uuid: string) => ctx.bind(Place.v1.getWithLineage)(Qualifier.byUuid(uuid)), + }, + person: { + /** + * Returns a person by their UUID. + * @param uuid the UUID of the person to retrieve + * @returns the person or `null` if no person is found for the UUID + * @throws Error if no UUID is provided + */ + getByUuid: (uuid: string) => ctx.bind(Person.v1.get)(Qualifier.byUuid(uuid)), + + /** + * Returns a person by their UUID along with the person's parent lineage. + * @param uuid the UUID of the person to retrieve + * @returns the person or `null` if no person is found for the UUID + * @throws Error if no UUID is provided + */ + getByUuidWithLineage: (uuid: string) => ctx.bind(Person.v1.getWithLineage)(Qualifier.byUuid(uuid)), + } + } + }; +}; diff --git a/shared-libs/cht-datasource/src/libs/contact.ts b/shared-libs/cht-datasource/src/libs/contact.ts new file mode 100644 index 00000000000..63c0f505b93 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/contact.ts @@ -0,0 +1,22 @@ +import { Doc } from './doc'; +import { DataObject, Identifiable, isDataObject, isIdentifiable } from './core'; + +/** @internal */ +export interface NormalizedParent extends DataObject, Identifiable { + readonly parent?: NormalizedParent; +} + +/** @internal */ +export const isNormalizedParent = (value: unknown): value is NormalizedParent => { + return isDataObject(value) + && isIdentifiable(value) + && (!value.parent || isNormalizedParent(value.parent)); +}; + +/** @internal */ +export interface Contact extends Doc, NormalizedParent { + readonly contact_type?: string; + readonly name?: string; + readonly reported_date?: Date; + readonly type: string; +} diff --git a/shared-libs/cht-datasource/src/libs/core.ts b/shared-libs/cht-datasource/src/libs/core.ts new file mode 100644 index 00000000000..f2475cc84f8 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/core.ts @@ -0,0 +1,114 @@ +import { DataContext } from './data-context'; + +/** + * A value that could be `null`. + */ +export type Nullable = T | null; + +/** @internal */ +export const isNotNull = (value: T | null): value is T => value !== null; + +/** + * An array that is guaranteed to have at least one element. + */ +export type NonEmptyArray = [T, ...T[]]; + +/** @internal */ +export const isNonEmptyArray = (value: T[]): value is NonEmptyArray => !!value.length; + +/** @internal */ +export const getLastElement = (array: NonEmptyArray): T => array[array.length - 1]; + +type DataValue = DataPrimitive | DataArray | DataObject; +type DataPrimitive = string | number | boolean | Date | null | undefined; + +const isDataPrimitive = (value: unknown): value is DataPrimitive => { + return value === null + || value === undefined + || typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + || value instanceof Date; +}; + +interface DataArray extends Readonly { } + +const isDataArray = (value: unknown): value is DataArray => { + return Array.isArray(value) && value.every(v => isDataPrimitive(v) || isDataArray(v) || isDataObject(v)); +}; + +/** @internal */ +export interface DataObject extends Readonly> { } + +/** @internal */ +export const isDataObject = (value: unknown): value is DataObject => { + if (!isRecord(value)) { + return false; + } + return Object + .values(value) + .every((v) => isDataPrimitive(v) || isDataArray(v) || isDataObject(v)); +}; + +/** + * Ideally, this function should only be used at the edge of this library (when returning potentially cross-referenced + * data objects) to avoid unintended consequences if any of the objects are edited in-place. This function should not + * be used for logic internal to this library since all data objects are marked as immutable. + * This could be replaced by [structuredClone](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) + * in CHT 5.x, or earlier if using a polyfill or a similar implementation like `_.cloneDeep()`. + * @internal + */ +export const deepCopy = (value: T): T => { + if (isDataPrimitive(value)) { + return value; + } + if (isDataArray(value)) { + return value.map(deepCopy) as unknown as T; + } + + return Object.fromEntries( + Object + .entries(value) + .map(([key, value]) => [key, deepCopy(value)]) + ) as unknown as T; +}; + +/** @internal */ +export const isString = (value: unknown): value is string => { + return typeof value === 'string'; +}; + +/** @internal */ +export const isRecord = (value: unknown): value is Record => { + return value !== null && typeof value === 'object'; +}; + +/** @internal */ +export const hasField = (value: Record, field: { name: string, type: string }): boolean => { + const valueField = value[field.name]; + return typeof valueField === field.type; +}; + +/** @internal */ +export const hasFields = ( + value: Record, + fields: NonEmptyArray<{ name: string, type: string }> +): boolean => fields.every(field => hasField(value, field)); + +/** @internal */ +export interface Identifiable extends DataObject { + readonly _id: string +} + +/** @internal */ +export const isIdentifiable = (value: unknown): value is Identifiable => isRecord(value) + && hasField(value, { name: '_id', type: 'string' }); + +/** @internal */ +export const findById = (values: T[], id: string): Nullable => values + .find(v => v._id === id) ?? null; + +/** @internal */ +export abstract class AbstractDataContext implements DataContext { + readonly bind = (fn: (ctx: DataContext) => T): T => fn(this); +} diff --git a/shared-libs/cht-datasource/src/libs/data-context.ts b/shared-libs/cht-datasource/src/libs/data-context.ts new file mode 100644 index 00000000000..d58c017c780 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/data-context.ts @@ -0,0 +1,41 @@ +import { hasField, isRecord } from './core'; +import { isLocalDataContext, LocalDataContext } from '../local/libs/data-context'; +import { assertRemoteDataContext, isRemoteDataContext, RemoteDataContext } from '../remote/libs/data-context'; + +/** + * Context for interacting with the data. This may represent a local data context where data can be accessed even while + * offline. Or it may represent a remote data context where all data operations are performed against a remote CHT + * instance. + */ +export interface DataContext { + /** + * Executes the provided function with this data context as the argument. + * @param fn the function to execute + * @returns the result of the function + */ + bind: (fn: (ctx: DataContext) => T) => T +} + +const isDataContext = (context: unknown): context is DataContext => { + return isRecord(context) && hasField(context, { name: 'bind', type: 'function' }); +}; + +/** @internal */ +export const assertDataContext: (context: unknown) => asserts context is DataContext = (context: unknown) => { + if (!isDataContext(context) || !(isLocalDataContext(context) || isRemoteDataContext(context))) { + throw new Error(`Invalid data context [${JSON.stringify(context)}].`); + } +}; + +/** @internal */ +export const adapt = ( + context: DataContext, + local: (c: LocalDataContext) => T, + remote: (c: RemoteDataContext) => T +): T => { + if (isLocalDataContext(context)) { + return local(context); + } + assertRemoteDataContext(context); + return remote(context); +}; diff --git a/shared-libs/cht-datasource/src/libs/doc.ts b/shared-libs/cht-datasource/src/libs/doc.ts new file mode 100644 index 00000000000..43ec13d5fd5 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/doc.ts @@ -0,0 +1,13 @@ +import { DataObject, hasField, Identifiable, isIdentifiable, isRecord } from './core'; + +/** + * A document from the database. + */ +export interface Doc extends DataObject, Identifiable { + readonly _rev: string; +} + +/** @internal */ +export const isDoc = (value: unknown): value is Doc => isRecord(value) + && isIdentifiable(value) + && hasField(value, { name: '_rev', type: 'string' }); diff --git a/shared-libs/cht-datasource/src/local/index.ts b/shared-libs/cht-datasource/src/local/index.ts new file mode 100644 index 00000000000..f6fc2ffe2f6 --- /dev/null +++ b/shared-libs/cht-datasource/src/local/index.ts @@ -0,0 +1,3 @@ +export * as Person from './person'; +export * as Place from './place'; +export { getLocalDataContext } from './libs/data-context'; diff --git a/shared-libs/cht-datasource/src/local/libs/data-context.ts b/shared-libs/cht-datasource/src/local/libs/data-context.ts new file mode 100644 index 00000000000..50f0193dcce --- /dev/null +++ b/shared-libs/cht-datasource/src/local/libs/data-context.ts @@ -0,0 +1,58 @@ +import { Doc } from '../../libs/doc'; +import { AbstractDataContext, hasField, isRecord } from '../../libs/core'; +import { DataContext } from '../../libs/data-context'; + +/** + * {@link PouchDB.Database}s to be used as the local data source. + */ +export type SourceDatabases = Readonly<{ medic: PouchDB.Database }>; + +/** + * Service providing access to the app settings. These settings must be guaranteed to remain current for as long as the + * service is used. Settings data returned from future calls to service methods should reflect the current state of the + * system's settings at the time and not just the state of the settings when the service was first created. + */ +export type SettingsService = Readonly<{ getAll: () => Doc }>; + +/** @internal */ +export class LocalDataContext extends AbstractDataContext { + /** @internal */ + constructor( + readonly medicDb: PouchDB.Database, + readonly settings: SettingsService + ) { + super(); + } +} + +const assertSettingsService: (settings: unknown) => asserts settings is SettingsService = (settings: unknown) => { + if (!isRecord(settings) || !hasField(settings, { name: 'getAll', type: 'function' })) { + throw new Error(`Invalid settings service [${JSON.stringify(settings)}].`); + } +}; + +const assertSourceDatabases: (sourceDatabases: unknown) => asserts sourceDatabases is SourceDatabases = + (sourceDatabases: unknown) => { + if (!isRecord(sourceDatabases) || !hasField(sourceDatabases, { name: 'medic', type: 'object' })) { + throw new Error(`Invalid source databases [${JSON.stringify(sourceDatabases)}].`); + } + }; + +/** @internal */ +export const isLocalDataContext = (context: DataContext): context is LocalDataContext => { + return 'settings' in context && 'medicDb' in context; +}; + +/** + * Returns the data context for accessing data via the provided local sources This functionality is intended for use + * cases requiring offline functionality. For all other use cases, use {@link getRemoteDataContext}. + * @param settings service providing access to the app settings + * @param sourceDatabases the PouchDB databases to use as the local datasource + * @returns the local data context + * @throws Error if the provided settings or source databases are invalid + */ +export const getLocalDataContext = (settings: SettingsService, sourceDatabases: SourceDatabases): DataContext => { + assertSettingsService(settings); + assertSourceDatabases(sourceDatabases); + return new LocalDataContext(sourceDatabases.medic, settings); +}; diff --git a/shared-libs/cht-datasource/src/local/libs/doc.ts b/shared-libs/cht-datasource/src/local/libs/doc.ts new file mode 100644 index 00000000000..37417ba6808 --- /dev/null +++ b/shared-libs/cht-datasource/src/local/libs/doc.ts @@ -0,0 +1,41 @@ +import logger from '@medic/logger'; +import { Nullable } from '../../libs/core'; +import { Doc, isDoc } from '../../libs/doc'; + +/** @internal */ +export const getDocById = (db: PouchDB.Database) => async (uuid: string): Promise> => db + .get(uuid) + .then(doc => isDoc(doc) ? doc : null) + .catch((err: unknown) => { + if ((err as PouchDB.Core.Error).status === 404) { + return null; + } + + logger.error(`Failed to fetch doc with id [${uuid}]`, err); + throw err; + }); + +/** @internal */ +export const getDocsByIds = (db: PouchDB.Database) => async (uuids: string[]): Promise => { + const keys = Array.from(new Set(uuids.filter(uuid => uuid.length))); + if (!keys.length) { + return []; + } + const response = await db.allDocs({ keys, include_docs: true }); + return response.rows + .map(({ doc }) => doc) + .filter((doc): doc is Doc => isDoc(doc)); +}; + +/** @internal */ +export const queryDocsByKey = ( + 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)); + diff --git a/shared-libs/cht-datasource/src/local/libs/lineage.ts b/shared-libs/cht-datasource/src/local/libs/lineage.ts new file mode 100644 index 00000000000..c23bb40ab69 --- /dev/null +++ b/shared-libs/cht-datasource/src/local/libs/lineage.ts @@ -0,0 +1,90 @@ +import { Contact, NormalizedParent } from '../../libs/contact'; +import { + DataObject, + findById, + getLastElement, + isIdentifiable, + isNonEmptyArray, + isNotNull, + NonEmptyArray, + Nullable +} from '../../libs/core'; +import { Doc } from '../../libs/doc'; +import { queryDocsByKey } from './doc'; +import logger from '@medic/logger'; + +/** + * Returns the identified document along with the parent documents recorded for its lineage. The returned array is + * 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'); + +/** @internal */ +export const getPrimaryContactIds = (places: NonEmptyArray>): string[] => places + .filter(isNotNull) + .map(({ contact }) => contact) + .filter(isIdentifiable) + .map(({ _id }) => _id) + .filter((_id) => _id.length > 0); + +/** @internal */ +export const hydratePrimaryContact = (contacts: Doc[]) => (place: Nullable): Nullable => { + if (!place || !isIdentifiable(place.contact)) { + return place; + } + const contact = findById(contacts, place.contact._id); + if (!contact) { + logger.debug(`No contact found with identifier [${place.contact._id}] for the place [${place._id}].`); + return place; + } + return { + ...place, + contact + }; +}; + +const getParentUuid = (index: number, contact?: NormalizedParent): Nullable => { + if (!contact) { + return null; + } + if (index === 0) { + return contact._id; + } + return getParentUuid(index - 1, contact.parent); +}; + +const mergeLineage = (lineage: DataObject[], parent: DataObject): DataObject => { + if (!isNonEmptyArray(lineage)) { + return parent; + } + const child = getLastElement(lineage); + const mergedChild = { + ...child, + parent: parent + }; + return mergeLineage(lineage.slice(0, -1), mergedChild); +}; + +/** @internal */ +export const hydrateLineage = ( + contact: Contact, + lineage: Nullable[] +): Contact => { + const fullLineage = lineage + .map((place, index) => { + if (place) { + return place; + } + const parentId = getParentUuid(index, contact.parent); + // If no doc was found, just add a placeholder object with the id from the contact + logger.debug( + `Lineage place with identifier [${parentId ?? ''}] was not found when getting lineage for [${contact._id}].` + ); + return { _id: parentId }; + }); + const hierarchy: NonEmptyArray = [contact, ...fullLineage]; + return mergeLineage(hierarchy.slice(0, -1), getLastElement(hierarchy)) as Contact; +}; diff --git a/shared-libs/cht-datasource/src/local/person.ts b/shared-libs/cht-datasource/src/local/person.ts new file mode 100644 index 00000000000..2f23d6d5170 --- /dev/null +++ b/shared-libs/cht-datasource/src/local/person.ts @@ -0,0 +1,61 @@ +import { Doc } from '../libs/doc'; +import contactTypeUtils from '@medic/contact-types-utils'; +import { deepCopy, isNonEmptyArray, Nullable } from '../libs/core'; +import { UuidQualifier } from '../qualifier'; +import * as Person from '../person'; +import { getDocById, getDocsByIds } from './libs/doc'; +import { LocalDataContext, SettingsService } from './libs/data-context'; +import logger from '@medic/logger'; +import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage'; + +/** @internal */ +export namespace v1 { + const isPerson = (settings: SettingsService, uuid: string, doc: Nullable): doc is Person.v1.Person => { + if (!doc) { + 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.`); + return false; + } + return true; + }; + + /** @internal */ + export const get = ({ medicDb, settings }: LocalDataContext) => { + const getMedicDocById = getDocById(medicDb); + return async (identifier: UuidQualifier): Promise> => { + const doc = await getMedicDocById(identifier.uuid); + if (!isPerson(settings, identifier.uuid, doc)) { + return null; + } + return doc; + }; + }; + + /** @internal */ + export const getWithLineage = ({ medicDb, settings }: LocalDataContext) => { + const getLineageDocs = getLineageDocsById(medicDb); + const getMedicDocsById = getDocsByIds(medicDb); + return async (identifier: UuidQualifier): Promise> => { + const [person, ...lineagePlaces] = await getLineageDocs(identifier.uuid); + if (!isPerson(settings, identifier.uuid, person)) { + return null; + } + // Intentionally not further validating lineage. For passivity, lineage problems should not block retrieval. + if (!isNonEmptyArray(lineagePlaces)) { + logger.debug(`No lineage places found for person [${identifier.uuid}].`); + return person; + } + + const contactUuids = getPrimaryContactIds(lineagePlaces) + .filter(uuid => uuid !== person._id); + const contacts = [person, ...await getMedicDocsById(contactUuids)]; + const linagePlacesWithContact = lineagePlaces.map(hydratePrimaryContact(contacts)); + const personWithLineage = hydrateLineage(person, linagePlacesWithContact); + return deepCopy(personWithLineage); + }; + }; +} diff --git a/shared-libs/cht-datasource/src/local/place.ts b/shared-libs/cht-datasource/src/local/place.ts new file mode 100644 index 00000000000..9cd38be30e7 --- /dev/null +++ b/shared-libs/cht-datasource/src/local/place.ts @@ -0,0 +1,60 @@ +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 * as Place from '../place'; +import { getDocById, getDocsByIds } 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'; + +/** @internal */ +export namespace v1 { + const isPlace = (settings: SettingsService, uuid: string, doc: Nullable): doc is Place.v1.Place => { + if (!doc) { + 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.`); + return false; + } + return true; + }; + + /** @internal */ + export const get = ({ medicDb, settings }: LocalDataContext) => { + const getMedicDocById = getDocById(medicDb); + return async (identifier: UuidQualifier): Promise> => { + const doc = await getMedicDocById(identifier.uuid); + const validPlace = isPlace(settings, identifier.uuid, doc); + return validPlace ? doc : null; + }; + }; + + /** @internal */ + export const getWithLineage = ({ medicDb, settings }: LocalDataContext) => { + const getLineageDocs = getLineageDocsById(medicDb); + const getMedicDocsById = getDocsByIds(medicDb); + return async (identifier: UuidQualifier): Promise> => { + const [place, ...lineagePlaces] = await getLineageDocs(identifier.uuid); + if (!isPlace(settings, identifier.uuid, place)) { + return null; + } + // Intentionally not further validating lineage. For passivity, lineage problems should not block retrieval. + if (!isNonEmptyArray(lineagePlaces)) { + logger.debug(`No lineage places found for place [${identifier.uuid}].`); + return place; + } + + const places: NonEmptyArray> = [place, ...lineagePlaces]; + const contactUuids = getPrimaryContactIds(places); + const contacts = await getMedicDocsById(contactUuids); + const [placeWithContact, ...linagePlacesWithContact] = places.map(hydratePrimaryContact(contacts)); + const placeWithLineage = hydrateLineage(placeWithContact as Contact, linagePlacesWithContact); + return deepCopy(placeWithLineage); + }; + }; +} diff --git a/shared-libs/cht-datasource/src/person.ts b/shared-libs/cht-datasource/src/person.ts new file mode 100644 index 00000000000..9f1fd64c9f6 --- /dev/null +++ b/shared-libs/cht-datasource/src/person.ts @@ -0,0 +1,62 @@ +import { isUuidQualifier, UuidQualifier } from './qualifier'; +import { adapt, assertDataContext, DataContext } from './libs/data-context'; +import { Contact, NormalizedParent } from './libs/contact'; +import * as Remote from './remote'; +import * as Local from './local'; +import * as Place from './place'; +import { LocalDataContext } from './local/libs/data-context'; +import { RemoteDataContext } from './remote/libs/data-context'; + +/** */ +export namespace v1 { + /** + * Immutable data about a person contact. + */ + export interface Person extends Contact { + readonly date_of_birth?: Date; + readonly phone?: string; + readonly patient_id?: string; + readonly sex?: string; + } + + /** + * Immutable data about a person contact, including the full records of the parent place lineage. + */ + export interface PersonWithLineage extends Person { + readonly parent?: Place.v1.PlaceWithLineage | NormalizedParent, + } + + const assertPersonQualifier: (qualifier: unknown) => asserts qualifier is UuidQualifier = (qualifier: unknown) => { + if (!isUuidQualifier(qualifier)) { + throw new Error(`Invalid identifier [${JSON.stringify(qualifier)}].`); + } + }; + + const getPerson = ( + localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, + remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise + ) => (context: DataContext) => { + assertDataContext(context); + const fn = adapt(context, localFn, remoteFn); + return async (qualifier: UuidQualifier): Promise => { + assertPersonQualifier(qualifier); + return fn(qualifier); + }; + }; + + /** + * Returns a person for the given qualifier. + * @param context the current data context + * @returns the person or `null` if no person is found for the qualifier + * @throws Error if the provided context or qualifier is invalid + */ + export const get = getPerson(Local.Person.v1.get, Remote.Person.v1.get); + + /** + * Returns a person for the given qualifier with the person's parent lineage. + * @param context the current data context + * @returns the person or `null` if no person is found for the qualifier + * @throws Error if the provided context or qualifier is invalid + */ + export const getWithLineage = getPerson(Local.Person.v1.getWithLineage, Remote.Person.v1.getWithLineage); +} diff --git a/shared-libs/cht-datasource/src/place.ts b/shared-libs/cht-datasource/src/place.ts new file mode 100644 index 00000000000..0e301b51b7c --- /dev/null +++ b/shared-libs/cht-datasource/src/place.ts @@ -0,0 +1,63 @@ +import { Contact, NormalizedParent } from './libs/contact'; +import * as Person from './person'; +import { LocalDataContext } from './local/libs/data-context'; +import { 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'; + +/** */ +export namespace v1 { + + /** + * Immutable data about a place contact. + */ + export interface Place extends Contact { + readonly contact?: NormalizedParent, + readonly place_id?: string, + } + + /** + * Immutable data about a place contact, including the full records of the parent place lineage and the primary + * contact for the place. + */ + export interface PlaceWithLineage extends Place { + readonly contact?: Person.v1.PersonWithLineage | NormalizedParent, + readonly parent?: PlaceWithLineage | NormalizedParent, + } + + const assertPlaceQualifier: (qualifier: unknown) => asserts qualifier is UuidQualifier = (qualifier: unknown) => { + if (!isUuidQualifier(qualifier)) { + throw new Error(`Invalid identifier [${JSON.stringify(qualifier)}].`); + } + }; + + const getPlace = ( + localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, + remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise + ) => (context: DataContext) => { + assertDataContext(context); + const fn = adapt(context, localFn, remoteFn); + return async (qualifier: UuidQualifier): Promise => { + assertPlaceQualifier(qualifier); + return fn(qualifier); + }; + }; + + /** + * Returns a place for the given qualifier. + * @param context the current data context + * @returns the place or `null` if no place is found for the qualifier + * @throws Error if the provided context or qualifier is invalid + */ + export const get = getPlace(Local.Place.v1.get, Remote.Place.v1.get); + + /** + * Returns a place for the given qualifier with the place's parent lineage. + * @param context the current data context + * @returns the place or `null` if no place is found for the qualifier + * @throws Error if the provided context or qualifier is invalid + */ + export const getWithLineage = getPlace(Local.Place.v1.getWithLineage, Remote.Place.v1.getWithLineage); +} diff --git a/shared-libs/cht-datasource/src/qualifier.ts b/shared-libs/cht-datasource/src/qualifier.ts new file mode 100644 index 00000000000..5b37a18edf5 --- /dev/null +++ b/shared-libs/cht-datasource/src/qualifier.ts @@ -0,0 +1,29 @@ +import { isString, hasField, isRecord } from './libs/core'; + +/** + * A qualifier that identifies an entity by its UUID. + */ +export type UuidQualifier = Readonly<{ uuid: string }>; + +/** + * Builds a qualifier that identifies an entity by its UUID. + * @param uuid the UUID of the entity + * @returns the qualifier + * @throws Error if the UUID is invalid + */ +export const byUuid = (uuid: string): UuidQualifier => { + if (!isString(uuid) || uuid.length === 0) { + throw new Error(`Invalid UUID [${JSON.stringify(uuid)}].`); + } + return { uuid }; +}; + +/** + * Returns `true` if the given qualifier is a {@link UuidQualifier}, otherwise `false`. + * @param identifier the identifier to check + * @returns `true` if the given identifier is a {@link UuidQualifier}, otherwise + * `false` + */ +export const isUuidQualifier = (identifier: unknown): identifier is UuidQualifier => { + return isRecord(identifier) && hasField(identifier, { name: 'uuid', type: 'string' }); +}; diff --git a/shared-libs/cht-datasource/src/remote/index.ts b/shared-libs/cht-datasource/src/remote/index.ts new file mode 100644 index 00000000000..6db881ec27e --- /dev/null +++ b/shared-libs/cht-datasource/src/remote/index.ts @@ -0,0 +1,3 @@ +export * as Person from './person'; +export * as Place from './place'; +export { getRemoteDataContext } from './libs/data-context'; diff --git a/shared-libs/cht-datasource/src/remote/libs/data-context.ts b/shared-libs/cht-datasource/src/remote/libs/data-context.ts new file mode 100644 index 00000000000..4154142dc35 --- /dev/null +++ b/shared-libs/cht-datasource/src/remote/libs/data-context.ts @@ -0,0 +1,60 @@ +import logger from '@medic/logger'; +import { DataContext } from '../../libs/data-context'; +import { AbstractDataContext, isString, Nullable } from '../../libs/core'; + +/** @internal */ +export class RemoteDataContext extends AbstractDataContext { + /** @internal */ + constructor(readonly url: string) { + super(); + } +} + +/** @internal */ +export const isRemoteDataContext = (context: DataContext): context is RemoteDataContext => 'url' in context; + +/** @internal */ +export const assertRemoteDataContext: (context: DataContext) => asserts context is RemoteDataContext = ( + context: DataContext +) => { + if (!isRemoteDataContext(context)) { + throw new Error(`Invalid remote data context [${JSON.stringify(context)}].`); + } +}; + +/** + * Returns the data context based on a remote CHT API server. This function should not be used when offline + * functionality is required. + * @param url the URL of the remote CHT API server. If not provided, requests will be made relative to the current + * location. + * @returns the data context + */ +export const getRemoteDataContext = (url = ''): DataContext => { + if (!isString(url)) { + throw new Error(`Invalid URL [${JSON.stringify(url)}].`); + } + + return new RemoteDataContext(url); +}; + +/** @internal */ +export const getResource = (context: RemoteDataContext, path: string) => async ( + identifier: string, + queryParams?: Record +): Promise> => { + const params = new URLSearchParams(queryParams).toString(); + try { + const response = await fetch(`${context.url}/${path}/${identifier}?${params}`); + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(response.statusText); + } + + return (await response.json()) as T; + } catch (error) { + logger.error(`Failed to fetch ${identifier} from ${context.url}/${path}`, error); + throw error; + } +}; diff --git a/shared-libs/cht-datasource/src/remote/person.ts b/shared-libs/cht-datasource/src/remote/person.ts new file mode 100644 index 00000000000..23544a5378d --- /dev/null +++ b/shared-libs/cht-datasource/src/remote/person.ts @@ -0,0 +1,22 @@ +import { Nullable } from '../libs/core'; +import { UuidQualifier } from '../qualifier'; +import * as Person from '../person'; +import { getResource, RemoteDataContext } from './libs/data-context'; + +/** @internal */ +export namespace v1 { + const getPerson = (remoteContext: RemoteDataContext) => getResource(remoteContext, 'api/v1/person'); + + /** @internal */ + export const get = (remoteContext: RemoteDataContext) => ( + identifier: UuidQualifier + ): Promise> => getPerson(remoteContext)(identifier.uuid); + + /** @internal */ + export const getWithLineage = (remoteContext: RemoteDataContext) => ( + identifier: UuidQualifier + ): Promise> => getPerson(remoteContext)( + identifier.uuid, + { with_lineage: 'true' } + ); +} diff --git a/shared-libs/cht-datasource/src/remote/place.ts b/shared-libs/cht-datasource/src/remote/place.ts new file mode 100644 index 00000000000..6aef2228c62 --- /dev/null +++ b/shared-libs/cht-datasource/src/remote/place.ts @@ -0,0 +1,22 @@ +import { Nullable } from '../libs/core'; +import { UuidQualifier } from '../qualifier'; +import * as Place from '../place'; +import { getResource, RemoteDataContext } from './libs/data-context'; + +/** @internal */ +export namespace v1 { + const getPlace = (remoteContext: RemoteDataContext) => getResource(remoteContext, 'api/v1/place'); + + /** @internal */ + export const get = (remoteContext: RemoteDataContext) => ( + identifier: UuidQualifier + ): Promise> => getPlace(remoteContext)(identifier.uuid); + + /** @internal */ + export const getWithLineage = (remoteContext: RemoteDataContext) => ( + identifier: UuidQualifier + ): Promise> => getPlace(remoteContext)( + identifier.uuid, + { with_lineage: 'true' } + ); +} diff --git a/shared-libs/cht-script-api/test/auth.spec.js b/shared-libs/cht-datasource/test/auth.spec.js similarity index 100% rename from shared-libs/cht-script-api/test/auth.spec.js rename to shared-libs/cht-datasource/test/auth.spec.js diff --git a/shared-libs/cht-datasource/test/index.spec.ts b/shared-libs/cht-datasource/test/index.spec.ts new file mode 100644 index 00000000000..b53f03a7686 --- /dev/null +++ b/shared-libs/cht-datasource/test/index.spec.ts @@ -0,0 +1,129 @@ +import { expect } from 'chai'; +import * as Index from '../src'; +import { hasAnyPermission, hasPermissions } from '../src/auth'; +import * as Person from '../src/person'; +import * as Place from '../src/place'; +import * as Qualifier from '../src/qualifier'; +import sinon, { SinonStub } from 'sinon'; +import * as Context from '../src/libs/data-context'; +import { DataContext } from '../src'; + +describe('CHT Script API - getDatasource', () => { + let dataContext: DataContext; + let dataContextBind: SinonStub; + let assertDataContext: SinonStub; + let datasource: ReturnType; + + beforeEach(() => { + dataContextBind = sinon.stub(); + dataContext = { bind: dataContextBind }; + assertDataContext = sinon.stub(Context, 'assertDataContext'); + datasource = Index.getDatasource(dataContext); + }); + + afterEach(() => sinon.restore()); + + it('contains expected keys', () => { + expect(datasource).to.have.all.keys([ 'v1' ]); + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + expect(() => Index.getDatasource(dataContext)).to.throw('Invalid data context [null].'); + }); + + describe('v1', () => { + let v1: typeof datasource.v1; + + beforeEach(() => v1 = datasource.v1); + + it('contains expected keys', () => expect(v1).to.have.all.keys([ + 'hasPermissions', 'hasAnyPermission', 'person', 'place' + ])); + + it('permission', () => { + expect(v1.hasPermissions).to.equal(hasPermissions); + expect(v1.hasAnyPermission).to.equal(hasAnyPermission); + }); + + describe('place', () => { + let place: typeof v1.place; + + beforeEach(() => place = v1.place); + + it('contains expected keys', () => { + expect(place).to.have.all.keys(['getByUuid', 'getByUuidWithLineage']); + }); + + it('getByUuid', async () => { + const expectedPlace = {}; + const placeGet = sinon.stub().resolves(expectedPlace); + dataContextBind.returns(placeGet); + const qualifier = { uuid: 'my-places-uuid' }; + const byUuid = sinon.stub(Qualifier, 'byUuid').returns(qualifier); + + const returnedPlace = await place.getByUuid(qualifier.uuid); + + expect(returnedPlace).to.equal(expectedPlace); + expect(dataContextBind.calledOnceWithExactly(Place.v1.get)).to.be.true; + expect(placeGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; + }); + + it('getByUuidWithLineage', async () => { + const expectedPlace = {}; + const placeGet = sinon.stub().resolves(expectedPlace); + dataContextBind.returns(placeGet); + const qualifier = { uuid: 'my-places-uuid' }; + const byUuid = sinon.stub(Qualifier, 'byUuid').returns(qualifier); + + const returnedPlace = await place.getByUuidWithLineage(qualifier.uuid); + + expect(returnedPlace).to.equal(expectedPlace); + expect(dataContextBind.calledOnceWithExactly(Place.v1.getWithLineage)).to.be.true; + expect(placeGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; + }); + }); + + describe('person', () => { + let person: typeof v1.person; + + beforeEach(() => person = v1.person); + + it('contains expected keys', () => { + expect(person).to.have.all.keys(['getByUuid', 'getByUuidWithLineage']); + }); + + it('getByUuid', async () => { + const expectedPerson = {}; + const personGet = sinon.stub().resolves(expectedPerson); + dataContextBind.returns(personGet); + const qualifier = { uuid: 'my-persons-uuid' }; + const byUuid = sinon.stub(Qualifier, 'byUuid').returns(qualifier); + + const returnedPerson = await person.getByUuid(qualifier.uuid); + + expect(returnedPerson).to.equal(expectedPerson); + expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(personGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; + }); + + it('getByUuidWithLineage', async () => { + const expectedPerson = {}; + const personGet = sinon.stub().resolves(expectedPerson); + dataContextBind.returns(personGet); + const qualifier = { uuid: 'my-persons-uuid' }; + const byUuid = sinon.stub(Qualifier, 'byUuid').returns(qualifier); + + const returnedPerson = await person.getByUuidWithLineage(qualifier.uuid); + + expect(returnedPerson).to.equal(expectedPerson); + expect(dataContextBind.calledOnceWithExactly(Person.v1.getWithLineage)).to.be.true; + expect(personGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/libs/contact.spec.ts b/shared-libs/cht-datasource/test/libs/contact.spec.ts new file mode 100644 index 00000000000..dadb7d71ca0 --- /dev/null +++ b/shared-libs/cht-datasource/test/libs/contact.spec.ts @@ -0,0 +1,31 @@ +import { expect } from 'chai'; +import { isNormalizedParent } from '../../src/libs/contact'; +import sinon, { SinonStub } from 'sinon'; +import * as Core from '../../src/libs/core'; + +describe('contact lib', () => { + describe('isNormalizedParent', () => { + let isDataObject: SinonStub; + + beforeEach(() => isDataObject = sinon.stub(Core, 'isDataObject')); + afterEach(() => sinon.restore()); + + ([ + [{ _id: 'my-id' }, true, true], + [{ _id: 'my-id' }, false, false], + [{ hello: 'my-id' }, true, false], + [{ _id: 1 }, true, false], + [{ _id: 'my-id', parent: 'hello' }, true, false], + [{ _id: 'my-id', parent: null }, true, true], + [{ _id: 'my-id', parent: { hello: 'world' } }, true, false], + [{ _id: 'my-id', parent: { _id: 'parent-id' } }, true, true], + [{ _id: 'my-id', parent: { _id: 'parent-id', parent: { hello: 'world' } } }, true, false], + [{ _id: 'my-id', parent: { _id: 'parent-id', parent: { _id: 'grandparent-id' } } }, true, true], + ] as [unknown, boolean, boolean][]).forEach(([value, dataObj, expected]) => { + it(`evaluates ${JSON.stringify(value)}`, () => { + isDataObject.returns(dataObj); + expect(isNormalizedParent(value)).to.equal(expected); + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/libs/core.spec.ts b/shared-libs/cht-datasource/test/libs/core.spec.ts new file mode 100644 index 00000000000..9344cbbd4c3 --- /dev/null +++ b/shared-libs/cht-datasource/test/libs/core.spec.ts @@ -0,0 +1,207 @@ +import { expect } from 'chai'; +import { + AbstractDataContext, deepCopy, findById, getLastElement, + hasField, + hasFields, isDataObject, isIdentifiable, + isNonEmptyArray, + isRecord, + isString, + NonEmptyArray +} from '../../src/libs/core'; +import sinon from 'sinon'; + +describe('core lib', () => { + afterEach(() => sinon.restore()); + + describe('isNonEmptyArray', () => { + ([ + [[], false], + [[1], true], + [[1, 2], true] + ] as [unknown[], boolean][]).forEach(([value, expected]) => { + it(`evaluates ${JSON.stringify(value)}`, () => { + expect(isNonEmptyArray(value)).to.equal(expected); + }); + }); + }); + + describe('getLastElement', () => { + ([ + [1, 2, 3], + ['hello', 'world'], + ] as NonEmptyArray[]).forEach(value => { + it(`returns the last element of ${JSON.stringify(value)}`, () => { + expect(getLastElement(value)).to.equal(value[value.length - 1]); + }); + }); + }); + + describe('isDataObject', () => { + [ + [null, false], + [`hello`, false], + [1, false], + [{ }, true], + [{ hello: null }, true], + [{ hello: undefined }, true], + [{ hello: 'world' }, true], + [{ hello: 1 }, true], + [{ hello: false }, true], + [{ hello: new Date() }, true], + [{ hello: ['world'] }, true], + [{ hello: [['world']] }, true], + [{ hello: [{ hello: 'world' }] }, true], + [{ hello: [() => 'world'] }, false], + [{ hello: { parent: 'world' } }, true], + [{ hello: () => 'world' }, false], + [{ hello: { parent: () => 'world' } }, false], + ].forEach(([value, expected]) => { + it(`evaluates ${JSON.stringify(value)}`, () => { + expect(isDataObject(value)).to.equal(expected); + }); + }); + }); + + describe('deepCopy', () => { + [ + 'hello', + 1, + null + ].forEach(value => { + it(`copies ${JSON.stringify(value)}`, () => { + expect(deepCopy(value)).to.equal(value); + }); + }); + + [ + [1, 2, 3], + { hello: 'world' }, + { hello: { nested: 'world', more: [1, 2], deep: { hello: 'world' } } }, + ].forEach(value => { + it(`copies ${JSON.stringify(value)}`, () => { + expect(deepCopy(value)).to.not.equal(value); + expect(deepCopy(value)).to.deep.equal(value); + }); + }); + + it('eliminates cross-references from within an object', () => { + const innerObject = { hello: 'world' }; + const outerObject = { first: innerObject, second: innerObject }; + + const copied = deepCopy(outerObject); + + expect(copied).to.deep.equal(outerObject); + expect(copied.first).to.not.equal(copied.second); + }); + }); + + describe('isString', () => { + [ + [null, false], + ['', true], + [{}, false], + [undefined, false], + [1, false], + ['hello', true] + ].forEach(([value, expected]) => { + it(`evaluates ${JSON.stringify(value)}`, () => { + expect(isString(value)).to.equal(expected); + }); + }); + }); + + describe('isRecord', () => { + [ + [null, false], + ['', false], + [{}, true], + [undefined, false], + [1, false], + ['hello', false] + ].forEach(([value, expected]) => { + it(`evaluates ${JSON.stringify(value)}`, () => { + expect(isRecord(value)).to.equal(expected); + }); + }); + }); + + describe('hasField', () => { + ([ + [{}, { name: 'uuid', type: 'string' }, false], + [{ uuid: 'uuid' }, { name: 'uuid', type: 'string' }, true], + [{ uuid: 'uuid' }, { name: 'uuid', type: 'number' }, false], + [{ uuid: 'uuid', other: 1 }, { name: 'uuid', type: 'string' }, true], + [{ uuid: 'uuid', other: 1 }, { name: 'other', type: 'string' }, false], + [{ uuid: 'uuid', other: 1 }, { name: 'other', type: 'number' }, true], + [{ getUuid: () => 'uuid' }, { name: 'getUuid', type: 'function' }, true], + ] as [Record, { name: string, type: string }, boolean][]).forEach(([record, field, expected]) => { + it(`evaluates ${JSON.stringify(record)} with ${JSON.stringify(field)}`, () => { + expect(hasField(record, field)).to.equal(expected); + }); + }); + }); + + describe('hasFields', () => { + ([ + [{}, [{ name: 'uuid', type: 'string' }], false], + [{ uuid: 'uuid' }, [{ name: 'uuid', type: 'string' }], true], + [{ getUuid: () => 'uuid' }, [{ name: 'getUuid', type: 'function' }, { name: 'uuid', type: 'string' }], false], + [ + { getUuid: () => 'uuid', uuid: 'uuid' }, + [{ name: 'getUuid', type: 'function' }, { name: 'uuid', type: 'string' }], + true + ], + ] as [Record, NonEmptyArray<{ name: string, type: string }>, boolean][]).forEach( + ([record, fields, expected]) => { + it(`evaluates ${JSON.stringify(record)} with ${JSON.stringify(fields)}`, () => { + expect(hasFields(record, fields)).to.equal(expected); + }); + } + ); + }); + + describe('isIdentifiable', () => { + [ + [null, false], + [{}, false], + [{ _id: 'uuid' }, true], + [{ _id: 'uuid', other: 1 }, true], + [{ _id: 'uuid', getUuid: () => 'uuid' }, true], + ].forEach(([value, expected]) => { + it(`evaluates ${JSON.stringify(value)}`, () => { + expect(isIdentifiable(value)).to.equal(expected); + }); + }); + }); + + describe('findById', () => { + it('returns the entry with the matching _id value', () => { + const match = { _id: 'uuid2' }; + const values = [{ _id: 'uuid0' }, { _id: 'uuid1' }, match]; + const result = findById(values, match._id); + + expect(result).to.equal(match); + }); + + it('returns null if no entry has a matching _id value', () => { + const values = [{ _id: 'uuid0' }, { _id: 'uuid1' }, { _id: 'uuid2' }]; + const result = findById(values, 'uuid3'); + + expect(result).to.be.null; + }); + }); + + describe('AbstractDataContext', () => { + class TestDataContext extends AbstractDataContext { } + + it('bind', () => { + const ctx = new TestDataContext(); + const testFn = sinon.stub().returns('test'); + + const result = ctx.bind(testFn); + + expect(result).to.equal('test'); + expect(testFn.calledOnceWithExactly(ctx)).to.be.true; + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/libs/data-context.spec.ts b/shared-libs/cht-datasource/test/libs/data-context.spec.ts new file mode 100644 index 00000000000..82ba05ba0da --- /dev/null +++ b/shared-libs/cht-datasource/test/libs/data-context.spec.ts @@ -0,0 +1,121 @@ +import { expect } from 'chai'; +import { adapt, assertDataContext } from '../../src/libs/data-context'; +import * as LocalContext from '../../src/local/libs/data-context'; +import * as RemoteContext from '../../src/remote/libs/data-context'; +import sinon, { SinonStub } from 'sinon'; +import { DataContext } from '../../dist'; + + +describe('context lib', () => { + const context = { bind: sinon.stub() } as DataContext; + let isLocalDataContext: SinonStub; + let isRemoteDataContext: SinonStub; + let assertRemoteDataContext: SinonStub; + + beforeEach(() => { + isLocalDataContext = sinon.stub(LocalContext, 'isLocalDataContext'); + isRemoteDataContext = sinon.stub(RemoteContext, 'isRemoteDataContext'); + assertRemoteDataContext = sinon.stub(RemoteContext, 'assertRemoteDataContext'); + }); + + afterEach(() => sinon.restore()); + + describe('assertDataContext', () => { + + it('allows a remote data context', () => { + isRemoteDataContext.returns(true); + isLocalDataContext.returns(false); + + expect(() => assertDataContext(context)).to.not.throw(); + + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(isRemoteDataContext.calledOnceWithExactly(context)).to.be.true; + }); + + it('allows a local data context', () => { + isRemoteDataContext.returns(false); + isLocalDataContext.returns(true); + + expect(() => assertDataContext(context)).to.not.throw(); + + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(isRemoteDataContext.notCalled).to.be.true; + }); + + it(`throws an error if the data context is not remote or local`, () => { + isRemoteDataContext.returns(false); + isLocalDataContext.returns(false); + + expect(() => assertDataContext(context)) + .to + .throw(`Invalid data context [${JSON.stringify(context)}].`); + + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(isRemoteDataContext.calledOnceWithExactly(context)).to.be.true; + }); + + [ + null, + 1, + 'hello', + {} + ].forEach((context) => { + it(`throws an error if the data context is invalid [${JSON.stringify(context)}]`, () => { + expect(() => assertDataContext(context)).to.throw(`Invalid data context [${JSON.stringify(context)}].`); + + expect(isLocalDataContext.notCalled).to.be.true; + expect(isRemoteDataContext.notCalled).to.be.true; + }); + }); + }); + + describe('adapt', () => { + const resource = { hello: 'world' } as const; + let local: SinonStub; + let remote: SinonStub; + + beforeEach(() => { + local = sinon.stub(); + remote = sinon.stub(); + }); + + it('adapts a local data context', () => { + isLocalDataContext.returns(true); + local.returns(resource); + + const result = adapt(context, local, remote); + + expect(result).to.equal(resource); + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(local.calledOnceWithExactly(context)).to.be.true; + expect(assertRemoteDataContext.notCalled).to.be.true; + expect(remote.notCalled).to.be.true; + }); + + it('adapts a remote data context', () => { + isLocalDataContext.returns(false); + remote.returns(resource); + + const result = adapt(context, local, remote); + + expect(result).to.equal(resource); + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(assertRemoteDataContext.calledOnceWithExactly(context)).to.be.true; + expect(local.notCalled).to.be.true; + expect(remote.calledOnceWithExactly(context)).to.be.true; + }); + + it('throws an error if the data context is not remote or local', () => { + isLocalDataContext.returns(false); + const error = new Error('Invalid data context'); + assertRemoteDataContext.throws(error); + + expect(() => adapt(context, local, remote)).to.throw(error); + + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(local.notCalled).to.be.true; + expect(assertRemoteDataContext.calledOnceWithExactly(context)).to.be.true; + expect(remote.notCalled).to.be.true; + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/libs/doc.spec.ts b/shared-libs/cht-datasource/test/libs/doc.spec.ts new file mode 100644 index 00000000000..557d018d803 --- /dev/null +++ b/shared-libs/cht-datasource/test/libs/doc.spec.ts @@ -0,0 +1,19 @@ +import { expect } from 'chai'; +import { isDoc } from '../../src/libs/doc'; + +describe('doc lib', () => { + describe('isDoc', () => { + [ + [null, false], + [{}, false], + [{ _id: 'id' }, false], + [{ _rev: 'rev' }, false], + [{ _id: 'id', _rev: 'rev' }, true], + [{ _id: 'id', _rev: 'rev', other: 'other' }, true] + ].forEach(([doc, expected]) => { + it(`evaluates ${JSON.stringify(doc)}`, () => { + expect(isDoc(doc)).to.equal(expected); + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/libs/data-context.spec.ts b/shared-libs/cht-datasource/test/local/libs/data-context.spec.ts new file mode 100644 index 00000000000..659081fb73a --- /dev/null +++ b/shared-libs/cht-datasource/test/local/libs/data-context.spec.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import { + getLocalDataContext, + isLocalDataContext, + SettingsService, + SourceDatabases +} from '../../../src/local/libs/data-context'; +import { DataContext } from '../../../src'; + +describe('local context lib', () => { + describe('isLocalDataContext', () => { + ([ + [{ medicDb: {}, settings: {} }, true], + [{ medicDb: {}, settings: {}, hello: 'world' }, true], + [{ medicDb: {} }, false], + [{ settings: {} }, false], + [{}, false] + ] as [DataContext, boolean][]).forEach(([context, expected]) => { + it(`evaluates ${JSON.stringify(context)}`, () => { + expect(isLocalDataContext(context)).to.equal(expected); + }); + }); + }); + + describe('getLocalDataContext', () => { + const settingsService = { getAll: () => ({}) } as SettingsService; + const sourceDatabases = { medic: {} } as SourceDatabases; + + ([ + null, + {}, + { getAll: 'not a function' }, + 'hello' + ] as unknown as SettingsService[]).forEach((settingsService) => { + it('throws an error if the settings service is invalid', () => { + expect(() => getLocalDataContext(settingsService, sourceDatabases)) + .to.throw(`Invalid settings service [${JSON.stringify(settingsService)}].`); + }); + }); + + ([ + null, + {}, + { medic: () => 'a function' }, + 'hello' + ] as unknown as SourceDatabases[]).forEach((sourceDatabases) => { + it('throws an error if the source databases are invalid', () => { + expect(() => getLocalDataContext(settingsService, sourceDatabases)) + .to.throw(`Invalid source databases [${JSON.stringify(sourceDatabases)}].`); + }); + }); + + it('returns the local data context', () => { + const dataContext = getLocalDataContext(settingsService, sourceDatabases); + expect(dataContext).to.deep.include({ medicDb: sourceDatabases.medic, settings: settingsService }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/libs/doc.spec.ts b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts new file mode 100644 index 00000000000..640237a1165 --- /dev/null +++ b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts @@ -0,0 +1,218 @@ +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 { expect } from 'chai'; + +describe('local doc lib', () => { + let dbGet: SinonStub; + let dbAllDocs: SinonStub; + let dbQuery: SinonStub; + let db: PouchDB.Database; + let isDoc: SinonStub; + let error: SinonStub; + + beforeEach(() => { + dbGet = sinon.stub(); + dbAllDocs = sinon.stub(); + dbQuery = sinon.stub(); + db = { + get: dbGet, + allDocs: dbAllDocs, + query: dbQuery + } as unknown as PouchDB.Database; + isDoc = sinon.stub(Doc, 'isDoc'); + error = sinon.stub(logger, 'error'); + }); + + afterEach(() => sinon.restore()); + + describe('getDocById', () => { + it('returns a doc by id', async () => { + const uuid = 'uuid'; + const doc = { type: 'doc' }; + dbGet.resolves(doc); + isDoc.returns(true); + + const result = await getDocById(db)(uuid); + + expect(result).to.equal(doc); + expect(dbGet.calledOnceWithExactly(uuid)).to.be.true; + expect(isDoc.calledOnceWithExactly(doc)).to.be.true; + }); + + it('returns null if the result is not a doc', async () => { + const uuid = 'uuid'; + const doc = { type: 'not-doc' }; + dbGet.resolves(doc); + isDoc.returns(false); + + const result = await getDocById(db)(uuid); + + expect(result).to.be.null; + expect(dbGet.calledOnceWithExactly(uuid)).to.be.true; + expect(isDoc.calledOnceWithExactly(doc)).to.be.true; + }); + + it('returns null if the doc is not found', async () => { + const uuid = 'uuid'; + dbGet.rejects({ status: 404 }); + + const result = await getDocById(db)(uuid); + + expect(result).to.be.null; + expect(dbGet.calledOnceWithExactly(uuid)).to.be.true; + expect(isDoc.notCalled).to.be.true; + expect(error.notCalled).to.be.true; + }); + + it('throws an error if an unexpected error occurs', async () => { + const uuid = 'uuid'; + const err = new Error('unexpected error'); + dbGet.rejects(err); + + await expect(getDocById(db)(uuid)).to.be.rejectedWith(err); + + expect(dbGet.calledOnceWithExactly(uuid)).to.be.true; + expect(isDoc.notCalled).to.be.true; + expect(error.calledOnceWithExactly(`Failed to fetch doc with id [${uuid}]`, err)).to.be.true; + }); + }); + + describe('getDocsByIds', () => { + it('returns docs for the given ids', async () => { + const doc0 = { _id: 'doc0' }; + const doc1 = { _id: 'doc1' }; + const doc2 = { _id: 'doc2' }; + const ids = [doc0._id, doc1._id, doc2._id]; + dbAllDocs.resolves({ + rows: [ + { doc: doc0 }, + { doc: doc1 }, + { doc: doc2 } + ] + }); + isDoc.returns(true); + + const result = await getDocsByIds(db)(ids); + + expect(result).to.deep.equal([doc0, doc1, doc2]); + expect(dbAllDocs.calledOnceWithExactly({ keys: ids, include_docs: true })).to.be.true; + expect(isDoc.args).to.deep.equal([[doc0], [doc1], [doc2]]); + }); + + it('returns an empty array if no ids are provided', async () => { + const result = await getDocsByIds(db)([]); + + expect(result).to.deep.equal([]); + expect(dbAllDocs.notCalled).to.be.true; + expect(isDoc.notCalled).to.be.true; + }); + + it('does not return an entry for a blank id', async () => { + const result = await getDocsByIds(db)(['']); + + expect(result).to.deep.equal([]); + expect(dbAllDocs.notCalled).to.be.true; + expect(isDoc.notCalled).to.be.true; + }); + + it('does not return an entry that is not a doc', async () => { + const doc0 = { _id: 'doc0' }; + const ids = [doc0._id]; + dbAllDocs.resolves({ + rows: [ + { doc: doc0 }, + ] + }); + isDoc.returns(false); + + const result = await getDocsByIds(db)(ids); + + expect(result).to.deep.equal([]); + expect(dbAllDocs.calledOnceWithExactly({ keys: ids, include_docs: true })).to.be.true; + expect(isDoc.args).to.deep.equal([[doc0]]); + }); + + it('returns one entry when duplicate ids are provided', async () => { + const doc0 = { _id: 'doc0' }; + dbAllDocs.resolves({ + rows: [{ doc: doc0 }] + }); + isDoc.returns(true); + + const result = await getDocsByIds(db)([doc0._id, doc0._id]); + + expect(result).to.deep.equal([doc0]); + expect(dbAllDocs.calledOnceWithExactly({ keys: [doc0._id], include_docs: true })).to.be.true; + expect(isDoc.calledOnceWithExactly(doc0)).to.be.true; + }); + }); + + describe('queryDocsByKey', () => { + it('returns lineage docs for the given id', 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/docs_by_id_lineage')(doc0._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 + })).to.be.true; + expect(isDoc.args).to.deep.equal([[doc0], [doc1], [doc2]]); + }); + + it('returns null if a doc in the lineage is not found', async () => { + const doc0 = { _id: 'doc0' }; + const doc2 = { _id: 'doc2' }; + dbQuery.resolves({ + rows: [ + { doc: doc0 }, + { doc: null }, + { doc: doc2 } + ] + }); + isDoc.returns(true); + + const result = await queryDocsByKey(db, 'medic-client/docs_by_id_lineage')(doc0._id); + + expect(result).to.deep.equal([doc0, null, doc2]); + expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { + startkey: [doc0._id], + endkey: [doc0._id, {}], + include_docs: true + })).to.be.true; + expect(isDoc.args).to.deep.equal([[doc0], [null], [doc2]]); + }); + + it('returns null if the returned object is not a doc', async () => { + const doc0 = { _id: 'doc0' }; + dbQuery.resolves({ + rows: [{ doc: doc0 }] + }); + isDoc.returns(false); + + const result = await queryDocsByKey(db, 'medic-client/docs_by_id_lineage')(doc0._id); + + expect(result).to.deep.equal([null]); + expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { + startkey: [doc0._id], + endkey: [doc0._id, {}], + include_docs: true + })).to.be.true; + expect(isDoc.calledOnceWithExactly(doc0)).to.be.true; + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts b/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts new file mode 100644 index 00000000000..20adcd0a0cd --- /dev/null +++ b/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts @@ -0,0 +1,187 @@ +import { expect } from 'chai'; +import { + getLineageDocsById, + getPrimaryContactIds, + hydrateLineage, + hydratePrimaryContact +} from '../../../src/local/libs/lineage'; +import sinon, { SinonStub } from 'sinon'; +import * as LocalDoc from '../../../src/local/libs/doc'; +import { Doc } from '../../../src/libs/doc'; +import logger from '@medic/logger'; + +describe('local lineage lib', () => { + let debug: SinonStub; + + beforeEach(() => { + debug = sinon.stub(logger, 'debug'); + }); + + afterEach(() => sinon.restore()); + + it('getLineageDocsById', () => { + const queryFn = sinon.stub(); + const queryDocsByKey = sinon + .stub(LocalDoc, 'queryDocsByKey') + .returns(queryFn); + const medicDb = { hello: 'world' } as unknown as PouchDB.Database; + + const result = getLineageDocsById(medicDb); + + expect(result).to.equal(queryFn); + expect(queryDocsByKey.calledOnceWithExactly(medicDb, 'medic-client/docs_by_id_lineage')).to.be.true; + }); + + describe('getPrimaryContactIds', () => { + it('returns the primary contact ids', () => { + const place0 = { _id: 'place-0', _rev: 'rev-1', contact: { _id: 'contact-0' } }; + const place1 = { _id: 'place-1', _rev: 'rev-2', contact: { _id: 'contact-1' } }; + const place2 = { _id: 'place-2', _rev: 'rev-3', contact: { _id: 'contact-2' } }; + + const result = getPrimaryContactIds([place0, place1, place2]); + + expect(result).to.deep.equal([place0.contact._id, place1.contact._id, place2.contact._id]); + }); + + [ + null, + { _id: 'place-0', _rev: 'rev-1' }, + { _id: 'place-0', _rev: 'rev-1', contact: { hello: 'contact-0' } }, + { _id: 'place-0', _rev: 'rev-1', contact: { _id: '' } } + ].forEach((place) => { + it(`returns nothing for ${JSON.stringify(place)}`, () => { + const result = getPrimaryContactIds([place]); + expect(result).to.be.empty; + }); + }); + }); + + describe('hydratePrimaryContact', () => { + it('returns a place with its contact hydrated', () => { + const contact = { _id: 'contact-0', _rev: 'rev-0', type: 'person' }; + const contacts = [{ _id: 'contact-1', _rev: 'rev-1', type: 'person' }, contact]; + const place0 = { _id: 'place-0', _rev: 'rev-1', contact: { _id: 'contact-0' } }; + + const result = hydratePrimaryContact(contacts)(place0); + + expect(result).to.deep.equal({ ...place0, contact }); + expect(debug.notCalled).to.be.true; + }); + + it('returns a place unchanged if no contacts are provided', () => { + const place0 = { _id: 'place-0', _rev: 'rev-1', contact: { _id: 'contact-0' } }; + + const result = hydratePrimaryContact([])(place0); + + expect(result).to.equal(place0); + expect(debug.calledOnceWithExactly( + `No contact found with identifier [${place0.contact._id}] for the place [${place0._id}].` + )).to.be.true; + }); + + it('returns a place unchanged if no matching contact could be found', () => { + const contacts = [ + { _id: 'contact-1', _rev: 'rev-1', type: 'person' }, + { _id: 'contact-2', _rev: 'rev-0', type: 'person' } + ]; + const place0 = { _id: 'place-0', _rev: 'rev-1', contact: { _id: 'contact-0' } }; + + const result = hydratePrimaryContact(contacts)(place0); + + expect(result).to.equal(place0); + expect(debug.calledOnceWithExactly( + `No contact found with identifier [${place0.contact._id}] for the place [${place0._id}].` + )).to.be.true; + }); + + it('returns a place unchanged if its contact is not identifiable', () => { + const contacts = [ + { _id: 'contact-1', _rev: 'rev-1', type: 'person' }, + { _id: 'contact-2', _rev: 'rev-0', type: 'person' } + ]; + const place0 = { _id: 'place-0', _rev: 'rev-1', contact: { hello: 'contact-1' } }; + + const result = hydratePrimaryContact(contacts)(place0); + + expect(result).to.equal(place0); + expect(debug.notCalled).to.be.true; + }); + + it('returns null if no place is provided', () => { + const contacts = [ + { _id: 'contact-1', _rev: 'rev-1', type: 'person' }, + { _id: 'contact-2', _rev: 'rev-0', type: 'person' } + ]; + + const result = hydratePrimaryContact(contacts)(null); + + expect(result).to.be.null; + expect(debug.notCalled).to.be.true; + }); + }); + + describe('hydrateLineage', () => { + it('returns a contact with its lineage populated', () => { + const contact = { _id: 'contact-0', _rev: 'rev-0', type: 'person' }; + const place0 = { _id: 'place-0', _rev: 'rev-1' }; + const place1 = { _id: 'place-1', _rev: 'rev-2' }; + const place2 = { _id: 'place-2', _rev: 'rev-3' }; + const places = [place0, place1, place2]; + + const result = hydrateLineage(contact, places); + + expect(result).to.deep.equal({ + ...contact, + parent: { + ...place0, + parent: { + ...place1, + parent: place2 + } + } + }); + expect(debug.notCalled).to.be.true; + }); + + it('fills in missing lineage gaps from contact\'s denormalized parent data', () => { + const contact = { _id: 'contact-0', _rev: 'rev-0', type: 'person', parent: { + _id: 'place-0', + parent: { + _id: 'place-1', + parent: { + _id: 'place-2' + } + } + } }; + const place0 = { _id: 'place-0', _rev: 'rev-1' }; + const place1 = null; + const place2 = { _id: 'place-2', _rev: 'rev-3' }; + const places = [place0, place1, place2, null]; + + const result = hydrateLineage(contact, places); + + expect(result).to.deep.equal({ + ...contact, + parent: { + ...place0, + parent: { + _id: 'place-1', + parent: { + ...place2, + parent: { + _id: null + } + } + } + } + }); + expect(debug.calledTwice).to.be.true; + expect(debug.firstCall.calledWithExactly( + `Lineage place with identifier [place-1] was not found when getting lineage for [${contact._id}].` + )).to.be.true; + expect(debug.secondCall.calledWithExactly( + `Lineage place with identifier [] was not found when getting lineage for [${contact._id}].` + )).to.be.true; + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/person.spec.ts b/shared-libs/cht-datasource/test/local/person.spec.ts new file mode 100644 index 00000000000..22d3c3b1e51 --- /dev/null +++ b/shared-libs/cht-datasource/test/local/person.spec.ts @@ -0,0 +1,228 @@ +import sinon, { SinonStub } from 'sinon'; +import contactTypeUtils from '@medic/contact-types-utils'; +import logger from '@medic/logger'; +import { Doc } from '../../src/libs/doc'; +import * as Person from '../../src/local/person'; +import * as LocalDoc from '../../src/local/libs/doc'; +import * as Lineage from '../../src/local/libs/lineage'; +import { expect } from 'chai'; +import { LocalDataContext } from '../../src/local/libs/data-context'; +import * as Core from '../../src/libs/core'; + +describe('local person', () => { + let localContext: LocalDataContext; + let settingsGetAll: SinonStub; + let warn: SinonStub; + let debug: SinonStub; + let isPerson: SinonStub; + + beforeEach(() => { + settingsGetAll = sinon.stub(); + localContext = { + medicDb: {} as PouchDB.Database, + settings: { getAll: settingsGetAll } + } as unknown as LocalDataContext; + warn = sinon.stub(logger, 'warn'); + debug = sinon.stub(logger, 'debug'); + isPerson = sinon.stub(contactTypeUtils, 'isPerson'); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + const settings = { hello: 'world' } as const; + + describe('get', () => { + const identifier = { uuid: 'uuid' } as const; + let getDocByIdOuter: SinonStub; + let getDocByIdInner: SinonStub; + + beforeEach(() => { + getDocByIdInner = sinon.stub(); + getDocByIdOuter = sinon.stub(LocalDoc, 'getDocById').returns(getDocByIdInner); + }); + + it('returns a person by UUID', async () => { + const doc = { type: 'person' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + isPerson.returns(true); + + const result = await Person.v1.get(localContext)(identifier); + + expect(result).to.equal(doc); + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPerson.calledOnceWithExactly(settings, doc)).to.be.true; + expect(warn.notCalled).to.be.true; + }); + + it('returns null if the identified doc does not have a person type', async () => { + const doc = { type: 'not-person' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + isPerson.returns(false); + + const result = await Person.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPerson.calledOnceWithExactly(settings, doc)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${identifier.uuid}] is not a valid person.`)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getDocByIdInner.resolves(null); + + const result = await Person.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(settingsGetAll.notCalled).to.be.true; + expect(isPerson.notCalled).to.be.true; + expect(warn.calledOnceWithExactly(`No person found for identifier [${identifier.uuid}].`)).to.be.true; + }); + }); + + describe('getWithLineage', () => { + const identifier = { uuid: 'uuid' } as const; + let getLineageDocsByIdInner: SinonStub; + let getLineageDocsByIdOuter: SinonStub; + let getDocsByIdsInner: SinonStub; + let getDocsByIdsOuter: SinonStub; + let getPrimaryContactIds: SinonStub; + let hydratePrimaryContactInner: SinonStub; + let hydratePrimaryContactOuter: SinonStub; + let hydrateLineage: SinonStub; + let deepCopy: SinonStub; + + beforeEach(() => { + getLineageDocsByIdInner = sinon.stub(); + getLineageDocsByIdOuter = sinon + .stub(Lineage, 'getLineageDocsById') + .returns(getLineageDocsByIdInner); + getDocsByIdsInner = sinon.stub(); + getDocsByIdsOuter = sinon + .stub(LocalDoc, 'getDocsByIds') + .returns(getDocsByIdsInner); + getPrimaryContactIds = sinon.stub(Lineage, 'getPrimaryContactIds'); + hydratePrimaryContactInner = sinon.stub(); + hydratePrimaryContactOuter = sinon + .stub(Lineage, 'hydratePrimaryContact') + .returns(hydratePrimaryContactInner); + hydrateLineage = sinon.stub(Lineage, 'hydrateLineage'); + deepCopy = sinon.stub(Core, 'deepCopy'); + }); + + afterEach(() => { + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocsByIdsOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + }); + + it('returns a person with lineage', async () => { + const person = { type: 'person', _id: 'uuid', _rev: 'rev' }; + const place0 = { _id: 'place0', _rev: 'rev' }; + const place1 = { _id: 'place1', _rev: 'rev' }; + const place2 = { _id: 'place2', _rev: 'rev' }; + const contact0 = { _id: 'contact0', _rev: 'rev' }; + const contact1 = { _id: 'contact1', _rev: 'rev' }; + getLineageDocsByIdInner.resolves([person, place0, place1, place2]); + isPerson.returns(true); + settingsGetAll.returns(settings); + getPrimaryContactIds.returns([contact0._id, contact1._id, person._id]); + getDocsByIdsInner.resolves([contact0, contact1]); + const place0WithContact = { ...place0, contact: contact0 }; + const place1WithContact = { ...place1, contact: contact1 }; + hydratePrimaryContactInner.onFirstCall().returns(place0WithContact); + hydratePrimaryContactInner.onSecondCall().returns(place1WithContact); + hydratePrimaryContactInner.onThirdCall().returns(place2); + const personWithLineage = { ...person, lineage: true }; + hydrateLineage.returns(personWithLineage); + const copiedPerson = { ...personWithLineage }; + deepCopy.returns(copiedPerson); + + const result = await Person.v1.getWithLineage(localContext)(identifier); + + expect(result).to.equal(copiedPerson); + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPerson.calledOnceWithExactly(settings, person)).to.be.true; + expect(warn.notCalled).to.be.true; + expect(debug.notCalled).to.be.true; + expect(getPrimaryContactIds.calledOnceWithExactly([place0, place1, place2])).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly([contact0._id, contact1._id])).to.be.true; + expect(hydratePrimaryContactOuter.calledOnceWithExactly([person, contact0, contact1])).to.be.true; + expect(hydratePrimaryContactInner.calledThrice).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place0)).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place1)).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place2)).to.be.true; + expect(hydrateLineage.calledOnceWithExactly(person, [place0WithContact, place1WithContact, place2])).to.be.true; + expect(deepCopy.calledOnceWithExactly(personWithLineage)).to.be.true; + }); + + it('returns null when no person or lineage is found', async () => { + getLineageDocsByIdInner.resolves([]); + + const result = await Person.v1.getWithLineage(localContext)(identifier); + + expect(result).to.be.null; + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPerson.notCalled).to.be.true; + expect(warn.calledOnceWithExactly(`No person found for identifier [${identifier.uuid}].`)).to.be.true; + expect(debug.notCalled).to.be.true; + expect(getPrimaryContactIds.notCalled).to.be.true; + expect(getDocsByIdsInner.notCalled).to.be.true; + expect(hydratePrimaryContactOuter.notCalled).to.be.true; + expect(hydratePrimaryContactInner.notCalled).to.be.true; + expect(hydrateLineage.notCalled).to.be.true; + expect(deepCopy.notCalled).to.be.true; + }); + + it('returns null if the doc returned is not a person', async () => { + const person = { type: 'person', _id: 'uuid', _rev: 'rev' }; + const place0 = { _id: 'place0', _rev: 'rev' }; + const place1 = { _id: 'place1', _rev: 'rev' }; + const place2 = { _id: 'place2', _rev: 'rev' }; + getLineageDocsByIdInner.resolves([person, place0, place1, place2]); + isPerson.returns(false); + settingsGetAll.returns(settings); + + const result = await Person.v1.getWithLineage(localContext)(identifier); + + expect(result).to.be.null; + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPerson.calledOnceWithExactly(settings, person)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${identifier.uuid}] is not a valid person.`)).to.be.true; + expect(debug.notCalled).to.be.true; + expect(getPrimaryContactIds.notCalled).to.be.true; + expect(getDocsByIdsInner.notCalled).to.be.true; + expect(hydratePrimaryContactOuter.notCalled).to.be.true; + expect(hydratePrimaryContactInner.notCalled).to.be.true; + expect(hydrateLineage.notCalled).to.be.true; + expect(deepCopy.notCalled).to.be.true; + }); + + it('returns a person if no lineage is found', async () => { + const person = { type: 'person', _id: 'uuid', _rev: 'rev' }; + getLineageDocsByIdInner.resolves([person]); + isPerson.returns(true); + settingsGetAll.returns(settings); + + const result = await Person.v1.getWithLineage(localContext)(identifier); + + expect(result).to.equal(person); + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPerson.calledOnceWithExactly(settings, person)).to.be.true; + expect(warn.notCalled).to.be.true; + expect(debug.calledOnceWithExactly(`No lineage places found for person [${identifier.uuid}].`)).to.be.true; + expect(getPrimaryContactIds.notCalled).to.be.true; + expect(getDocsByIdsInner.notCalled).to.be.true; + expect(hydratePrimaryContactOuter.notCalled).to.be.true; + expect(hydratePrimaryContactInner.notCalled).to.be.true; + expect(hydrateLineage.notCalled).to.be.true; + expect(deepCopy.notCalled).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/place.spec.ts b/shared-libs/cht-datasource/test/local/place.spec.ts new file mode 100644 index 00000000000..da8c9c50c61 --- /dev/null +++ b/shared-libs/cht-datasource/test/local/place.spec.ts @@ -0,0 +1,226 @@ +import sinon, { SinonStub } from 'sinon'; +import contactTypeUtils from '@medic/contact-types-utils'; +import logger from '@medic/logger'; +import { Doc } from '../../src/libs/doc'; +import * as Place from '../../src/local/place'; +import * as LocalDoc from '../../src/local/libs/doc'; +import { expect } from 'chai'; +import { LocalDataContext } from '../../src/local/libs/data-context'; +import * as Lineage from '../../src/local/libs/lineage'; +import * as Core from '../../src/libs/core'; + +describe('local place', () => { + let localContext: LocalDataContext; + let settingsGetAll: SinonStub; + let warn: SinonStub; + let debug: SinonStub; + let isPlace: SinonStub; + + beforeEach(() => { + settingsGetAll = sinon.stub(); + localContext = { + medicDb: {} as PouchDB.Database, + settings: { getAll: settingsGetAll } + } as unknown as LocalDataContext; + warn = sinon.stub(logger, 'warn'); + debug = sinon.stub(logger, 'debug'); + isPlace = sinon.stub(contactTypeUtils, 'isPlace'); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + const settings = { hello: 'world' } as const; + + describe('get', () => { + const identifier = { uuid: 'uuid' } as const; + let getDocByIdOuter: SinonStub; + let getDocByIdInner: SinonStub; + + beforeEach(() => { + getDocByIdInner = sinon.stub(); + getDocByIdOuter = sinon.stub(LocalDoc, 'getDocById').returns(getDocByIdInner); + }); + + it('returns a place by UUID', async () => { + const doc = { type: 'clinic' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + isPlace.returns(true); + + const result = await Place.v1.get(localContext)(identifier); + + expect(result).to.equal(doc); + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPlace.calledOnceWithExactly(settings, doc)).to.be.true; + expect(warn.notCalled).to.be.true; + }); + + it('returns null if the identified doc does not have a place type', async () => { + const doc = { type: 'not-place' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + isPlace.returns(false); + + const result = await Place.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPlace.calledOnceWithExactly(settings, doc)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${identifier.uuid}] is not a valid place.`)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getDocByIdInner.resolves(null); + + const result = await Place.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(settingsGetAll.notCalled).to.be.true; + expect(isPlace.notCalled).to.be.true; + expect(warn.calledOnceWithExactly(`No place found for identifier [${identifier.uuid}].`)).to.be.true; + }); + }); + + describe('getWithLineage', () => { + const identifier = { uuid: 'place0' } as const; + let getLineageDocsByIdInner: SinonStub; + let getLineageDocsByIdOuter: SinonStub; + let getDocsByIdsInner: SinonStub; + let getDocsByIdsOuter: SinonStub; + let getPrimaryContactIds: SinonStub; + let hydratePrimaryContactInner: SinonStub; + let hydratePrimaryContactOuter: SinonStub; + let hydrateLineage: SinonStub; + let deepCopy: SinonStub; + + beforeEach(() => { + getLineageDocsByIdInner = sinon.stub(); + getLineageDocsByIdOuter = sinon + .stub(Lineage, 'getLineageDocsById') + .returns(getLineageDocsByIdInner); + getDocsByIdsInner = sinon.stub(); + getDocsByIdsOuter = sinon + .stub(LocalDoc, 'getDocsByIds') + .returns(getDocsByIdsInner); + getPrimaryContactIds = sinon.stub(Lineage, 'getPrimaryContactIds'); + hydratePrimaryContactInner = sinon.stub(); + hydratePrimaryContactOuter = sinon + .stub(Lineage, 'hydratePrimaryContact') + .returns(hydratePrimaryContactInner); + hydrateLineage = sinon.stub(Lineage, 'hydrateLineage'); + deepCopy = sinon.stub(Core, 'deepCopy'); + }); + + afterEach(() => { + expect(getLineageDocsByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocsByIdsOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + }); + + it('returns a place with lineage', async () => { + const place0 = { _id: 'place0', _rev: 'rev' }; + const place1 = { _id: 'place1', _rev: 'rev' }; + const place2 = { _id: 'place2', _rev: 'rev' }; + const contact0 = { _id: 'contact0', _rev: 'rev' }; + const contact1 = { _id: 'contact1', _rev: 'rev' }; + getLineageDocsByIdInner.resolves([place0, place1, place2]); + isPlace.returns(true); + settingsGetAll.returns(settings); + getPrimaryContactIds.returns([contact0._id, contact1._id]); + getDocsByIdsInner.resolves([contact0, contact1]); + const place0WithContact = { ...place0, contact: contact0 }; + const place1WithContact = { ...place1, contact: contact1 }; + hydratePrimaryContactInner.onFirstCall().returns(place0WithContact); + hydratePrimaryContactInner.onSecondCall().returns(place1WithContact); + hydratePrimaryContactInner.onThirdCall().returns(place2); + const place0WithLineage = { ...place0WithContact, lineage: true }; + hydrateLineage.returns(place0WithLineage); + const copiedPlace = { ...place0WithLineage }; + deepCopy.returns(copiedPlace); + + const result = await Place.v1.getWithLineage(localContext)(identifier); + + expect(result).to.equal(copiedPlace); + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPlace.calledOnceWithExactly(settings, place0)).to.be.true; + expect(warn.notCalled).to.be.true; + expect(debug.notCalled).to.be.true; + expect(getPrimaryContactIds.calledOnceWithExactly([place0, place1, place2])).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly([contact0._id, contact1._id])).to.be.true; + expect(hydratePrimaryContactOuter.calledOnceWithExactly([contact0, contact1])).to.be.true; + expect(hydratePrimaryContactInner.calledThrice).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place0)).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place1)).to.be.true; + expect(hydratePrimaryContactInner.calledWith(place2)).to.be.true; + expect(hydrateLineage.calledOnceWithExactly(place0WithContact, [place1WithContact, place2])).to.be.true; + expect(deepCopy.calledOnceWithExactly(place0WithLineage)).to.be.true; + }); + + it('returns null when no place or lineage is found', async () => { + getLineageDocsByIdInner.resolves([]); + + const result = await Place.v1.getWithLineage(localContext)(identifier); + + expect(result).to.be.null; + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPlace.notCalled).to.be.true; + expect(warn.calledOnceWithExactly(`No place found for identifier [${identifier.uuid}].`)).to.be.true; + expect(debug.notCalled).to.be.true; + expect(getPrimaryContactIds.notCalled).to.be.true; + expect(getDocsByIdsInner.notCalled).to.be.true; + expect(hydratePrimaryContactOuter.notCalled).to.be.true; + expect(hydratePrimaryContactInner.notCalled).to.be.true; + expect(hydrateLineage.notCalled).to.be.true; + expect(deepCopy.notCalled).to.be.true; + }); + + it('returns null if the doc returned is not a place', async () => { + const place0 = { _id: 'place0', _rev: 'rev' }; + const place1 = { _id: 'place1', _rev: 'rev' }; + const place2 = { _id: 'place2', _rev: 'rev' }; + getLineageDocsByIdInner.resolves([place0, place1, place2]); + isPlace.returns(false); + settingsGetAll.returns(settings); + + const result = await Place.v1.getWithLineage(localContext)(identifier); + + expect(result).to.be.null; + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPlace.calledOnceWithExactly(settings, place0)).to.be.true; + expect(warn.calledOnceWithExactly(`Document [${identifier.uuid}] is not a valid place.`)).to.be.true; + expect(debug.notCalled).to.be.true; + expect(getPrimaryContactIds.notCalled).to.be.true; + expect(getDocsByIdsInner.notCalled).to.be.true; + expect(hydratePrimaryContactOuter.notCalled).to.be.true; + expect(hydratePrimaryContactInner.notCalled).to.be.true; + expect(hydrateLineage.notCalled).to.be.true; + expect(deepCopy.notCalled).to.be.true; + }); + + it('returns a place if no lineage is found', async () => { + const place = { _id: 'place0', _rev: 'rev' }; + getLineageDocsByIdInner.resolves([place]); + isPlace.returns(true); + settingsGetAll.returns(settings); + + const result = await Place.v1.getWithLineage(localContext)(identifier); + + expect(result).to.equal(place); + expect(getLineageDocsByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPlace.calledOnceWithExactly(settings, place)).to.be.true; + expect(warn.notCalled).to.be.true; + expect(debug.calledOnceWithExactly(`No lineage places found for place [${identifier.uuid}].`)).to.be.true; + expect(getPrimaryContactIds.notCalled).to.be.true; + expect(getDocsByIdsInner.notCalled).to.be.true; + expect(hydratePrimaryContactOuter.notCalled).to.be.true; + expect(hydratePrimaryContactInner.notCalled).to.be.true; + expect(hydrateLineage.notCalled).to.be.true; + expect(deepCopy.notCalled).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/person.spec.ts b/shared-libs/cht-datasource/test/person.spec.ts new file mode 100644 index 00000000000..5dd3f0c1f2a --- /dev/null +++ b/shared-libs/cht-datasource/test/person.spec.ts @@ -0,0 +1,127 @@ +import * as Person from '../src/person'; +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 sinon, { SinonStub } from 'sinon'; +import { expect } from 'chai'; +import { DataContext } from '../src'; + +describe('person', () => { + const dataContext = { } as DataContext; + let assertDataContext: SinonStub; + let adapt: SinonStub; + let isUuidQualifier: SinonStub; + + beforeEach(() => { + assertDataContext = sinon.stub(Context, 'assertDataContext'); + adapt = sinon.stub(Context, 'adapt'); + isUuidQualifier = sinon.stub(Qualifier, 'isUuidQualifier'); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + const person = { _id: 'my-person' } as Person.v1.Person; + const qualifier = { uuid: person._id } as const; + let getPerson: SinonStub; + + beforeEach(() => { + getPerson = sinon.stub(); + adapt.returns(getPerson); + }); + + it('retrieves the person for the given qualifier from the data context', async () => { + isUuidQualifier.returns(true); + getPerson.resolves(person); + + const result = await Person.v1.get(dataContext)(qualifier); + + expect(result).to.equal(person); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.get, Remote.Person.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPerson.calledOnceWithExactly(qualifier)).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isUuidQualifier.returns(false); + + await expect(Person.v1.get(dataContext)(qualifier)) + .to.be.rejectedWith(`Invalid identifier [${JSON.stringify(qualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.get, Remote.Person.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPerson.notCalled).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Person.v1.get(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(isUuidQualifier.notCalled).to.be.true; + expect(getPerson.notCalled).to.be.true; + }); + }); + + describe('getWithLineage', () => { + const person = { _id: 'my-person' } as Person.v1.Person; + const qualifier = { uuid: person._id } as const; + let getPersonWithLineage: SinonStub; + + beforeEach(() => { + getPersonWithLineage = sinon.stub(); + adapt.returns(getPersonWithLineage); + }); + + it('retrieves the person with lineage for the given qualifier from the data context', async () => { + isUuidQualifier.returns(true); + getPersonWithLineage.resolves(person); + + const result = await Person.v1.getWithLineage(dataContext)(qualifier); + + expect(result).to.equal(person); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly( + dataContext, + Local.Person.v1.getWithLineage, + Remote.Person.v1.getWithLineage + )).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPersonWithLineage.calledOnceWithExactly(qualifier)).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isUuidQualifier.returns(false); + + await expect(Person.v1.getWithLineage(dataContext)(qualifier)) + .to.be.rejectedWith(`Invalid identifier [${JSON.stringify(qualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly( + dataContext, + Local.Person.v1.getWithLineage, + Remote.Person.v1.getWithLineage + )).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPersonWithLineage.notCalled).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Person.v1.getWithLineage(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(isUuidQualifier.notCalled).to.be.true; + expect(getPersonWithLineage.notCalled).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/place.spec.ts b/shared-libs/cht-datasource/test/place.spec.ts new file mode 100644 index 00000000000..0a6c8dee942 --- /dev/null +++ b/shared-libs/cht-datasource/test/place.spec.ts @@ -0,0 +1,127 @@ +import * as Place from '../src/place'; +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 sinon, { SinonStub } from 'sinon'; +import { expect } from 'chai'; +import { DataContext } from '../src'; + +describe('place', () => { + const dataContext = { } as DataContext; + let assertDataContext: SinonStub; + let adapt: SinonStub; + let isUuidQualifier: SinonStub; + + beforeEach(() => { + assertDataContext = sinon.stub(Context, 'assertDataContext'); + adapt = sinon.stub(Context, 'adapt'); + isUuidQualifier = sinon.stub(Qualifier, 'isUuidQualifier'); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + const place = { _id: 'my-place' } as Place.v1.Place; + const qualifier = { uuid: place._id } as const; + let getPlace: SinonStub; + + beforeEach(() => { + getPlace = sinon.stub(); + adapt.returns(getPlace); + }); + + it('retrieves the place for the given qualifier from the data context', async () => { + isUuidQualifier.returns(true); + getPlace.resolves(place); + + const result = await Place.v1.get(dataContext)(qualifier); + + expect(result).to.equal(place); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Place.v1.get, Remote.Place.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPlace.calledOnceWithExactly(qualifier)).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isUuidQualifier.returns(false); + + await expect(Place.v1.get(dataContext)(qualifier)) + .to.be.rejectedWith(`Invalid identifier [${JSON.stringify(qualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Place.v1.get, Remote.Place.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPlace.notCalled).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Place.v1.get(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(isUuidQualifier.notCalled).to.be.true; + expect(getPlace.notCalled).to.be.true; + }); + }); + + describe('getWithLineage', () => { + const place = { _id: 'my-place' } as Place.v1.Place; + const qualifier = { uuid: place._id } as const; + let getPlaceWithLineage: SinonStub; + + beforeEach(() => { + getPlaceWithLineage = sinon.stub(); + adapt.returns(getPlaceWithLineage); + }); + + it('retrieves the place with lineage for the given qualifier from the data context', async () => { + isUuidQualifier.returns(true); + getPlaceWithLineage.resolves(place); + + const result = await Place.v1.getWithLineage(dataContext)(qualifier); + + expect(result).to.equal(place); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly( + dataContext, + Local.Place.v1.getWithLineage, + Remote.Place.v1.getWithLineage + )).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPlaceWithLineage.calledOnceWithExactly(qualifier)).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isUuidQualifier.returns(false); + + await expect(Place.v1.getWithLineage(dataContext)(qualifier)) + .to.be.rejectedWith(`Invalid identifier [${JSON.stringify(qualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly( + dataContext, + Local.Place.v1.getWithLineage, + Remote.Place.v1.getWithLineage + )).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPlaceWithLineage.notCalled).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Place.v1.getWithLineage(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(isUuidQualifier.notCalled).to.be.true; + expect(getPlaceWithLineage.notCalled).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/qualifier.spec.ts b/shared-libs/cht-datasource/test/qualifier.spec.ts new file mode 100644 index 00000000000..0b9fcb3b5b3 --- /dev/null +++ b/shared-libs/cht-datasource/test/qualifier.spec.ts @@ -0,0 +1,34 @@ +import { byUuid, isUuidQualifier } from '../src/qualifier'; +import { expect } from 'chai'; + +describe('qualifier', () => { + describe('byUuid', () => { + it('builds a qualifier that identifies an entity by its UUID', () => { + expect(byUuid('uuid')).to.deep.equal({ uuid: 'uuid' }); + }); + + [ + null, + '', + { }, + ].forEach(uuid => { + it(`throws an error for ${JSON.stringify(uuid)}`, () => { + expect(() => byUuid(uuid as string)).to.throw(`Invalid UUID [${JSON.stringify(uuid)}].`); + }); + }); + }); + + describe('isUuidQualifier', () => { + [ + [ null, false ], + [ 'uuid', false ], + [ { uuid: { } }, false ], + [ { uuid: 'uuid' }, true ], + [ { uuid: 'uuid', other: 'other' }, true ] + ].forEach(([ identifier, expected ]) => { + it(`evaluates ${JSON.stringify(identifier)}`, () => { + expect(isUuidQualifier(identifier)).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 new file mode 100644 index 00000000000..8516b547e2e --- /dev/null +++ b/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts @@ -0,0 +1,152 @@ +import { expect } from 'chai'; +import logger from '@medic/logger'; +import sinon, { SinonStub } from 'sinon'; +import { + assertRemoteDataContext, + getResource, + getRemoteDataContext, + isRemoteDataContext, + RemoteDataContext +} from '../../../src/remote/libs/data-context'; +import { DataContext } from '../../../src'; + +describe('remote context lib', () => { + const context = { url: 'hello.world' } as RemoteDataContext; + let fetchResponse: { ok: boolean, status: number, statusText: string, json: SinonStub }; + let fetchStub: SinonStub; + let loggerError: SinonStub; + + beforeEach(() => { + fetchResponse = { + ok: true, + status: 200, + statusText: 'OK', + json: sinon.stub().resolves() + }; + fetchStub = sinon.stub(global, 'fetch').resolves(fetchResponse as unknown as Response); + loggerError = sinon.stub(logger, 'error'); + }); + + afterEach(() => sinon.restore()); + + describe('isRemoteDataContext', () => { + ([ + [{ url: 'hello.world' }, true], + [{ hello: 'world' }, false], + [{ }, false], + ] as [DataContext, boolean][]).forEach(([context, expected]) => { + it(`evaluates ${JSON.stringify(context)}`, () => { + expect(isRemoteDataContext(context)).to.equal(expected); + }); + }); + }); + + describe('assertRemoteDataContext', () => { + it('asserts a remote data context', () => { + const context = getRemoteDataContext('hello.world'); + + expect(() => assertRemoteDataContext(context)).to.not.throw(); + }); + + ([ + { hello: 'world' }, + { }, + ] as DataContext[]).forEach(context => { + it(`throws an error for ${JSON.stringify(context)}`, () => { + expect(() => assertRemoteDataContext(context)) + .to.throw(`Invalid remote data context [${JSON.stringify(context)}].`); + }); + }); + }); + + describe('getRemoteDataContext', () => { + + [ + '', + 'hello.world', + undefined + ].forEach(url => { + it(`returns a remote data context for URL: ${JSON.stringify(url)}`, () => { + const context = getRemoteDataContext(url); + + expect(isRemoteDataContext(context)).to.be.true; + expect(context).to.deep.include({ url: url ?? '' }); + }); + }); + + + [ + null, + 0, + {}, + [], + ].forEach(url => { + it(`throws an error for an invalid URL: ${JSON.stringify(url)}`, () => { + expect(() => getRemoteDataContext(url as string)) + .to.throw(`Invalid URL [${JSON.stringify(url)}].`); + }); + }); + }); + + describe('getResource', () => { + it('fetches a resource with a path', async () => { + const path = 'path'; + const resourceId = 'resource'; + const resource = { hello: 'world' }; + fetchResponse.json.resolves(resource); + + const response = await getResource(context, path)(resourceId); + + expect(response).to.equal(resource); + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}/${resourceId}?`)).to.be.true; + expect(fetchResponse.json.calledOnceWithExactly()).to.be.true; + }); + + it('returns null if the resource is not found', async () => { + const path = 'path'; + const resourceId = 'resource'; + fetchResponse.ok = false; + fetchResponse.status = 404; + + const response = await getResource(context, path)(resourceId); + + expect(response).to.be.null; + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}/${resourceId}?`)).to.be.true; + expect(fetchResponse.json.notCalled).to.be.true; + expect(loggerError.notCalled).to.be.true; + }); + + it('throws an error if the resource fetch rejects', async () => { + const path = 'path'; + const resourceId = 'resource'; + const expectedError = new Error('unexpected error'); + fetchStub.rejects(expectedError); + + await expect(getResource(context, path)(resourceId)).to.be.rejectedWith(expectedError); + + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}/${resourceId}?`)).to.be.true; + expect(loggerError.calledOnceWithExactly( + `Failed to fetch ${resourceId} from ${context.url}/${path}`, + 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'; + const resourceId = 'resource'; fetchResponse.ok = false; + fetchResponse.status = 501; + fetchResponse.statusText = 'Not Implemented'; + + await expect(getResource(context, path)(resourceId)).to.be.rejectedWith(fetchResponse.statusText); + + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}/${resourceId}?`)).to.be.true; + expect(loggerError.calledOnce).to.be.true; + expect(loggerError.args[0]).to.deep.equal([ + `Failed to fetch ${resourceId} from ${context.url}/${path}`, + 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 new file mode 100644 index 00000000000..41ac0a4b0b0 --- /dev/null +++ b/shared-libs/cht-datasource/test/remote/person.spec.ts @@ -0,0 +1,68 @@ +import sinon, { SinonStub } from 'sinon'; +import { expect } from 'chai'; +import * as Person from '../../src/remote/person'; +import * as RemoteEnv from '../../src/remote/libs/data-context'; +import { RemoteDataContext } from '../../src/remote/libs/data-context'; + +describe('remote person', () => { + const remoteContext = {} as RemoteDataContext; + let getResourceInner: SinonStub; + let getResourceOuter: SinonStub; + + beforeEach(() => { + getResourceInner = sinon.stub(); + getResourceOuter = sinon.stub(RemoteEnv, 'getResource').returns(getResourceInner); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + const identifier = { uuid: 'uuid' } as const; + + describe('get', () => { + it('returns a person by UUID', async () => { + const doc = { type: 'person' }; + getResourceInner.resolves(doc); + + const result = await Person.v1.get(remoteContext)(identifier); + + expect(result).to.equal(doc); + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/person')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getResourceInner.resolves(null); + + const result = await Person.v1.get(remoteContext)(identifier); + + expect(result).to.be.null; + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/person')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + }); + + describe('getWithLineage', () => { + it('returns a person with lineage by UUID', async () => { + const doc = { type: 'person' }; + getResourceInner.resolves(doc); + + const result = await Person.v1.getWithLineage(remoteContext)(identifier); + + expect(result).to.equal(doc); + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/person')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid, { with_lineage: 'true' })).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getResourceInner.resolves(null); + + const result = await Person.v1.getWithLineage(remoteContext)(identifier); + + expect(result).to.be.null; + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/person')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid, { with_lineage: 'true' })).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/remote/place.spec.ts b/shared-libs/cht-datasource/test/remote/place.spec.ts new file mode 100644 index 00000000000..12b853b7727 --- /dev/null +++ b/shared-libs/cht-datasource/test/remote/place.spec.ts @@ -0,0 +1,68 @@ +import sinon, { SinonStub } from 'sinon'; +import { expect } from 'chai'; +import * as Place from '../../src/remote/place'; +import * as RemoteEnv from '../../src/remote/libs/data-context'; +import { RemoteDataContext } from '../../src/remote/libs/data-context'; + +describe('remote place', () => { + const remoteContext = {} as RemoteDataContext; + let getResourceInner: SinonStub; + let getResourceOuter: SinonStub; + + beforeEach(() => { + getResourceInner = sinon.stub(); + getResourceOuter = sinon.stub(RemoteEnv, 'getResource').returns(getResourceInner); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + const identifier = { uuid: 'uuid' } as const; + + describe('get', () => { + it('returns a place by UUID', async () => { + const doc = { type: 'clinic' }; + getResourceInner.resolves(doc); + + const result = await Place.v1.get(remoteContext)(identifier); + + expect(result).to.equal(doc); + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/place')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getResourceInner.resolves(null); + + const result = await Place.v1.get(remoteContext)(identifier); + + expect(result).to.be.null; + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/place')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + }); + + describe('getWithLineage', () => { + it('returns a place with lineage by UUID', async () => { + const doc = { type: 'clinic' }; + getResourceInner.resolves(doc); + + const result = await Place.v1.getWithLineage(remoteContext)(identifier); + + expect(result).to.equal(doc); + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/place')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid, { with_lineage: 'true' })).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getResourceInner.resolves(null); + + const result = await Place.v1.getWithLineage(remoteContext)(identifier); + + expect(result).to.be.null; + expect(getResourceOuter.calledOnceWithExactly(remoteContext, 'api/v1/place')).to.be.true; + expect(getResourceInner.calledOnceWithExactly(identifier.uuid, { with_lineage: 'true' })).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/tsconfig.build.json b/shared-libs/cht-datasource/tsconfig.build.json new file mode 100644 index 00000000000..eb8a3385e2d --- /dev/null +++ b/shared-libs/cht-datasource/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + // Flatten output in the dist directory so `src` is not included in the path + "compilerOptions": { "rootDir": "./src" }, + // Do not include the test files + "include": ["src/**/*.ts"], +} diff --git a/shared-libs/cht-datasource/tsconfig.json b/shared-libs/cht-datasource/tsconfig.json new file mode 100644 index 00000000000..d0340621ff1 --- /dev/null +++ b/shared-libs/cht-datasource/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "composite": true, + "declarationMap": true, + "outDir": "dist", + "sourceMap": true, + "tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo", + }, + "files": [ + "src/auth.js" + ], + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/shared-libs/cht-script-api/README.md b/shared-libs/cht-script-api/README.md deleted file mode 100644 index d66eb9e6949..00000000000 --- a/shared-libs/cht-script-api/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# CHT Script API - -The CHT Script API library is intended to be agnostic and simple. It provides a versioned API from feature modules. - -## API v1 - -The API v1 is defined as follows: - -| Function | Arguments | Description | -| -------- | --------- | ----------- | -| hasPermissions | String or array of permission name(s).
Array of user roles.
Object of configured permissions in CHT-Core's settings. | Returns true if the user has the permission(s), otherwise returns false | -| hasAnyPermission | Array of groups of permission name(s).
Array of user roles.
Object of configured permissions in CHT-Core's settings. | Returns true if the user has all the permissions of any of the provided groups, otherwise returns false | diff --git a/shared-libs/cht-script-api/package.json b/shared-libs/cht-script-api/package.json deleted file mode 100644 index 0e7eb33c477..00000000000 --- a/shared-libs/cht-script-api/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@medic/cht-script-api", - "version": "1.0.0", - "description": "Provides an API for CHT Scripts", - "main": "src/index.js", - "scripts": { - "test": "nyc --nycrcPath='../nyc.config.js' mocha ./test" - }, - "author": "", - "license": "Apache-2.0" -} diff --git a/shared-libs/cht-script-api/src/index.js b/shared-libs/cht-script-api/src/index.js deleted file mode 100644 index c7557c23a75..00000000000 --- a/shared-libs/cht-script-api/src/index.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * CHT Script API - Index - * Builds and exports a versioned API from feature modules. - * Whenever possible keep this file clean by defining new features in modules. - */ -const auth = require('./auth'); - -/** - * Verify if the user's role has the permission(s). - * @param permissions {string | string[]} Permission(s) to verify - * @param userRoles {string[]} Array of user roles. - * @param chtPermissionsSettings {object} Object of configured permissions in CHT-Core's settings. - * @return {boolean} - */ -const hasPermissions = (permissions, userRoles, chtPermissionsSettings) => { - return auth.hasPermissions(permissions, userRoles, chtPermissionsSettings); -}; - -/** - * Verify if the user's role has all the permissions of any of the provided groups. - * @param permissionsGroupList {string[][]} Array of groups of permissions due to the complexity of permission grouping - * @param userRoles {string[]} Array of user roles. - * @param chtPermissionsSettings {object} Object of configured permissions in CHT-Core's settings. - * @return {boolean} - */ -const hasAnyPermission = (permissionsGroupList, userRoles, chtPermissionsSettings) => { - return auth.hasAnyPermission(permissionsGroupList, userRoles, chtPermissionsSettings); -}; - -module.exports = { - v1: { - hasPermissions, - hasAnyPermission - } -}; diff --git a/shared-libs/cht-script-api/test/index.spec.js b/shared-libs/cht-script-api/test/index.spec.js deleted file mode 100644 index 7bc1288c242..00000000000 --- a/shared-libs/cht-script-api/test/index.spec.js +++ /dev/null @@ -1,56 +0,0 @@ -const expect = require('chai').expect; -const sinon = require('sinon'); -const chtScriptApi = require('../src/index'); -const auth = require('../src/auth'); - -describe('CHT Script API - index', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return versioned api and set functions', () => { - expect(chtScriptApi).to.have.all.keys([ 'v1' ]); - expect(chtScriptApi.v1).to.have.all.keys([ 'hasPermissions', 'hasAnyPermission' ]); - expect(chtScriptApi.v1.hasPermissions).to.be.a('function'); - expect(chtScriptApi.v1.hasAnyPermission).to.be.a('function'); - }); - - it('should call auth.hasPermissions', () => { - const authHasPermissions = sinon.stub(auth, 'hasPermissions').returns(true); - const userRoles = [ 'chw' ]; - const chtPermissionsSettings = { - can_backup_facilities: [ 'chw', 'national_admin' ], - can_export_messages: [ 'national_admin', 'chw', 'analytics' ] - }; - const permissions = [ 'can_backup_facilities', 'can_export_messages' ]; - - const result = chtScriptApi.v1.hasPermissions(permissions, userRoles, chtPermissionsSettings); - - expect(result).to.be.true; - expect(authHasPermissions.callCount).to.equal(1); - expect(authHasPermissions.args[0]).to.deep.equal([ permissions, userRoles, chtPermissionsSettings ]); - }); - - it('should call auth.hasAnyPermission', () => { - const authHasAnyPermission = sinon.stub(auth, 'hasAnyPermission').returns(true); - const userRoles = [ 'district_admin' ]; - const chtPermissionsSettings = { - can_backup_facilities: [ 'national_admin', 'district_admin' ], - can_export_messages: [ 'national_admin', 'district_admin', 'analytics' ], - can_add_people: [ 'national_admin', 'district_admin' ], - can_add_places: [ 'national_admin', 'district_admin' ], - can_roll_over: [ 'national_admin', 'district_admin' ], - }; - const permissions = [ - [ 'can_backup_facilities' ], - [ 'can_export_messages', 'can_roll_over' ], - [ 'can_add_people', 'can_add_places' ], - ]; - - const result = chtScriptApi.v1.hasAnyPermission(permissions, userRoles, chtPermissionsSettings); - - expect(result).to.be.true; - expect(authHasAnyPermission.callCount).to.equal(1); - expect(authHasAnyPermission.args[0]).to.deep.equal([ permissions, userRoles, chtPermissionsSettings ]); - }); -}); diff --git a/shared-libs/contact-types-utils/src/index.d.ts b/shared-libs/contact-types-utils/src/index.d.ts new file mode 100644 index 00000000000..1eb73feebcf --- /dev/null +++ b/shared-libs/contact-types-utils/src/index.d.ts @@ -0,0 +1,16 @@ +export function getTypeId(doc: Record): string | undefined; +export function getTypeById(config: Record, typeId: string): Record | null; +export function isPersonType(type: Record): boolean; +export function isPlaceType(type: Record): boolean; +export function hasParents(type: Record): boolean; +export function isParentOf(parentType: string | Record, childType: Record): boolean; +export function getLeafPlaceTypes(config: Record): Record[]; +export function getContactType(config: Record, contact: Record): Record | undefined; +export function isPerson(config: Record, contact: Record): boolean; +export function isPlace(config: Record, contact: Record): boolean; +export function isHardcodedType(type: string): boolean; +export declare const HARDCODED_TYPES: string[]; +export function getContactTypes(config?: Record): Record[]; +export function getChildren(config?: Record, parentType?: string | Record): Record[]; +export function getPlaceTypes(config?: Record): Record[]; +export function getPersonTypes(config?: Record): Record[]; diff --git a/shared-libs/contact-types-utils/src/index.js b/shared-libs/contact-types-utils/src/index.js index d877183f30e..6a829002bcd 100644 --- a/shared-libs/contact-types-utils/src/index.js +++ b/shared-libs/contact-types-utils/src/index.js @@ -67,6 +67,11 @@ const isPlace = (config, contact) => { return isPlaceType(type); }; +const isSameContactType = (contacts) => { + const contactTypes = new Set(contacts.map(contact => getTypeId(contact))); + return contactTypes.size === 1; +}; + const isHardcodedType = type => HARDCODED_TYPES.includes(type); const isOrphan = (type) => !type.parents || !type.parents.length; @@ -93,6 +98,7 @@ module.exports = { getContactType, isPerson, isPlace, + isSameContactType, isHardcodedType, HARDCODED_TYPES, getContactTypes, diff --git a/shared-libs/contact-types-utils/test/index.js b/shared-libs/contact-types-utils/test/index.js index 7c56002eb38..6850c28db89 100644 --- a/shared-libs/contact-types-utils/test/index.js +++ b/shared-libs/contact-types-utils/test/index.js @@ -155,6 +155,45 @@ describe('ContactType Utils', () => { }); }); + describe('isSameContactType', () => { + it('should return true for hardcoded contacts of the same type', () => { + chai.expect(utils.isSameContactType([ + { type: 'contact', contact_type: 'health_center' }, + { type: 'contact', contact_type: 'health_center' }, + ])).to.equal(true); + }); + it('should return true for configurable contacts of the same type', () => { + chai.expect(utils.isSameContactType([ + { type: 'my_health_center' }, + { type: 'my_health_center' }, + ])).to.equal(true); + }); + it('should return true for a mix of hardcoded and configurable types of the same hierarchy', () => { + chai.expect(utils.isSameContactType([ + { type: 'health_center' }, + { type: 'contact', contact_type: 'health_center' }, + ])).to.equal(true); + }); + it('should return false for hardcoded contacts of different type', () => { + chai.expect(utils.isSameContactType([ + { type: 'contact', contact_type: 'health_center' }, + { type: 'contact', contact_type: 'district_hospital' }, + ])).to.equal(false); + }); + it('should return false for configurable contacts of different type', () => { + chai.expect(utils.isSameContactType([ + { type: 'my_health_center' }, + { type: 'health_center' }, + ])).to.equal(false); + }); + it('should return true for a mix of hardcoded and configurable types of the same hierarchy', () => { + chai.expect(utils.isSameContactType([ + { type: 'health_center' }, + { type: 'contact', contact_type: 'my_health_center' }, + ])).to.equal(false); + }); + }); + describe('isPlaceType', () => { it('should return false for no type', () => { chai.expect(utils.isPlaceType(false)).to.equal(false); diff --git a/shared-libs/contacts/package.json b/shared-libs/contacts/package.json index 9a6a165f7ee..fe456e0314b 100755 --- a/shared-libs/contacts/package.json +++ b/shared-libs/contacts/package.json @@ -8,6 +8,7 @@ "author": "", "license": "Apache-2.0", "dependencies": { + "@medic/cht-datasource": "file:../cht-datasource", "@medic/contact-types-utils": "file:../contact-types-utils", "@medic/lineage": "file:../lineage", "lodash": "^4.17.21", diff --git a/shared-libs/contacts/src/index.js b/shared-libs/contacts/src/index.js index 915459fc23d..24cea0e64f2 100644 --- a/shared-libs/contacts/src/index.js +++ b/shared-libs/contacts/src/index.js @@ -1,12 +1,14 @@ const config = require('./libs/config'); const db = require('./libs/db'); +const dataContext = require('./libs/data-context'); const lineage = require('./libs/lineage'); const people = require('./people'); const places = require('./places'); -module.exports = (sourceConfig, sourceDb) => { +module.exports = (sourceConfig, sourceDb, sourceDataContext) => { config.init(sourceConfig); db.init(sourceDb); + dataContext.init(sourceDataContext); lineage.init(require('@medic/lineage')(Promise, db.medic)); return { diff --git a/shared-libs/contacts/src/libs/data-context.js b/shared-libs/contacts/src/libs/data-context.js new file mode 100644 index 00000000000..0a22a184aea --- /dev/null +++ b/shared-libs/contacts/src/libs/data-context.js @@ -0,0 +1,5 @@ +module.exports = { + init: function(dataContext) { + Object.assign(module.exports, dataContext, { init: this.init }); + }, +}; diff --git a/shared-libs/contacts/src/people.js b/shared-libs/contacts/src/people.js index 701c250c951..a9194205acc 100644 --- a/shared-libs/contacts/src/people.js +++ b/shared-libs/contacts/src/people.js @@ -1,26 +1,21 @@ const _ = require('lodash'); +const dataContext = require('./libs/data-context'); const db = require('./libs/db'); const config = require('./libs/config'); const utils = require('./libs/utils'); const places = require('./places'); const lineage = require('./libs/lineage'); +const { Person, Qualifier } = require('@medic/cht-datasource'); const contactTypeUtils = require('@medic/contact-types-utils'); -const getPerson = id => { - return lineage.fetchHydratedDoc(id) - .catch(err => { - if (err.status === 404) { - throw { code: 404, message: 'Failed to find person.' }; - } - throw err; - }) - .then(doc => { - if (!isAPerson(doc)) { - throw { code: 404, message: 'Failed to find person.' }; - } - return doc; - }); -}; +const getPerson = id => dataContext + .bind(Person.v1.getWithLineage)(Qualifier.byUuid(id)) + .then(doc => { + if (!doc) { + return Promise.reject({ code: 404, message: 'Failed to find person.' }); + } + return doc; + }); const isAPerson = person => contactTypeUtils.isPerson(config.get(), person); diff --git a/shared-libs/contacts/src/places.js b/shared-libs/contacts/src/places.js index 9dd5a6d2ce0..b6f350e4b75 100644 --- a/shared-libs/contacts/src/places.js +++ b/shared-libs/contacts/src/places.js @@ -3,24 +3,37 @@ const config = require('./libs/config'); const people = require('./people'); const utils = require('./libs/utils'); const db = require('./libs/db'); +const dataContext = require('./libs/data-context'); const lineage = require('./libs/lineage'); +const { Place, Qualifier } = require('@medic/cht-datasource'); const contactTypesUtils = require('@medic/contact-types-utils'); const PLACE_EDITABLE_FIELDS = ['name', 'parent', 'contact', 'place_id']; -const getPlace = id => { - return lineage.fetchHydratedDoc(id) - .then(doc => { - if (!isAPlace(doc)) { - return Promise.reject({ status: 404 }); - } - return doc; - }) - .catch(err => { - if (err.status === 404) { - err.message = 'Failed to find place.'; - } +const getPlace = id => dataContext + .bind(Place.v1.getWithLineage)(Qualifier.byUuid(id)) + .then(doc => { + if (!doc) { + return Promise.reject({ status: 404, message: 'Failed to find place.' }); + } + return doc; + }); + +const placesExist = async (placeIds) => { + if (!Array.isArray(placeIds)) { + throw new Error('Invalid place ids list'); + } + + const result = await db.medic.allDocs({ keys: placeIds, include_docs: true }); + + for (const row of result.rows) { + if (!row.doc || row.error || !isAPlace(row.doc)) { + const err = new Error(`Failed to find place ${row.id}`); + err.status = 404; throw err; - }); + } + } + + return true; }; const isAPlace = place => place && contactTypesUtils.isPlace(config.get(), place); @@ -235,4 +248,5 @@ module.exports = { getPlace: getPlace, updatePlace: updatePlace, getOrCreatePlace: getOrCreatePlace, + placesExist, }; diff --git a/shared-libs/contacts/test/unit/people.spec.js b/shared-libs/contacts/test/unit/people.spec.js index de03b1af6f1..74ee9f31743 100644 --- a/shared-libs/contacts/test/unit/people.spec.js +++ b/shared-libs/contacts/test/unit/people.spec.js @@ -4,19 +4,20 @@ const places = require('../../src/places'); const cutils = require('../../src/libs/utils'); const config = require('../../src/libs/config'); const db = require('../../src/libs/db'); +const dataContext = require('../../src/libs/data-context'); const lineage = require('../../src/libs/lineage'); const sinon = require('sinon'); +const { Person, Qualifier } = require('@medic/cht-datasource'); describe('people controller', () => { - let fetchHydratedDoc; let minifyLineage; beforeEach(() => { config.init({ get: sinon.stub() }); db.init({ medic: { post: sinon.stub() } }); - fetchHydratedDoc = sinon.stub(); + dataContext.init({ bind: sinon.stub() }); minifyLineage = sinon.stub(); - lineage.init({ fetchHydratedDoc, minifyLineage }); + lineage.init({ minifyLineage }); }); afterEach(() => { @@ -63,26 +64,26 @@ describe('people controller', () => { }); describe('getPerson', () => { + let getWithLineage; - it('returns custom message on 404 errors.', done => { - fetchHydratedDoc.rejects({status: 404}); - controller._getPerson('x').catch(err => { - chai.expect(err.message).to.equal('Failed to find person.'); - done(); - }); + beforeEach(() => { + getWithLineage = sinon.stub(); + dataContext.bind.returns(getWithLineage); }); - it('returns not found message if doc is wrong type.', done => { - fetchHydratedDoc.resolves({type: 'clinic'}); - controller._getPerson('x').catch(err => { - chai.expect(err.message).to.equal('Failed to find person.'); - done(); - }); + afterEach(() => chai.expect(dataContext.bind.calledOnceWithExactly(Person.v1.getWithLineage)).to.be.true); + + it('throws error when person not found', async () => { + getWithLineage.resolves(null); + + await chai.expect(controller._getPerson('x')).to.be.rejectedWith('Failed to find person.'); + + chai.expect(getWithLineage.calledOnceWithExactly(Qualifier.byUuid('x'))).to.be.true; }); it('succeeds and returns doc when person type.', () => { config.get.returns([{ id: 'person', person: true }]); - fetchHydratedDoc.resolves({type: 'person'}); + getWithLineage.resolves({type: 'person'}); return controller._getPerson('x').then(doc => { chai.expect(doc).to.deep.equal({ type: 'person' }); }); diff --git a/shared-libs/contacts/test/unit/places.spec.js b/shared-libs/contacts/test/unit/places.spec.js index 894a6c89d1d..5eed1838d90 100644 --- a/shared-libs/contacts/test/unit/places.spec.js +++ b/shared-libs/contacts/test/unit/places.spec.js @@ -1,11 +1,15 @@ const chai = require('chai'); +chai.use(require('chai-as-promised')); const sinon = require('sinon'); const config = require('../../src/libs/config'); const db = require('../../src/libs/db'); +const dataContext = require('../../src/libs/data-context'); const controller = require('../../src/places'); const people = require('../../src/people'); const cutils = require('../../src/libs/utils'); const lineage = require('../../src/libs/lineage'); +const { Place, Qualifier } = require('@medic/cht-datasource'); +const contactTypesUtils = require('@medic/contact-types-utils'); let examplePlace; @@ -59,11 +63,12 @@ const contactTypes = [ ]; describe('places controller', () => { - let fetchHydratedDoc; + let getWithLineage; beforeEach(() => { config.init({ get: sinon.stub() }); - db.init({ medic: { post: sinon.stub() } }); + db.init({ medic: { post: sinon.stub(), allDocs: sinon.stub() } }); + dataContext.init({ bind: sinon.stub() }); examplePlace = { type: 'clinic', name: 'St. Paul', @@ -72,7 +77,8 @@ describe('places controller', () => { config.get.returns({ contact_types: contactTypes }); lineage.init(require('@medic/lineage')(Promise, db.medic)); - fetchHydratedDoc = sinon.stub(lineage, 'fetchHydratedDoc'); + getWithLineage = sinon.stub(); + dataContext.bind.returns(getWithLineage); }); afterEach(() => { @@ -190,15 +196,14 @@ describe('places controller', () => { }); describe('getPlace', () => { + it('returns custom message on 404 errors.', async () => { + getWithLineage.resolves(null); - it('returns custom message on 404 errors.', done => { - fetchHydratedDoc.rejects({ status: 404 }); - controller.getPlace('x').catch(err => { - chai.expect(err.message).to.equal('Failed to find place.'); - done(); - }); - }); + await chai.expect(controller.getPlace('x')).to.be.rejectedWith('Failed to find place.'); + chai.expect(dataContext.bind.calledOnceWithExactly(Place.v1.getWithLineage)).to.be.true; + chai.expect(getWithLineage.calledOnceWithExactly(Qualifier.byUuid('x'))).to.be.true; + }); }); describe('createPlaces', () => { @@ -248,7 +253,7 @@ describe('places controller', () => { }); }); - it('supports objects with name and right type.', () => { + it('supports objects with name and right type.', async () => { const place = { name: 'CHP Family', type: 'clinic', @@ -283,15 +288,15 @@ describe('places controller', () => { return Promise.resolve({ id: 'ghi' }); } }); - fetchHydratedDoc.callsFake(id => { - if (id === 'abc') { + getWithLineage.callsFake(({ uuid }) => { + if (uuid === 'abc') { return Promise.resolve({ _id: 'abc', name: 'CHP Branch One', type: 'district_hospital' }); } - if (id === 'def') { + if (uuid === 'def') { return Promise.resolve({ _id: 'def', name: 'CHP Area One', @@ -303,7 +308,7 @@ describe('places controller', () => { } }); } - if (id === 'ghi') { + if (uuid === 'ghi') { return Promise.resolve({ _id: 'ghi', name: 'CHP Family', @@ -321,12 +326,15 @@ describe('places controller', () => { }); } }); - return controller._createPlaces(place).then(actual => { - chai.expect(actual).to.deep.equal({ id: 'ghi' }); - }); + + const actual = await controller._createPlaces(place); + + chai.expect(actual).to.deep.equal({ id: 'ghi' }); + chai.expect(dataContext.bind.args).to.deep.equal([[Place.v1.getWithLineage], [Place.v1.getWithLineage]]); + chai.expect(getWithLineage.args).to.deep.equal([[Qualifier.byUuid('abc')], [Qualifier.byUuid('def')]]); }); - it('creates contacts', () => { + it('creates contacts', async () => { const place = { name: 'CHP Family', type: 'health_center', @@ -361,7 +369,7 @@ describe('places controller', () => { return Promise.resolve({ id: 'hc', rev: '2' }); }); - fetchHydratedDoc.withArgs('hc').resolves({ + getWithLineage.withArgs(Qualifier.byUuid('hc')).resolves({ name: 'CHP Family', type: 'health_center', parent: { @@ -370,22 +378,24 @@ describe('places controller', () => { type: 'district_hospital' } }); - fetchHydratedDoc.withArgs('ad06d137').resolves({ + getWithLineage.withArgs(Qualifier.byUuid('ad06d137')).resolves({ _id: 'ad06d137', name: 'CHP Branch One', type: 'district_hospital' }); - return controller._createPlaces(place).then(actual => { - chai.expect(db.medic.post.callCount).to.equal(2); - chai.expect(actual).to.deep.equal({ - id: 'hc', - rev: '2', - contact: { - id: 'qwe', - } - }); + const actual = await controller._createPlaces(place); + + chai.expect(db.medic.post.callCount).to.equal(2); + chai.expect(actual).to.deep.equal({ + id: 'hc', + rev: '2', + contact: { + id: 'qwe', + } }); + chai.expect(dataContext.bind.args).to.deep.equal([[Place.v1.getWithLineage], [Place.v1.getWithLineage]]); + chai.expect(getWithLineage.args).to.deep.equal([[Qualifier.byUuid('ad06d137')], [Qualifier.byUuid('hc')]]); }); it('returns err if contact does not have name', done => { @@ -410,15 +420,13 @@ describe('places controller', () => { type: 'district_hospital', contact: 'person' }; - fetchHydratedDoc.rejects({ status: 404 }); + getWithLineage.resolves(null); const post = db.medic.post; - try { - await controller._createPlaces(place); - chai.expect.fail('Call should throw'); - } catch (err) { - chai.expect(err.message).to.equal('Failed to find person.'); - chai.expect(post.callCount).to.equal(0); - } + await chai.expect(controller._createPlaces(place)).to.be.rejectedWith('Failed to find person.'); + + chai.expect(post.callCount).to.equal(0); + chai.expect(dataContext.bind.calledOnce).to.be.true; + chai.expect(getWithLineage.calledOnceWithExactly(Qualifier.byUuid('person'))).to.be.true; }); it('rejects contacts with wrong type', done => { @@ -439,13 +447,13 @@ describe('places controller', () => { }); - it('supports parents defined as uuids.', () => { + it('supports parents defined as uuids.', async () => { const place = { name: 'CHP Area One', type: 'health_center', parent: 'ad06d137' }; - fetchHydratedDoc.resolves({ + getWithLineage.resolves({ _id: 'ad06d137', name: 'CHP Branch One', type: 'district_hospital' @@ -459,9 +467,12 @@ describe('places controller', () => { chai.expect(doc.parent.type).to.equal(undefined); // minified return Promise.resolve({ id: 'abc123' }); }); - return controller._createPlaces(place).then(actual => { - chai.expect(actual).to.deep.equal({ id: 'abc123' }); - }); + + const actual = await controller._createPlaces(place); + + chai.expect(actual).to.deep.equal({ id: 'abc123' }); + chai.expect(dataContext.bind.calledOnceWithExactly(Place.v1.getWithLineage)).to.be.true; + chai.expect(getWithLineage.calledOnceWithExactly(Qualifier.byUuid('ad06d137'))).to.be.true; }); it('rejects invalid reported_date.', done => { @@ -578,6 +589,67 @@ describe('places controller', () => { }); + describe('placesExist', () => { + it('should throw error on invalid input', async () => { + await chai.expect(controller.placesExist()).to.be.eventually.rejectedWith('Invalid place ids list'); + await chai.expect(controller.placesExist({})).to.be.eventually.rejectedWith('Invalid place ids list'); + await chai.expect(controller.placesExist('a')).to.be.eventually.rejectedWith('Invalid place ids list'); + }); + + it('should throw an error if a place has an error', async () => { + db.medic.allDocs.resolves({ + rows: [ + { id: '1', error: 'not found' }, + { id: '2', doc: { _id: '2' } }, + ] + }); + + await chai.expect(controller.placesExist(['1', '2'])).to.be.eventually.rejectedWith(`Failed to find place 1`); + chai.expect(db.medic.allDocs.args).to.deep.equal([[{ keys: ['1', '2'], include_docs: true }]]); + }); + + it('should throw an error if a place is not found', async () => { + sinon.stub(contactTypesUtils, 'isPlace').returns(true); + db.medic.allDocs.resolves({ + rows: [ + { id: '2', doc: { _id: '2' } }, + { id: '1' }, + ] + }); + + await chai.expect(controller.placesExist(['1', '2'])).to.be.eventually.rejectedWith(`Failed to find place 1`); + }); + + it('should throw an error if any result is not a place', async () => { + sinon.stub(contactTypesUtils, 'isPlace').returns(false); + db.medic.allDocs.resolves({ + rows: [ + { id: '2', doc: { _id: '2' } }, + { id: '1', doc: { _id: '1' } }, + ] + }); + + await chai.expect(controller.placesExist(['1', '2'])).to.be.eventually.rejectedWith(`Failed to find place 2`); + chai.expect(contactTypesUtils.isPlace.args[0][1]).to.deep.equal({ _id: '2' }); + }); + + it('should succeed if all places are found', async () => { + sinon.stub(contactTypesUtils, 'isPlace').returns(true); + db.medic.allDocs.resolves({ + rows: [ + { id: '2', doc: { _id: '2' } }, + { id: '1', doc: { _id: '1' } }, + { id: '3', doc: { _id: '3' } }, + ] + }); + + chai.expect(await controller.placesExist(['1', '2', '3'])).to.equal(true); + chai.expect(contactTypesUtils.isPlace.args[0][1]).to.deep.equal({ _id: '2' }); + chai.expect(contactTypesUtils.isPlace.args[1][1]).to.deep.equal({ _id: '1' }); + chai.expect(contactTypesUtils.isPlace.args[2][1]).to.deep.equal({ _id: '3' }); + }); + }); + describe('preparePlaceContact', () => { it('adds default person type', () => { return controller._preparePlaceContact({ name: 'test' }).then(({ exists, contact }) => { @@ -588,13 +660,12 @@ describe('places controller', () => { }); it('rejects if contact does not exist', async () => { - fetchHydratedDoc.rejects({ status: 404 }); - try { - await controller._preparePlaceContact('test'); - chai.expect.fail('Call should throw'); - } catch (err) { - chai.expect(err.message).to.equal('Failed to find person.'); - } + getWithLineage.resolves(null); + + await chai.expect(controller._preparePlaceContact('test')).to.be.rejectedWith('Failed to find person.'); + + chai.expect(dataContext.bind.calledOnce).to.be.true; + chai.expect(getWithLineage.calledOnceWithExactly(Qualifier.byUuid('test'))).to.be.true; }); }); diff --git a/shared-libs/logger/src/index.d.ts b/shared-libs/logger/src/index.d.ts new file mode 100644 index 00000000000..249da4ffe5a --- /dev/null +++ b/shared-libs/logger/src/index.d.ts @@ -0,0 +1 @@ +export * as default from 'winston'; diff --git a/shared-libs/transitions/package.json b/shared-libs/transitions/package.json index 2a6d95090ea..e069826b390 100644 --- a/shared-libs/transitions/package.json +++ b/shared-libs/transitions/package.json @@ -11,6 +11,7 @@ "jsverify": "^0.8.4" }, "dependencies": { + "@medic/cht-datasource": "file:../cht-datasource", "@medic/contact-types-utils": "file:../contact-types-utils", "@medic/contacts": "file:../contacts", "@medic/couch-request": "file:../couch-request", diff --git a/shared-libs/transitions/src/data-context.js b/shared-libs/transitions/src/data-context.js new file mode 100644 index 00000000000..0a22a184aea --- /dev/null +++ b/shared-libs/transitions/src/data-context.js @@ -0,0 +1,5 @@ +module.exports = { + init: function(dataContext) { + Object.assign(module.exports, dataContext, { init: this.init }); + }, +}; diff --git a/shared-libs/transitions/src/index.js b/shared-libs/transitions/src/index.js index edb7d20d9e4..4db728bf405 100644 --- a/shared-libs/transitions/src/index.js +++ b/shared-libs/transitions/src/index.js @@ -1,16 +1,18 @@ const config = require('./config'); const db = require('./db'); +const dataContext = require('./data-context'); const infodoc = require('@medic/infodoc'); let inited = false; -module.exports = (sourceDb, sourceConfig) => { +module.exports = (sourceDb, sourceConfig, sourceDataContext) => { if (!inited) { db.init(sourceDb); infodoc.initLib(db.medic, db.sentinel); inited = true; } config.init(sourceConfig); + dataContext.init(sourceDataContext); const transitions = require('./transitions'); const utils = require('./lib/utils'); diff --git a/shared-libs/transitions/src/transitions/create_user_for_contacts.js b/shared-libs/transitions/src/transitions/create_user_for_contacts.js index 90aaf25a610..5bc7a7202d6 100644 --- a/shared-libs/transitions/src/transitions/create_user_for_contacts.js +++ b/shared-libs/transitions/src/transitions/create_user_for_contacts.js @@ -1,8 +1,10 @@ const config = require('../config'); const db = require('../db'); +const dataContext = require('../data-context'); +const { Person, Qualifier } = require('@medic/cht-datasource'); const contactTypeUtils = require('@medic/contact-types-utils'); -const { people } = require('@medic/contacts')(config, db); -const { users } = require('@medic/user-management')(config, db); +const { people } = require('@medic/contacts')(config, db, dataContext); +const { users } = require('@medic/user-management')(config, db, dataContext); const NAME = 'create_user_for_contacts'; @@ -97,7 +99,8 @@ const createNewUser = async ({ roles }, contact) => { try { await users.createUser(user, config.get('app_url')); // Pick up any updates made to the contact - Object.assign(contact, await db.medic.get(_id)); + const getPerson = dataContext.bind(Person.v1.get); + Object.assign(contact, await getPerson(Qualifier.byUuid(_id))); } catch (err) { if (!err.message || typeof err.message === 'string') { throw err; diff --git a/shared-libs/transitions/src/transitions/death_reporting.js b/shared-libs/transitions/src/transitions/death_reporting.js index ea3a0208c7c..cc997b6570a 100644 --- a/shared-libs/transitions/src/transitions/death_reporting.js +++ b/shared-libs/transitions/src/transitions/death_reporting.js @@ -4,6 +4,9 @@ const utils = require('../lib/utils'); const objectPath = require('object-path'); const transitionUtils = require('./utils'); const db = require('../db'); +const dataContext = require('../data-context'); +const { Person, Qualifier } = require('@medic/cht-datasource'); + const NAME = 'death_reporting'; const CONFIG_NAME = 'death_reporting'; const MARK_PROPERTY_NAME = 'mark_deceased_forms'; @@ -61,9 +64,15 @@ module.exports = { return Promise.resolve(false); } - return db.medic - .get(hydratedPatient._id) - .then(patient => updatePatient(patient, change.doc)) + const getPerson = dataContext.bind(Person.v1.get); + return getPerson(Qualifier.byUuid(hydratedPatient._id)) + .then(patient => { + if (!patient) { + return Promise.reject(`Patient not found: ${hydratedPatient._id}`); + } + + return updatePatient(patient, change.doc); + }) .then(changed => !!changed); }, }; diff --git a/shared-libs/transitions/src/transitions/registration.js b/shared-libs/transitions/src/transitions/registration.js index 7a9613b9373..220a6dd270a 100644 --- a/shared-libs/transitions/src/transitions/registration.js +++ b/shared-libs/transitions/src/transitions/registration.js @@ -3,6 +3,8 @@ const utils = require('../lib/utils'); const transitionUtils = require('./utils'); const logger = require('@medic/logger'); const db = require('../db'); +const dataContext = require('../data-context'); +const { Place, Qualifier } = require('@medic/cht-datasource'); const lineage = require('@medic/lineage')(Promise, db.medic); const messages = require('../lib/messages'); const schedules = require('../lib/schedules'); @@ -421,7 +423,8 @@ const getParentByPhone = options => { } options.doc.contact = contact; - return contact.parent && db.medic.get(contact.parent._id); + const getPlace = dataContext.bind(Place.v1.get); + return contact.parent && getPlace(Qualifier.byUuid(contact.parent._id)); }); }; diff --git a/shared-libs/transitions/src/transitions/update_clinics.js b/shared-libs/transitions/src/transitions/update_clinics.js index dfcc21e6025..77cd60b2e84 100644 --- a/shared-libs/transitions/src/transitions/update_clinics.js +++ b/shared-libs/transitions/src/transitions/update_clinics.js @@ -1,8 +1,10 @@ const transitionUtils = require('./utils'); const db = require('../db'); +const dataContext = require('../data-context'); const lineage = require('@medic/lineage')(Promise, db.medic); const utils = require('../lib/utils'); const contactTypesUtils = require('@medic/contact-types-utils'); +const { Person, Qualifier } = require('@medic/cht-datasource'); const NAME = 'update_clinics'; const FACILITY_NOT_FOUND = 'sys.facility_not_found'; @@ -32,9 +34,11 @@ const getContactByRefid = doc => { if (!contactType) { return; } + + const getPersonWithLineage = dataContext.bind(Person.v1.getWithLineage); // person if (contactType.person) { - return lineage.fetchHydratedDoc(result._id); + return getPersonWithLineage(Qualifier.byUuid(result._id)); } // place @@ -43,7 +47,7 @@ const getContactByRefid = doc => { return result.contact || { parent: result }; } - return lineage.fetchHydratedDoc(id); + return getPersonWithLineage(Qualifier.byUuid(id)); }); }; diff --git a/shared-libs/transitions/test/unit/transitions/create_user_for_contacts.js b/shared-libs/transitions/test/unit/transitions/create_user_for_contacts.js index 1fb213176de..230831a88db 100644 --- a/shared-libs/transitions/test/unit/transitions/create_user_for_contacts.js +++ b/shared-libs/transitions/test/unit/transitions/create_user_for_contacts.js @@ -3,8 +3,10 @@ const { expect } = require('chai'); const rewire = require('rewire'); const config = require('../../../src/config'); const db = require('../../../src/db'); -const { people } = require('@medic/contacts')(config, db); -const { users } = require('@medic/user-management')(config, db); +const dataContext = require('../../../src/data-context'); +const { Person, Qualifier } = require('@medic/cht-datasource'); +const { people } = require('@medic/contacts')(config, db, dataContext); +const { users } = require('@medic/user-management')(config, db, dataContext); const contactTypeUtils = require('@medic/contact-types-utils'); const deepFreeze = obj => { @@ -68,6 +70,7 @@ describe('create_user_for_contacts', () => { getAll: sinon.stub().returns({}), get: sinon.stub(), }); + dataContext.init({ bind: sinon.stub() }); transition = rewire('../../../src/transitions/create_user_for_contacts'); }); @@ -243,7 +246,7 @@ describe('create_user_for_contacts', () => { let createUser; let resetPassword; let validateNewUsername; - let medicGet; + let getPerson; beforeEach(() => { config.get @@ -268,8 +271,8 @@ describe('create_user_for_contacts', () => { validateNewUsername = sinon .stub(users, 'validateNewUsername') .resolves(); - medicGet = sinon - .stub(db.medic, 'get'); + getPerson = sinon.stub(); + dataContext.bind.returns(getPerson); }); const expectInitialDataRetrieved = (users) => { @@ -314,28 +317,28 @@ describe('create_user_for_contacts', () => { it(`creates user for new contact with create flag of 'true' and multiple roles`, async () => { const doc = getCreatedContact({ roles: ['nurse', 'chw'], role: null }); - medicGet.resolves({ ...doc, user_for_contact: {} }); + getPerson.resolves({ ...doc, user_for_contact: {} }); const result = await transition.onMatch({ doc, initialProcessing: true }); expect(result).to.be.true; expectUsersCreated([{ contact: doc, user: doc }]); expect(doc.user_for_contact.create).to.not.exist; - expect(medicGet.callCount).to.equal(1); - expect(medicGet.args[0]).to.deep.equal([doc._id]); + expect(dataContext.bind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(doc._id))).to.be.true; }); it(`creates user for new contact with create flag of 'true' and single role`, async () => { const doc = getCreatedContact({ role: 'chw' }); - medicGet.resolves({ ...doc, user_for_contact: {} }); + getPerson.resolves({ ...doc, user_for_contact: {} }); const result = await transition.onMatch({ doc, initialProcessing: true }); expect(result).to.be.true; expectUsersCreated([{ contact: doc, user: { roles: [doc.role] } }]); expect(doc.user_for_contact.create).to.not.exist; - expect(medicGet.callCount).to.equal(1); - expect(medicGet.args[0]).to.deep.equal([doc._id]); + expect(dataContext.bind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(doc._id))).to.be.true; }); it('records error when creating user when an error is thrown generating a new username', async () => { @@ -416,8 +419,8 @@ describe('create_user_for_contacts', () => { expectUsersCreated([{ contact: NEW_CONTACT, user: ORIGINAL_USER }]); expectUserPasswordReset([ORIGINAL_USER]); expect(doc.user_for_contact.replace[ORIGINAL_USER.name].status).to.equal('COMPLETE'); - expect(medicGet.callCount).to.equal(1); - expect(medicGet.args[0]).to.deep.equal([NEW_CONTACT._id]); + expect(dataContext.bind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(NEW_CONTACT._id))).to.be.true; }); [ @@ -736,8 +739,8 @@ describe('create_user_for_contacts', () => { role: 'chw', phone: '+1234567890', }; - medicGet.withArgs(doc._id).resolves({ ...doc, user_for_contact: { ...doc.user_for_contact } }); - medicGet.withArgs(NEW_CONTACT._id).resolves(NEW_CONTACT); + getPerson.withArgs(doc._id).resolves({ ...doc, user_for_contact: { ...doc.user_for_contact } }); + getPerson.withArgs(NEW_CONTACT._id).resolves(NEW_CONTACT); doc.user_for_contact.create = 'true'; const result = await transition.onMatch({ doc, initialProcessing: true }); @@ -748,8 +751,8 @@ describe('create_user_for_contacts', () => { expectUserPasswordReset([ORIGINAL_USER]); expect(doc.user_for_contact.replace[ORIGINAL_USER.name].status).to.equal('COMPLETE'); expect(doc.user_for_contact.create).to.not.exist; - expect(medicGet.callCount).to.equal(1); - expect(medicGet.args).to.deep.equal([[NEW_CONTACT._id]]); + expect(dataContext.bind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(NEW_CONTACT._id))).to.be.true; }); }); }); diff --git a/shared-libs/transitions/test/unit/transitions/death_reporting.js b/shared-libs/transitions/test/unit/transitions/death_reporting.js index 324bec2eac2..4c46c42567f 100644 --- a/shared-libs/transitions/test/unit/transitions/death_reporting.js +++ b/shared-libs/transitions/test/unit/transitions/death_reporting.js @@ -1,8 +1,11 @@ require('chai').should(); +const { expect } = require('chai'); const sinon = require('sinon'); const db = require('../../../src/db'); const utils = require('../../../src/lib/utils'); const config = require('../../../src/config'); +const dataContext = require('../../../src/data-context'); +const { Person, Qualifier } = require('@medic/cht-datasource'); describe('death_reporting', () => { let transition; @@ -12,6 +15,7 @@ describe('death_reporting', () => { getAll: sinon.stub().returns({}), get: sinon.stub(), }); + dataContext.init({ bind: sinon.stub() }); transition = require('../../../src/transitions/death_reporting'); }); @@ -22,7 +26,14 @@ describe('death_reporting', () => { }); describe('onMatch', () => { - it('marks a patient deceased with uuid', () => { + let getPerson; + + beforeEach(() => { + getPerson = sinon.stub(); + dataContext.bind.returns(getPerson); + }); + + it('marks a patient deceased with uuid', async () => { const patientId = 'some-uuid'; const dateOfDeath = 15612321; const patient = { _id: patientId, name: 'greg' }; @@ -41,21 +52,22 @@ describe('death_reporting', () => { }); const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); - const get = sinon.stub(db.medic, 'get').withArgs(patient._id).resolves(patient); - return transition.onMatch(change).then(changed => { - changed.should.equal(true); - get.callCount.should.equal(1); - get.args[0].should.deep.equal([patientId]); - saveDoc.callCount.should.equal(1); - saveDoc.args[0].should.deep.equal([{ - _id: patientId, - name: 'greg', - date_of_death: dateOfDeath, - }]); - }); + getPerson.resolves(patient); + + const changed = await transition.onMatch(change); + + changed.should.equal(true); + dataContext.bind.calledOnceWithExactly(Person.v1.get).should.be.true; + getPerson.calledOnceWithExactly(Qualifier.byUuid(patientId)).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0].should.deep.equal([{ + _id: patientId, + name: 'greg', + date_of_death: dateOfDeath, + }]); }); - it('marks a patient deceased with shortcode', () => { + it('marks a patient deceased with shortcode', async () => { const patientId = '00001'; const dateOfDeath = 15612321; const patient = { name: 'greg', _id: 'greg_uuid', patient_id: patientId }; @@ -73,22 +85,23 @@ describe('death_reporting', () => { date_field: 'death.date', }); const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); - sinon.stub(db.medic, 'get').withArgs(patient._id).resolves(patient); - return transition.onMatch(change).then(changed => { - changed.should.equal(true); - db.medic.get.callCount.should.equal(1); - db.medic.get.args[0].should.deep.equal([patient._id]); - saveDoc.callCount.should.equal(1); - saveDoc.args[0].should.deep.equal([{ - name: 'greg', - _id: 'greg_uuid', - patient_id: patientId, - date_of_death: dateOfDeath, - }]); - }); + getPerson.resolves(patient); + + const changed = await transition.onMatch(change); + + changed.should.equal(true); + dataContext.bind.calledOnceWithExactly(Person.v1.get).should.be.true; + getPerson.calledOnceWithExactly(Qualifier.byUuid(patient._id)).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0].should.deep.equal([{ + name: 'greg', + _id: 'greg_uuid', + patient_id: patientId, + date_of_death: dateOfDeath, + }]); }); - it('does not require patient_id', () => { + it('does not require patient_id', async () => { const patientId = '00001'; const dateOfDeath = 15612321; const patient = { name: 'greg', _id: 'greg_uuid', patient_id: patientId }; @@ -106,22 +119,23 @@ describe('death_reporting', () => { date_field: 'death.date', }); const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); - sinon.stub(db.medic, 'get').withArgs(patient._id).resolves(patient); - return transition.onMatch(change).then(changed => { - changed.should.equal(true); - db.medic.get.callCount.should.equal(1); - db.medic.get.args[0].should.deep.equal([patient._id]); - saveDoc.callCount.should.equal(1); - saveDoc.args[0].should.deep.equal([{ - name: 'greg', - _id: 'greg_uuid', - patient_id: patientId, - date_of_death: dateOfDeath, - }]); - }); + getPerson.resolves(patient); + + const changed = await transition.onMatch(change); + + changed.should.equal(true); + dataContext.bind.calledOnceWithExactly(Person.v1.get).should.be.true; + getPerson.calledOnceWithExactly(Qualifier.byUuid(patient._id)).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0].should.deep.equal([{ + name: 'greg', + _id: 'greg_uuid', + patient_id: patientId, + date_of_death: dateOfDeath, + }]); }); - it('uses the configured field for the date', () => { + it('uses the configured field for the date', async () => { const patientId = 'some-uuid'; const dateOfDeath = 1529285369317; const patient = { name: 'greg', _id: patientId }; @@ -142,19 +156,22 @@ describe('death_reporting', () => { date_field: 'fields.death.date', }); const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); - sinon.stub(db.medic, 'get').withArgs(patient._id).resolves(patient); - return transition.onMatch(change).then(changed => { - changed.should.equal(true); - saveDoc.callCount.should.equal(1); - saveDoc.args[0].should.deep.equal([{ - name: 'greg', - _id: patientId, - date_of_death: dateOfDeath, - }]); - }); + getPerson.resolves(patient); + + const changed = await transition.onMatch(change); + + changed.should.equal(true); + dataContext.bind.calledOnceWithExactly(Person.v1.get).should.be.true; + getPerson.calledOnceWithExactly(Qualifier.byUuid(patientId)).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0].should.deep.equal([{ + name: 'greg', + _id: patientId, + date_of_death: dateOfDeath, + }]); }); - it('unmarks a patient deceased', () => { + it('unmarks a patient deceased', async () => { const patientId = '00001'; const patient = { name: 'greg', date_of_death: 13151549848, _id: patientId }; const change = { @@ -169,15 +186,18 @@ describe('death_reporting', () => { undo_deceased_forms: ['death-undo'], }); const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); - sinon.stub(db.medic, 'get').withArgs(patient._id).resolves(patient); - return transition.onMatch(change).then(changed => { - changed.should.equal(true); - saveDoc.callCount.should.equal(1); - saveDoc.args[0].should.deep.equal([{ name: 'greg', _id: patientId }]); - }); + getPerson.resolves(patient); + + const changed = await transition.onMatch(change); + + changed.should.equal(true); + dataContext.bind.calledOnceWithExactly(Person.v1.get).should.be.true; + getPerson.calledOnceWithExactly(Qualifier.byUuid(patientId)).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0].should.deep.equal([{ name: 'greg', _id: patientId }]); }); - it('does nothing if patient in correct state', () => { + it('does nothing if patient in correct state', async () => { const patientId = '00001'; const patient = { name: 'greg', date_of_death: 13151549848, _id: patientId }; const change = { @@ -192,11 +212,38 @@ describe('death_reporting', () => { undo_deceased_forms: ['death-undo'], }); const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); - sinon.stub(db.medic, 'get').withArgs(patient._id).resolves(patient); - return transition.onMatch(change).then(changed => { - changed.should.equal(false); - saveDoc.callCount.should.equal(0); + getPerson.resolves(patient); + + const changed = await transition.onMatch(change); + + changed.should.equal(false); + dataContext.bind.calledOnceWithExactly(Person.v1.get).should.be.true; + getPerson.calledOnceWithExactly(Qualifier.byUuid(patientId)).should.be.true; + saveDoc.callCount.should.equal(0); + }); + + it('records an error when the patient cannot be found', async () => { + const patientId = '00001'; + const patient = { name: 'greg', date_of_death: 13151549848, _id: patientId }; + const change = { + doc: { + form: 'death-confirm', + fields: { patient_uuid: patientId }, + patient: patient + }, + }; + config.get.returns({ + mark_deceased_forms: ['death-confirm'], + undo_deceased_forms: ['death-undo'], }); + const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); + getPerson.resolves(null); + + await expect(transition.onMatch(change)).to.be.rejectedWith('Patient not found: 00001'); + + dataContext.bind.calledOnceWithExactly(Person.v1.get).should.be.true; + getPerson.calledOnceWithExactly(Qualifier.byUuid(patientId)).should.be.true; + saveDoc.callCount.should.equal(0); }); it('should do nothing if patient somehow is not hydrated or something', () => { diff --git a/shared-libs/transitions/test/unit/transitions/registration.js b/shared-libs/transitions/test/unit/transitions/registration.js index 56044abdbfd..a0687a439ad 100644 --- a/shared-libs/transitions/test/unit/transitions/registration.js +++ b/shared-libs/transitions/test/unit/transitions/registration.js @@ -2,10 +2,12 @@ require('chai').should(); const sinon = require('sinon'); const rewire = require('rewire'); const db = require('../../../src/db'); +const dataContext = require('../../../src/data-context'); const messages = require('../../../src/lib/messages'); const utils = require('../../../src/lib/utils'); const config = require('../../../src/config'); const validation = require('@medic/validation'); +const { Place, Qualifier } = require('@medic/cht-datasource'); const contactTypeUtils = require('@medic/contact-types-utils'); const phoneNumberParser = require('@medic/phone-number'); @@ -14,6 +16,7 @@ let transitionUtils; let acceptPatientReports; let transition; let settings; +let getPlace; describe('registration', () => { beforeEach(() => { @@ -26,6 +29,12 @@ describe('registration', () => { .stub() .returns({}) }); + getPlace = sinon.stub(); + dataContext.init({ + bind: sinon + .stub() + .returns(getPlace) + }); schedules = require('../../../src/lib/schedules'); transitionUtils = require('../../../src/transitions/utils'); @@ -134,7 +143,7 @@ describe('registration', () => { }); describe('addPatient', () => { - it('trigger creates a new patient', () => { + it('trigger creates a new patient', async () => { const patientName = 'jack'; const submitterId = 'abc'; const parentId = 'papa'; @@ -165,7 +174,7 @@ describe('registration', () => { }, ], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); + getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { form: 'R', @@ -177,25 +186,27 @@ describe('registration', () => { sinon.stub(transitionUtils, 'getUniqueId').resolves(patientId); config.getAll.returns(settings); - return transition.onMatch(change).then(() => { - getContactUuid.callCount.should.equal(1); - view.callCount.should.equal(1); - view.args[0][0].should.equal('medic-client/contacts_by_phone'); - view.args[0][1].key.should.equal(senderPhoneNumber); - view.args[0][1].include_docs.should.equal(true); - saveDoc.callCount.should.equal(1); - saveDoc.args[0][0].name.should.equal(patientName); - saveDoc.args[0][0].parent._id.should.equal(parentId); - saveDoc.args[0][0].reported_date.should.equal(53); - saveDoc.args[0][0].type.should.equal('person'); - saveDoc.args[0][0].patient_id.should.equal(patientId); - saveDoc.args[0][0].date_of_birth.should.equal(dob); - saveDoc.args[0][0].source_id.should.equal(reportId); - saveDoc.args[0][0].created_by.should.equal(submitterId); - }); - }); - - it('should only create a new patient with phone if form has phone field and phone is valid', () => { + await transition.onMatch(change); + + getContactUuid.callCount.should.equal(1); + view.callCount.should.equal(1); + view.args[0][0].should.equal('medic-client/contacts_by_phone'); + view.args[0][1].key.should.equal(senderPhoneNumber); + view.args[0][1].include_docs.should.equal(true); + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid(parentId)).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0][0].name.should.equal(patientName); + saveDoc.args[0][0].parent._id.should.equal(parentId); + saveDoc.args[0][0].reported_date.should.equal(53); + saveDoc.args[0][0].type.should.equal('person'); + saveDoc.args[0][0].patient_id.should.equal(patientId); + saveDoc.args[0][0].date_of_birth.should.equal(dob); + saveDoc.args[0][0].source_id.should.equal(reportId); + saveDoc.args[0][0].created_by.should.equal(submitterId); + }); + + it('should only create a new patient with phone if form has phone field and phone is valid', async () => { // Form with phone field const formDef = { fields: { @@ -240,7 +251,7 @@ describe('registration', () => { }, ], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); + getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -254,26 +265,28 @@ describe('registration', () => { sinon.stub(transitionUtils, 'getUniqueId').resolves(patientId); config.getAll.returns(settings); - return transition.onMatch(change).then(() => { - getContactUuid.callCount.should.equal(1); - view.callCount.should.equal(1); - view.args[0][0].should.equal('medic-client/contacts_by_phone'); - view.args[0][1].key.should.equal(senderPhoneNumber); - view.args[0][1].include_docs.should.equal(true); - saveDoc.callCount.should.equal(1); - saveDoc.args[0][0].name.should.equal(patientName); - saveDoc.args[0][0].phone.should.equal(patientPhoneNumber); - saveDoc.args[0][0].parent._id.should.equal(parentId); - saveDoc.args[0][0].reported_date.should.equal(53); - saveDoc.args[0][0].type.should.equal('person'); - saveDoc.args[0][0].patient_id.should.equal(patientId); - saveDoc.args[0][0].date_of_birth.should.equal(dob); - saveDoc.args[0][0].source_id.should.equal(reportId); - saveDoc.args[0][0].created_by.should.equal(submitterId); - }); - }); - - it('should not create patient if form has phone field and phone is invalid', () => { + await transition.onMatch(change); + + getContactUuid.callCount.should.equal(1); + view.callCount.should.equal(1); + view.args[0][0].should.equal('medic-client/contacts_by_phone'); + view.args[0][1].key.should.equal(senderPhoneNumber); + view.args[0][1].include_docs.should.equal(true); + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid(parentId)).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0][0].name.should.equal(patientName); + saveDoc.args[0][0].phone.should.equal(patientPhoneNumber); + saveDoc.args[0][0].parent._id.should.equal(parentId); + saveDoc.args[0][0].reported_date.should.equal(53); + saveDoc.args[0][0].type.should.equal('person'); + saveDoc.args[0][0].patient_id.should.equal(patientId); + saveDoc.args[0][0].date_of_birth.should.equal(dob); + saveDoc.args[0][0].source_id.should.equal(reportId); + saveDoc.args[0][0].created_by.should.equal(submitterId); + }); + + it('should not create patient if form has phone field and phone is invalid', async () => { // Form with phone field const formDef = { fields: { @@ -319,7 +332,7 @@ describe('registration', () => { }, ], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); + getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -334,12 +347,14 @@ describe('registration', () => { sinon.stub(transitionUtils, 'getUniqueId').resolves(patientId); config.getAll.returns(settings); - return transition.onMatch(change).then(() => { - saveDoc.callCount.should.equal(0); - }); + await transition.onMatch(change); + + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid(parentId)).should.be.true; + saveDoc.callCount.should.equal(0); }); - it('should not add patient phone if form does not have phone field', () => { + it('should not add patient phone if form does not have phone field', async () => { // Form without phone field const formDef = { fields: { @@ -379,7 +394,7 @@ describe('registration', () => { }, ], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); + getPlace.resolves({ _id: parentId, type: 'contact', contact_type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { @@ -393,23 +408,25 @@ describe('registration', () => { sinon.stub(transitionUtils, 'getUniqueId').resolves(patientId); config.getAll.returns(settings); - return transition.onMatch(change).then(() => { - getContactUuid.callCount.should.equal(1); - view.callCount.should.equal(1); - view.args[0][0].should.equal('medic-client/contacts_by_phone'); - view.args[0][1].key.should.equal(senderPhoneNumber); - view.args[0][1].include_docs.should.equal(true); - saveDoc.callCount.should.equal(1); - saveDoc.args[0][0].name.should.equal(patientName); - (typeof saveDoc.args[0][0].phone).should.be.equal('undefined'); - saveDoc.args[0][0].parent._id.should.equal(parentId); - saveDoc.args[0][0].reported_date.should.equal(53); - saveDoc.args[0][0].type.should.equal('person'); - saveDoc.args[0][0].patient_id.should.equal(patientId); - saveDoc.args[0][0].date_of_birth.should.equal(dob); - saveDoc.args[0][0].source_id.should.equal(reportId); - saveDoc.args[0][0].created_by.should.equal(submitterId); - }); + await transition.onMatch(change); + + getContactUuid.callCount.should.equal(1); + view.callCount.should.equal(1); + view.args[0][0].should.equal('medic-client/contacts_by_phone'); + view.args[0][1].key.should.equal(senderPhoneNumber); + view.args[0][1].include_docs.should.equal(true); + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid(parentId)).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0][0].name.should.equal(patientName); + (typeof saveDoc.args[0][0].phone).should.be.equal('undefined'); + saveDoc.args[0][0].parent._id.should.equal(parentId); + saveDoc.args[0][0].reported_date.should.equal(53); + saveDoc.args[0][0].type.should.equal('person'); + saveDoc.args[0][0].patient_id.should.equal(patientId); + saveDoc.args[0][0].date_of_birth.should.equal(dob); + saveDoc.args[0][0].source_id.should.equal(reportId); + saveDoc.args[0][0].created_by.should.equal(submitterId); }); it('does nothing when patient already added', () => { @@ -442,7 +459,7 @@ describe('registration', () => { }); }); - it('uses a given id if configured to', () => { + it('uses a given id if configured to', async () => { const patientId = '05648'; const doc = { type: 'data_record', @@ -460,7 +477,7 @@ describe('registration', () => { .resolves({ rows: [{ doc: { parent: { _id: 'papa' } } }], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: 'papa', type: 'place' }); + getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(1); const eventConfig = { form: 'R', @@ -477,14 +494,16 @@ describe('registration', () => { sinon.stub(transitionUtils, 'isIdUnique').resolves(true); config.getAll.returns(settings); - return transition.onMatch(change).then(() => { - saveDoc.args[0][0].patient_id.should.equal(patientId); - doc.patient_id.should.equal(patientId); - (typeof doc.errors).should.equal('undefined'); - }); + await transition.onMatch(change); + + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; + saveDoc.args[0][0].patient_id.should.equal(patientId); + doc.patient_id.should.equal(patientId); + (typeof doc.errors).should.equal('undefined'); }); - it('trigger creates a new contact with the given type', () => { + it('trigger creates a new contact with the given type', async () => { const change = { doc: { _id: 'def', @@ -508,7 +527,7 @@ describe('registration', () => { }, ], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: 'papa', type: 'place' }); + getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { form: 'R', @@ -529,14 +548,16 @@ describe('registration', () => { ] }); - return transition.onMatch(change).then(() => { - saveDoc.callCount.should.equal(1); - saveDoc.args[0][0].type.should.equal('contact'); - saveDoc.args[0][0].contact_type.should.equal('patient'); - }); + await transition.onMatch(change); + + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0][0].type.should.equal('contact'); + saveDoc.args[0][0].contact_type.should.equal('patient'); }); - it('errors if the configuration does not point to an id', () => { + it('errors if the configuration does not point to an id', async () => { const patientId = '05648'; const doc = { type: 'data_record', @@ -554,7 +575,7 @@ describe('registration', () => { .resolves({ rows: [{ doc: { parent: { _id: 'papa' } } }], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: 'papa', type: 'place' }); + getPlace.resolves({ _id: 'papa', type: 'place' }); sinon.stub(db.medic, 'post').resolves(); const eventConfig = { form: 'R', @@ -573,18 +594,20 @@ describe('registration', () => { sinon.stub(validation, 'validate').resolves(); - return transition.onMatch(change).then(() => { - (typeof doc.patient_id).should.equal('undefined'); - doc.errors.should.deep.equal([ - { - message: 'messages.generic.no_provided_patient_id', - code: 'no_provided_patient_id', - }, - ]); - }); + await transition.onMatch(change); + + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; + (typeof doc.patient_id).should.equal('undefined'); + doc.errors.should.deep.equal([ + { + message: 'messages.generic.no_provided_patient_id', + code: 'no_provided_patient_id', + }, + ]); }); - it('errors if the given id is not unique', () => { + it('errors if the given id is not unique', async () => { const patientId = '05648'; const doc = { type: 'data_record', @@ -602,7 +625,7 @@ describe('registration', () => { .resolves({ rows: [{ doc: { parent: { _id: 'papa' } } }], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: 'papa', type: 'place' }); + getPlace.resolves({ _id: 'papa', type: 'place' }); sinon.stub(db.medic, 'post').resolves(); const eventConfig = { form: 'R', @@ -623,18 +646,20 @@ describe('registration', () => { sinon.stub(validation, 'validate').resolves(); config.getAll.returns(settings); - return transition.onMatch(change).then(() => { - (typeof doc.patient_id).should.be.equal('undefined'); - doc.errors.should.deep.equal([ - { - message: 'messages.generic.provided_patient_id_not_unique', - code: 'provided_patient_id_not_unique', - }, - ]); - }); + await transition.onMatch(change); + + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; + (typeof doc.patient_id).should.be.equal('undefined'); + doc.errors.should.deep.equal([ + { + message: 'messages.generic.provided_patient_id_not_unique', + code: 'provided_patient_id_not_unique', + }, + ]); }); - it('event parameter overwrites the default property for the name of the patient', () => { + it('event parameter overwrites the default property for the name of the patient', async () => { const patientName = 'jack'; const submitterId = 'papa'; const patientId = '05649'; @@ -657,7 +682,7 @@ describe('registration', () => { .resolves({ rows: [{ doc: { parent: { _id: submitterId } } }], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: 'papa', type: 'place' }); + getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { form: 'R', @@ -670,13 +695,15 @@ describe('registration', () => { sinon.stub(transitionUtils, 'getUniqueId').resolves(patientId); config.getAll.returns(settings); - return transition.onMatch(change).then(() => { - saveDoc.callCount.should.equal(1); - saveDoc.args[0][0].name.should.equal(patientName); - }); + await transition.onMatch(change); + + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0][0].name.should.equal(patientName); }); - it('event parameter overwrites the default property for the name of the patient using JSON config', () => { + it('event parameter overwrites the default property for the name of the patient using JSON config', async () => { const patientName = 'jack'; const submitterId = 'papa'; const patientId = '05649'; @@ -699,7 +726,7 @@ describe('registration', () => { .resolves({ rows: [{ doc: { parent: { _id: submitterId } } }], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: 'papa', type: 'place' }); + getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { form: 'R', @@ -718,13 +745,15 @@ describe('registration', () => { sinon.stub(transitionUtils, 'getUniqueId').resolves(patientId); config.getAll.returns(settings); - return transition.onMatch(change).then(() => { - saveDoc.callCount.should.equal(1); - saveDoc.args[0][0].name.should.equal(patientName); - }); + await transition.onMatch(change); + + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0][0].name.should.equal(patientName); }); - it('add_patient and add_patient_id triggers are idempotent', () => { + it('add_patient and add_patient_id triggers are idempotent', async () => { const patientName = 'jack'; const submitterId = 'papa'; const patientId = '05649'; @@ -747,7 +776,7 @@ describe('registration', () => { .resolves({ rows: [{ doc: { parent: { _id: submitterId } } }], }); - sinon.stub(db.medic, 'get').withArgs('papa').resolves({ _id: 'papa', type: 'place' }); + getPlace.resolves({ _id: 'papa', type: 'place' }); const saveDoc = sinon.stub(db.medic, 'post').resolves(); const eventConfig = { form: 'R', @@ -763,11 +792,12 @@ describe('registration', () => { sinon.stub(transitionUtils, 'getUniqueId').resolves(patientId); config.getAll.returns(settings); + await transition.onMatch(change); - return transition.onMatch(change).then(() => { - saveDoc.callCount.should.equal(1); - saveDoc.args[0][0].name.should.equal(patientName); - }); + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid('papa')).should.be.true; + saveDoc.callCount.should.equal(1); + saveDoc.args[0][0].name.should.equal(patientName); }); it('fails when patient_id_field is set to patient_id', () => { @@ -1409,7 +1439,7 @@ describe('registration', () => { }); }); - it('should default to submitter by phone parent when parent_id param not specified', () => { + it('should default to submitter by phone parent when parent_id param not specified', async () => { const change = { doc: { _id: 'reportID', @@ -1456,7 +1486,7 @@ describe('registration', () => { } ] }); - sinon.stub(db.medic, 'get').withArgs('west_hc').resolves({ + getPlace.resolves({ _id: 'west_hc', name: 'west hc', place_id: 'west_hc_place', @@ -1476,33 +1506,33 @@ describe('registration', () => { sinon.stub(utils, 'getRegistrations').resolves([]); sinon.stub(transitionUtils, 'getUniqueId').resolves(placeId); - return transition.onMatch(change).then(() => { - transitionUtils.getUniqueId.callCount.should.equal(1); - utils.getContactUuid.callCount.should.equal(1); - utils.getContactUuid.args[0].should.deep.equal([placeId]); - utils.getContact.callCount.should.equal(0); - db.medic.query.callCount.should.equal(1); - db.medic.query.args[0] - .should.deep.equal(['medic-client/contacts_by_phone', { key: '+111222', include_docs: true }]); - db.medic.get.callCount.should.equal(1); - db.medic.get.args[0].should.deep.equal(['west_hc']); - db.medic.post.callCount.should.equal(1); - db.medic.post.args[0].should.deep.equal([{ - name: 'new clinic', - place_id: placeId, - source_id: change.doc._id, - type: 'contact', - contact_type: 'clinic_1', - parent: { _id: 'west_hc' }, - created_by: 'supervisor', - reported_date: change.doc.reported_date, - }]); - (!!change.doc.errors).should.equal(false); - change.doc.tasks.length.should.equal(1); - change.doc.tasks[0].messages[0].should.include({ - to: change.doc.from, - message: 'Place new clinic with type clinic_1 was added to west hc(health_center_1)' - }); + await transition.onMatch(change); + + transitionUtils.getUniqueId.callCount.should.equal(1); + utils.getContactUuid.callCount.should.equal(1); + utils.getContactUuid.args[0].should.deep.equal([placeId]); + utils.getContact.callCount.should.equal(0); + db.medic.query.callCount.should.equal(1); + db.medic.query.args[0] + .should.deep.equal(['medic-client/contacts_by_phone', { key: '+111222', include_docs: true }]); + dataContext.bind.calledOnceWithExactly(Place.v1.get).should.be.true; + getPlace.calledOnceWithExactly(Qualifier.byUuid('west_hc')).should.be.true; + db.medic.post.callCount.should.equal(1); + db.medic.post.args[0].should.deep.equal([{ + name: 'new clinic', + place_id: placeId, + source_id: change.doc._id, + type: 'contact', + contact_type: 'clinic_1', + parent: { _id: 'west_hc' }, + created_by: 'supervisor', + reported_date: change.doc.reported_date, + }]); + (!!change.doc.errors).should.equal(false); + change.doc.tasks.length.should.equal(1); + change.doc.tasks[0].messages[0].should.include({ + to: change.doc.from, + message: 'Place new clinic with type clinic_1 was added to west hc(health_center_1)' }); }); @@ -1701,7 +1731,7 @@ describe('registration', () => { }); }); - it('should not create place when parent_id is not defined and no contact', () => { + it('should not create place when parent_id is not defined and no contact', async () => { const change = { doc: { _id: 'reportID', @@ -1732,33 +1762,32 @@ describe('registration', () => { }; config.get.withArgs('registrations').returns([eventConfig]); sinon.stub(db.medic, 'query').withArgs('medic-client/contacts_by_phone').resolves({ rows: [] }); - sinon.stub(db.medic, 'get'); sinon.stub(validation, 'validate').resolves(); sinon.stub(utils, 'getRegistrations').resolves([]); sinon.stub(transitionUtils, 'getUniqueId').resolves(placeId); - return transition.onMatch(change).then(() => { - transitionUtils.getUniqueId.callCount.should.equal(1); - utils.getContactUuid.callCount.should.equal(1); - utils.getContactUuid.args[0].should.deep.equal([placeId]); - utils.getContact.callCount.should.equal(0); - db.medic.post.callCount.should.equal(0); - db.medic.query.callCount.should.equal(1); - db.medic.query.args[0] - .should.deep.equal(['medic-client/contacts_by_phone', { key: '+111222', include_docs: true }]); - db.medic.get.callCount.should.equal(0); - - change.doc.errors.length.should.equal(1); - change.doc.errors[0].should.deep.equal({ - code: 'parent_not_found', - message: 'Cannot create clinic with name New Orleans: parent not found.', - }); - change.doc.tasks.length.should.equal(1); - change.doc.tasks[0].messages[0].should.include({ - to: change.doc.from, - message: 'Cannot create clinic with name New Orleans: parent not found.' - }); + await transition.onMatch(change); + transitionUtils.getUniqueId.callCount.should.equal(1); + utils.getContactUuid.callCount.should.equal(1); + utils.getContactUuid.args[0].should.deep.equal([placeId]); + utils.getContact.callCount.should.equal(0); + db.medic.post.callCount.should.equal(0); + db.medic.query.callCount.should.equal(1); + db.medic.query.args[0] + .should.deep.equal(['medic-client/contacts_by_phone', { key: '+111222', include_docs: true }]); + dataContext.bind.notCalled.should.be.true; + getPlace.notCalled.should.be.true; + + change.doc.errors.length.should.equal(1); + change.doc.errors[0].should.deep.equal({ + code: 'parent_not_found', + message: 'Cannot create clinic with name New Orleans: parent not found.', + }); + change.doc.tasks.length.should.equal(1); + change.doc.tasks[0].messages[0].should.include({ + to: change.doc.from, + message: 'Cannot create clinic with name New Orleans: parent not found.' }); }); diff --git a/shared-libs/transitions/test/unit/transitions/update_clinics.js b/shared-libs/transitions/test/unit/transitions/update_clinics.js index 65fd374efdc..efdcd983424 100644 --- a/shared-libs/transitions/test/unit/transitions/update_clinics.js +++ b/shared-libs/transitions/test/unit/transitions/update_clinics.js @@ -1,7 +1,9 @@ const sinon = require('sinon'); const assert = require('chai').assert; +const { Person, Qualifier } = require('@medic/cht-datasource'); const db = require('../../../src/db'); const config = require('../../../src/config'); +const dataContext = require('../../../src/data-context'); const utils = require('../../../src/lib/utils'); const phone = '+34567890123'; @@ -17,6 +19,7 @@ describe('update clinic', () => { }); transition = require('../../../src/transitions/update_clinics'); lineageStub = sinon.stub(transition._lineage, 'fetchHydratedDoc'); + dataContext.init({ bind: sinon.stub() }); }); afterEach(() => { @@ -169,7 +172,7 @@ describe('update clinic', () => { }); }); - it('should update clinic by refid and get latest contact', () => { + it('should update clinic by refid and get latest contact', async () => { const doc = { from: '+12345', refid: '1000', @@ -213,13 +216,19 @@ describe('update clinic', () => { }; config.getAll.returns({ contact_types: [ { id: 'clinic' } ] }); sinon.stub(db.medic, 'query').resolves({ rows: [{ doc: clinic }] }); - lineageStub.resolves(contact); - return transition.onMatch({ doc: doc }).then(changed => { - assert(changed); - assert(doc.contact); - assert.equal(doc.contact._rev, '2'); - assert.equal(doc.contact.name, 'zenith'); - }); + const getPersonWithLineage = sinon + .stub() + .resolves(contact); + dataContext.bind.returns(getPersonWithLineage); + + const changed = await transition.onMatch({ doc: doc }); + + assert(changed); + assert(doc.contact); + assert.equal(doc.contact._rev, '2'); + assert.equal(doc.contact.name, 'zenith'); + assert.isTrue(dataContext.bind.calledOnceWithExactly(Person.v1.getWithLineage)); + assert.isTrue(getPersonWithLineage.calledOnceWithExactly(Qualifier.byUuid('z'))); }); /* diff --git a/shared-libs/user-management/package.json b/shared-libs/user-management/package.json index 701c83d9400..64de994f792 100755 --- a/shared-libs/user-management/package.json +++ b/shared-libs/user-management/package.json @@ -8,6 +8,7 @@ "author": "", "license": "Apache-2.0", "dependencies": { + "@medic/cht-datasource": "file:../cht-datasource", "@medic/contacts": "file:../contacts", "@medic/lineage": "file:../lineage", "@medic/phone-number": "file:../phone-number", diff --git a/shared-libs/user-management/src/index.js b/shared-libs/user-management/src/index.js index f81af2e9f80..86eecbdf20f 100644 --- a/shared-libs/user-management/src/index.js +++ b/shared-libs/user-management/src/index.js @@ -1,14 +1,16 @@ const config = require('./libs/config'); const db = require('./libs/db'); +const dataContext = require('./libs/data-context'); const lineage = require('./libs/lineage'); const bulkUploadLog = require('./bulk-upload-log'); const roles = require('./roles'); const tokenLogin = require('./token-login'); const users = require('./users'); -module.exports = (sourceConfig, sourceDb) => { +module.exports = (sourceConfig, sourceDb, sourceDataContext) => { config.init(sourceConfig); db.init(sourceDb); + dataContext.init(sourceDataContext); lineage.init(require('@medic/lineage')(Promise, db.medic)); return { diff --git a/shared-libs/user-management/src/libs/data-context.js b/shared-libs/user-management/src/libs/data-context.js new file mode 100644 index 00000000000..0a22a184aea --- /dev/null +++ b/shared-libs/user-management/src/libs/data-context.js @@ -0,0 +1,5 @@ +module.exports = { + init: function(dataContext) { + Object.assign(module.exports, dataContext, { init: this.init }); + }, +}; diff --git a/shared-libs/user-management/src/libs/facility.js b/shared-libs/user-management/src/libs/facility.js index 29e4ab243c0..77d1fc7fea5 100644 --- a/shared-libs/user-management/src/libs/facility.js +++ b/shared-libs/user-management/src/libs/facility.js @@ -3,7 +3,8 @@ const db = require('./db'); const list = async (users) => { const ids = new Set(); for (const user of users) { - ids.add(user?.facility_id); + const facilityIds = Array.isArray(user?.facility_id) ? user.facility_id : [user.facility_id]; + facilityIds.forEach(facilityId => ids.add(facilityId)); ids.add(user?.contact_id); } ids.delete(undefined); diff --git a/shared-libs/user-management/src/roles.js b/shared-libs/user-management/src/roles.js index 5486f84d103..ad1be7c8c08 100644 --- a/shared-libs/user-management/src/roles.js +++ b/shared-libs/user-management/src/roles.js @@ -26,6 +26,14 @@ const hasOnlineRole = roles => { return roles.some(role => onlineRoles.includes(role)); }; +const hasPermission = (roles, permission) => { + const rolesWithPermission = config.get('permissions')[permission]; + if (!rolesWithPermission) { + return false; + } + return _.some(rolesWithPermission, role => _.includes(roles, role)); +}; + module.exports = { hasOnlineRole, isOnlineOnly: userCtx => { @@ -38,5 +46,21 @@ module.exports = { (!configuredRole || roles.some(role => configured[role] && configured[role].offline)); }, isDbAdmin, - ONLINE_ROLE + ONLINE_ROLE, + + hasAllPermissions: (roles, permissions) => { + if (module.exports.isDbAdmin({ roles })) { + return true; + } + + if (!permissions || !roles) { + return false; + } + + if (!_.isArray(permissions)) { + permissions = [ permissions ]; + } + + return _.every(permissions, _.partial(hasPermission, roles)); + } }; diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 6940707c304..91b5f996294 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -1,6 +1,7 @@ const _ = require('lodash'); const passwordTester = require('simple-password-tester'); const db = require('./libs/db'); +const dataContext = require('./libs/data-context'); const facility = require('./libs/facility'); const lineage = require('./libs/lineage'); const couchSettings = require('@medic/settings'); @@ -11,7 +12,8 @@ const config = require('./libs/config'); const moment = require('moment'); const bulkUploadLog = require('./bulk-upload-log'); const passwords = require('./libs/passwords'); -const { people, places } = require('@medic/contacts')(config, db); +const { Person, Place, Qualifier } = require('@medic/cht-datasource'); +const { people, places } = require('@medic/contacts')(config, db, dataContext); const USER_PREFIX = 'org.couchdb.user:'; @@ -58,8 +60,6 @@ const RESTRICTED_SETTINGS_EDITABLE_FIELDS = [ ]; const SETTINGS_EDITABLE_FIELDS = RESTRICTED_SETTINGS_EDITABLE_FIELDS.concat([ - 'place', - 'contact', 'external_id', 'type', 'roles', @@ -133,7 +133,10 @@ const getUsers = async (facilityId, contactId) => { return usersForContactId; } - return usersForContactId.filter(user => user.facility_id === facilityId); + return usersForContactId.filter(user => { + return user.facility_id === facilityId || + (Array.isArray(user.facility_id) && user.facility_id.includes(facilityId)); + }); }; const getUsersAndSettings = async ({ facilityId, contactId } = {}) => { @@ -208,13 +211,14 @@ const createUser = (data, response) => { }); }; -const hasUserCreateFlag = doc => doc.user_for_contact && doc.user_for_contact.create; +const hasUserCreateFlag = doc => doc?.user_for_contact?.create; const clearCreateUserForContact = async (doc) => { if (!hasUserCreateFlag(doc)) { return; } - const contact = await db.medic.get(doc._id); + const getPerson = dataContext.bind(Person.v1.get); + const contact = await getPerson(Qualifier.byUuid(doc._id)); if (hasUserCreateFlag(contact)) { delete contact.user_for_contact.create; const { rev } = await db.medic.put(contact); @@ -263,10 +267,12 @@ const storeUpdatedPlace = (data, preservePrimaryContact, retry = 0) => { data.place.contact = lineage.minifyLineage(data.contact); data.place.parent = lineage.minifyLineage(data.place.parent); - - return db.medic - .get(data.place._id) + const getPlace = dataContext.bind(Place.v1.get); + return getPlace(Qualifier.byUuid(data.place._id)) .then(place => { + if (!place) { + return Promise.reject(`Place not found [${data.place._id}].`); + } if (preservePrimaryContact) { return; } @@ -340,6 +346,8 @@ const hasParent = (facility, id) => { * docs somehow get out of sync this might cause confusion. */ const mapUser = (user, setting, facilities) => { + const facilityIds = Array.isArray(user.facility_id) ? user.facility_id : [user.facility_id]; + const places = facilityIds.filter(facilityId => facilityId).map(facility => getDoc(facility, facilities)); return { id: user._id, rev: user._rev, @@ -347,7 +355,7 @@ const mapUser = (user, setting, facilities) => { fullname: setting.fullname, email: setting.email, phone: setting.phone, - place: getDoc(user.facility_id, facilities), + place: places.length ? places : null, roles: user.roles, contact: getDoc(user.contact_id, facilities), external_id: setting.external_id, @@ -365,6 +373,68 @@ const mapUsers = (users, settings, facilities) => { }); }; +const getFacilityId = (data) => { + if (data.place) { + let facilities = Array.isArray(data.place) ? data.place.map(place => getDocID(place)) : [getDocID(data.place)]; + facilities = facilities.filter(Boolean); + return facilities.length ? facilities : null; + } + + if (_.isNull(data.place)) { + return null; + } +}; +const getContactId = (data) => { + if (data.contact) { + return getDocID(data.contact); + } + + if (_.isNull(data.contact)) { + return null; + } +}; + +const hydratePayload = (data) => { + if (data.type) { + // deprecated: use 'roles' instead + data.roles = getRoles(data.type); + } + + data.facility_id = getFacilityId(data); + data.contact_id = getContactId(data); +}; + +const getRolesUpdates = (data) => { + if (!data.roles) { + return; + } + const index = data.roles.indexOf(roles.ONLINE_ROLE); + if (roles.isOffline(data.roles)) { + if (index !== -1) { + // remove the online role + data.roles.splice(index, 1); + } + } else if (index === -1) { + // add the online role + data.roles.push(roles.ONLINE_ROLE); + } +}; + +const getCommonFieldsUpdates = (userDoc, data) => { + getRolesUpdates(data); + if (data.roles) { + userDoc.roles = data.roles; + } + + if (!_.isUndefined(data.place)) { + userDoc.facility_id = data.facility_id; + } + + if (!_.isUndefined(data.contact)) { + userDoc.contact_id = data.contact_id; + } +}; + const getSettingsUpdates = (username, data) => { const ignore = ['type', 'place', 'contact']; @@ -379,28 +449,7 @@ const getSettingsUpdates = (username, data) => { } }); - if (data.type) { - // deprecated: use 'roles' instead - settings.roles = getRoles(data.type); - } - if (settings.roles) { - const index = settings.roles.indexOf(roles.ONLINE_ROLE); - if (roles.isOffline(settings.roles)) { - if (index !== -1) { - // remove the online role - settings.roles.splice(index, 1); - } - } else if (index === -1) { - // add the online role - settings.roles.push(roles.ONLINE_ROLE); - } - } - if (data.place) { - settings.facility_id = getDocID(data.place); - } - if (data.contact) { - settings.contact_id = getDocID(data.contact); - } + getCommonFieldsUpdates(settings, data); return settings; }; @@ -419,19 +468,7 @@ const getUserUpdates = (username, data) => { } }); - if (data.type) { - // deprecated: use 'roles' instead - user.roles = getRoles(data.type); - } - if (user.roles && !roles.isOffline(user.roles)) { - user.roles.push(roles.ONLINE_ROLE); - } - if (data.place) { - user.facility_id = getDocID(data.place); - } - if (data.contact) { - user.contact_id = getDocID(data.contact); - } + getCommonFieldsUpdates(user, data); return user; }; @@ -471,6 +508,7 @@ const validatePassword = (password) => { } }; +const getDataRoles = (data) => data.roles || (data.type && getRoles(data.type)); const missingFields = data => { const required = ['username']; @@ -480,17 +518,30 @@ const missingFields = data => { required.push('password'); } - if (data.roles && roles.isOffline(data.roles)) { + const userRoles = getDataRoles(data); + if (!userRoles) { + required.push('type or roles'); + } else if (roles.isOffline(userRoles)) { required.push('place', 'contact'); } - const missing = required.filter(prop => !data[prop]); + const isInvalidProp = (prop) => { + if (!data[prop]) { + return true; + } - if (!data.type && !data.roles) { - missing.push('type or roles'); - } + if (Array.isArray(data[prop])) { + return data[prop].filter(value => value).length === 0; + } + + if (typeof data[prop] === 'object') { + return Object.values(data[prop]).filter(value => value).length === 0; + } - return missing; + return false; + }; + + return required.filter(prop => isInvalidProp(prop)); }; const getUpdatedUserDoc = async (username, data) => getUserDoc(username, 'users') @@ -538,40 +589,68 @@ const saveUserSettingsUpdates = async (userSettings) => { }; }; -const validateUserFacility = (data, user, userSettings) => { - if (data.place) { - userSettings.facility_id = user.facility_id; - return places.getPlace(user.facility_id); +const validateFacilityIsNeeded = (data, user) => { + const userRoles = data.roles || user?.roles; + if (userRoles && roles.isOffline(userRoles)) { + return Promise.reject(error400( + 'Place field is required for offline users', + 'field is required', + {'field': 'Place'} + )); } +}; - if (_.isNull(data.place)) { - if (userSettings.roles && roles.isOffline(userSettings.roles)) { - return Promise.reject(error400( - 'Place field is required for offline users', +const validateAllowedMultipleFacilities = (data, user) => { + if (!Array.isArray(data.place) || data.place.length === 1) { + return true; + } + + const userRoles = data.roles || user?.roles; + if (!userRoles || !roles.hasAllPermissions(userRoles, ['can_have_multiple_places'])) { + throw error400( + 'This user cannot have multiple places', + 'field is required', + {'field': 'Place'} + ); + } +}; + +const validateUserFacility = (data, user) => { + if (data.place) { + if (!data.facility_id) { + throw error400( + 'Invalid facilities list', 'field is required', {'field': 'Place'} - )); + ); } - user.facility_id = null; - userSettings.facility_id = null; + validateAllowedMultipleFacilities(data, user); + return places.placesExist(data.facility_id); + } + + if (_.isNull(data.place)) { + return validateFacilityIsNeeded(data, user); } }; -const validateUserContact = (data, user, userSettings) => { +const validateUserContact = (data, user) => { if (data.contact) { - return validateContact(user.contact_id, user.facility_id); + return Promise + .any(data.facility_id.map(facility_id => validateContact(data.contact_id, facility_id))) + .catch(errors => { + throw errors.errors[0]; + }); } if (_.isNull(data.contact)) { - if (userSettings.roles && roles.isOffline(userSettings.roles)) { + const userRoles = user?.roles || data.roles; + if (userRoles.roles && roles.isOffline(userRoles.roles)) { return Promise.reject(error400( 'Contact field is required for offline users', 'field is required', {'field': 'Contact'} )); } - user.contact_id = null; - userSettings.contact_id = null; } }; @@ -580,11 +659,15 @@ const validateUserContact = (data, user, userSettings) => { * @param {Object} data * @param {string} data.username Identifier used for authentication * @param {string[]} data.roles - * @param {(Object|string)=} data.place Place identifier string (UUID) or object this user resides in. Required if the roles contain an offline role. - * @param {(Object|string)=} data.contact A person identifier string (UUID) or object based on the form configured in the app. Required if the roles contain an offline role. - * @param {string=} data.password Password string used for authentication. Only allowed to be set, not retrieved. Required if token_login is not enabled for the user. + * @param {(Object|string)=} data.place Place identifier string (UUID) or object this user resides in. Required if the + * roles contain an offline role. + * @param {(Object|string)=} data.contact A person identifier string (UUID) or object based on the form configured in + * the app. Required if the roles contain an offline role. + * @param {string=} data.password Password string used for authentication. Only allowed to be set, not retrieved. + * Required if token_login is not enabled for the user. * @param {string=} data.phone Valid phone number. Required if token_login is enabled for the user. - * @param {Boolean=} data.token_login A boolean representing whether or not the Login by SMS should be enabled for this user. + * @param {Boolean=} data.token_login A boolean representing whether or not the Login by SMS should be enabled for this + * user. * @param {string=} data.fullname Full name * @param {string=} data.email Email address * @param {Boolean=} data.known Boolean to define if the user has logged in before. @@ -603,6 +686,7 @@ const createUserEntities = async (data, appUrl) => { await setContactParent(data); await createContact(data, response); await storeUpdatedPlace(data, preservePrimaryContact); + hydratePayload(data); await createUser(data, response); await createUserSettings(data, response); await tokenLogin.manageTokenLogin(data, appUrl, response); @@ -733,18 +817,17 @@ const createRecordBulkLog = (record, status, error, message) => { const hydrateUserSettings = (userSettings) => { return db.medic - .allDocs({ keys: [ userSettings.facility_id, userSettings.contact_id ], include_docs: true }) + .allDocs({ keys: [ userSettings.contact_id, ...userSettings.facility_id ], include_docs: true }) .then((response) => { - if (!Array.isArray(response.rows) || response.rows.length !== 2) { // malformed response + if (!response.rows || !Array.isArray(response.rows)) { return userSettings; } - - const [facilityRow, contactRow] = response.rows; - if (!facilityRow || !contactRow) { // malformed response + const [ contactRow, ...facilityRows ] = response.rows; + if (!facilityRows.length || !contactRow) { // malformed response return userSettings; } - userSettings.facility = facilityRow.doc; + userSettings.facility = facilityRows.map(row => row.doc); userSettings.contact = contactRow.doc; return userSettings; @@ -764,14 +847,90 @@ const getUserDocsByName = (name) => Promise.all(['users', 'medic'].map(dbName => const getUserSettings = async({ name }) => { const [ user, medicUser ] = await getUserDocsByName(name); - Object.assign(medicUser, _.pick(user, 'name', 'roles', 'facility_id', 'contact_id')); + Object.assign(medicUser, _.pick(user, 'name', 'roles', 'contact_id')); + medicUser.facility_id = Array.isArray(user.facility_id) ? user.facility_id : [user.facility_id]; return hydrateUserSettings(medicUser); }; +const createMultiFacilityUser = async (data, appUrl) => { + const missing = missingFields(data); + if (missing.length > 0) { + return Promise.reject(error400( + 'Missing required fields: ' + missing.join(', '), + 'fields.required', + { 'fields': missing.join(', ') } + )); + } + hydratePayload(data); + + const tokenLoginError = tokenLogin.validateTokenLogin(data, true); + if (tokenLoginError) { + throw error400(tokenLoginError.msg, tokenLoginError.key); + } + const passwordError = validatePassword(data.password); + if (passwordError) { + throw passwordError; + } + + const response = {}; + await validateNewUsername(data.username); + await validateUserFacility(data); + await validateUserContact(data); + + await createUser(data, response); + await createUserSettings(data, response); + await tokenLogin.manageTokenLogin(data, appUrl, response); + return response; +}; + +const validateUpgradeAttemptFields = (data) => { + const props = _.uniq(USER_EDITABLE_FIELDS.concat(SETTINGS_EDITABLE_FIELDS, META_FIELDS, LEGACY_FIELDS)); + + // Online users can remove place or contact + if (!_.isNull(data.place) && + !_.isNull(data.contact) && + !_.some(props, key => (!_.isNull(data[key]) && !_.isUndefined(data[key]))) + ) { + throw error400( + 'One of the following fields are required: ' + props.join(', '), + 'fields.one.required', + { 'fields': props.join(', ') } + ); + } +}; + +const validateUpgradeAtetmptPassword = (data) => { + if (data.password) { + const passwordError = validatePassword(data.password); + if (passwordError) { + throw passwordError; + } + } +}; + +const validateUpdateAttempt = (data, fullAccess) => { + // Reject update attempts that try to modify data they're not allowed to + if (!fullAccess) { + const illegalAttempts = illegalDataModificationAttempts(data); + if (illegalAttempts.length) { + const err = Error('You do not have permission to modify: ' + illegalAttempts.join(',')); + err.status = 401; + throw err; + } + } + + validateUpgradeAttemptFields(data); + validateUpgradeAtetmptPassword(data); +}; + +const checkPayloadFacilityCount = (data) => { + return Array.isArray(data.place) && data.place.length > 1; +}; + /* - * Everything not exported directly is private. Underscore prefix is only used - * to export functions needed for testing. - */ + * Everything not exported directly is private. Underscore prefix is only used + * to export functions needed for testing. + */ module.exports = { deleteUser: username => deleteUser(createID(username)), getList: async (filters) => { @@ -797,11 +956,15 @@ module.exports = { * @param {Object} data * @param {string} data.username Identifier used for authentication * @param {string[]} data.roles - * @param {(Object|string)=} data.place Place identifier string (UUID) or object this user resides in. Required if the roles contain an offline role. - * @param {(Object|string)=} data.contact A person identifier string (UUID) or object based on the form configured in the app. Required if the roles contain an offline role. - * @param {string=} data.password Password string used for authentication. Only allowed to be set, not retrieved. Required if token_login is not enabled for the user. + * @param {(Object|string)=} data.place Place identifier string (UUID) or object this user resides in. Required if + * the roles contain an offline role. + * @param {(Object|string)=} data.contact A person identifier string (UUID) or object based on the form configured in + * the app. Required if the roles contain an offline role. + * @param {string=} data.password Password string used for authentication. Only allowed to be set, not retrieved. + * Required if token_login is not enabled for the user. * @param {string=} data.phone Valid phone number. Required if token_login is enabled for the user. - * @param {Boolean=} data.token_login A boolean representing whether or not the Login by SMS should be enabled for this user. + * @param {Boolean=} data.token_login A boolean representing whether or not the Login by SMS should be enabled for + * this user. * @param {string=} data.fullname Full name * @param {string=} data.email Email address * @param {Boolean=} data.known Boolean to define if the user has logged in before. @@ -840,11 +1003,15 @@ module.exports = { * @param {Object|Object[]} users[] * @param {string} users[].username Identifier used for authentication * @param {string[]} users[].roles - * @param {(Object|string)=} users[].place Place identifier string (UUID) or object this user resides in. Required if the roles contain an offline role. - * @param {(Object|string)=} users[].contact A person identifier string (UUID) or object based on the form configured in the app. Required if the roles contain an offline role. - * @param {string=} users[].password Password string used for authentication. Only allowed to be set, not retrieved. Required if token_login is not enabled for the user. + * @param {(Object|string)=} users[].place Place identifier string (UUID) or object this user resides in. Required if + * the roles contain an offline role. + * @param {(Object|string)=} users[].contact A person identifier string (UUID) or object based on the form configured + * in the app. Required if the roles contain an offline role. + * @param {string=} users[].password Password string used for authentication. Only allowed to be set, not retrieved. + * Required if token_login is not enabled for the user. * @param {string=} users[].phone Valid phone number. Required if token_login is enabled for the user. - * @param {Boolean=} users[].token_login A boolean representing whether or not the Login by SMS should be enabled for this user. + * @param {Boolean=} users[].token_login A boolean representing whether or not the Login by SMS should be enabled for + * this user. * @param {string=} users[].fullname Full name * @param {string=} users[].email Email address * @param {Boolean=} users[].known Boolean to define if the user has logged in before. @@ -886,6 +1053,7 @@ module.exports = { if (missing.length > 0) { throw new Error('Missing required fields: ' + missing.join(', ')); } + hydratePayload(user); const tokenLoginError = tokenLogin.validateTokenLogin(user, true); if (tokenLoginError) { @@ -953,36 +1121,8 @@ module.exports = { * @param {String} appUrl request protocol://hostname */ updateUser: async (username, data, fullAccess, appUrl) => { - // Reject update attempts that try to modify data they're not allowed to - if (!fullAccess) { - const illegalAttempts = illegalDataModificationAttempts(data); - if (illegalAttempts.length) { - const err = Error('You do not have permission to modify: ' + illegalAttempts.join(',')); - err.status = 401; - return Promise.reject(err); - } - } - - const props = _.uniq(USER_EDITABLE_FIELDS.concat(SETTINGS_EDITABLE_FIELDS, META_FIELDS, LEGACY_FIELDS)); - - // Online users can remove place or contact - if (!_.isNull(data.place) && - !_.isNull(data.contact) && - !_.some(props, key => (!_.isNull(data[key]) && !_.isUndefined(data[key]))) - ) { - return Promise.reject(error400( - 'One of the following fields are required: ' + props.join(', '), - 'fields.one.required', - { 'fields': props.join(', ') } - )); - } - - if (data.password) { - const passwordError = validatePassword(data.password); - if (passwordError) { - return Promise.reject(passwordError); - } - } + await validateUpdateAttempt(data, fullAccess); + hydratePayload(data); const [user, userSettings] = await Promise.all([ getUpdatedUserDoc(username, data), @@ -994,8 +1134,9 @@ module.exports = { return Promise.reject(error400(tokenLoginError.msg, tokenLoginError.key)); } - await validateUserFacility(data, user, userSettings); - await validateUserContact(data, user, userSettings); + await validateUserFacility(data, user); + await validateUserContact(data, user); + const response = { user: await saveUserUpdates(user), 'user-settings': await saveUserSettingsUpdates(userSettings), @@ -1024,4 +1165,8 @@ module.exports = { * @param {string} csv CSV of users. */ parseCsv, + + createMultiFacilityUser, + + checkPayloadFacilityCount, }; diff --git a/shared-libs/user-management/test/unit/roles.spec.js b/shared-libs/user-management/test/unit/roles.spec.js index 4350b8b6257..e3722063ce3 100644 --- a/shared-libs/user-management/test/unit/roles.spec.js +++ b/shared-libs/user-management/test/unit/roles.spec.js @@ -101,4 +101,77 @@ describe('roles', () => { chai.expect(roles.isOffline(['roleB', 'roleC'])).to.equal(false); }); }); + + describe('hasAllPermissions', () => { + it('should return true for db admin', () => { + chai.expect(roles.hasAllPermissions(['_admin'], 'permission')).to.equal(true); + }); + + it('should return false for no permissions', () => { + chai.expect(roles.hasAllPermissions(['role'])).to.equal(false); + }); + + it('should return false for no roles', () => { + chai.expect(roles.hasAllPermissions(undefined, ['perm'])).to.equal(false); + }); + + it('should return false when no role has permission', () => { + config.get.withArgs('permissions').returns({ + 'permission1': ['role1', 'role2'], + 'permission2': ['role2', 'role3'], + 'permission3': [], + }); + + chai.expect(roles.hasAllPermissions(['role2'], ['permission3'])).to.equal(false); + }); + + it('should return false for missing permission', () => { + config.get.withArgs('permissions').returns({ + 'permission1': ['role1', 'role2'], + 'permission2': ['role2', 'role3'], + }); + + chai.expect(roles.hasAllPermissions(['role2'], ['perm'])).to.equal(false); + }); + + it('should return false when some roles have some permissions', () => { + config.get.withArgs('permissions').returns({ + 'permission1': ['role1', 'role2'], + 'permission2': ['role2', 'role3'], + 'permission3': ['role3'], + }); + + chai.expect(roles.hasAllPermissions(['role2'], ['permission3', 'permission1'])).to.equal(false); + }); + + it('should return true when one role has all permissions', () => { + config.get.withArgs('permissions').returns({ + 'permission1': ['role1', 'role2'], + 'permission2': ['role2', 'role3'], + 'permission3': ['role3'], + }); + + chai.expect(roles.hasAllPermissions(['role2', 'role1'], ['permission1', 'permission2'])).to.equal(true); + }); + + it('should return true when multiple roles have all permissions', () => { + config.get.withArgs('permissions').returns({ + 'permission1': ['role1', 'role2'], + 'permission2': ['role3', 'role4'], + 'permission3': ['role3'], + }); + + chai.expect(roles.hasAllPermissions(['role1', 'role3'], ['permission1', 'permission2'])).to.equal(true); + }); + + it('should return work with single permission', () => { + config.get.withArgs('permissions').returns({ + 'permission1': ['role1', 'role2'], + 'permission2': ['role3', 'role4'], + 'permission3': ['role3'], + }); + + chai.expect(roles.hasAllPermissions(['role3'], 'permission3')).to.equal(true); + }); + }); }); diff --git a/shared-libs/user-management/test/unit/users.spec.js b/shared-libs/user-management/test/unit/users.spec.js index 3318e1aba8e..6f1fc9ddf1f 100644 --- a/shared-libs/user-management/test/unit/users.spec.js +++ b/shared-libs/user-management/test/unit/users.spec.js @@ -1,4 +1,5 @@ const chai = require('chai'); +chai.use(require('chai-as-promised')); const sinon = require('sinon'); const rewire = require('rewire'); @@ -6,16 +7,19 @@ const couchSettings = require('@medic/settings'); const tokenLogin = require('../../src/token-login'); const config = require('../../src/libs/config'); const db = require('../../src/libs/db'); +const dataContext = require('../../src/libs/data-context'); const facility = require('../../src/libs/facility'); const lineage = require('../../src/libs/lineage'); const passwords = require('../../src/libs/passwords'); const roles = require('../../src/roles'); -const { people, places } = require('@medic/contacts')(config, db); +const { Person, Place, Qualifier } = require('@medic/cht-datasource'); +const { people, places } = require('@medic/contacts')(config, db, dataContext); const COMPLEX_PASSWORD = '23l4ijk3nSDELKSFnwekirh'; -const facilitya = { _id: 'a', name: 'aaron' }; -const facilityb = { _id: 'b', name: 'brian' }; -const facilityc = { _id: 'c', name: 'cathy' }; +const facilityA = { _id: 'a', name: 'aaron' }; +const facilityB = { _id: 'b', name: 'brian' }; +const facilityC = { _id: 'c', name: 'cathy' }; +const facilityD = { _id: 'd', name: 'dorothy' }; const contactMilan = { _id: 'milan-contact', type: 'person', @@ -25,6 +29,8 @@ const contactMilan = { let userData; let clock; let addMessage; +let getPerson; +let getPlace; const oneDayInMS = 24 * 60 * 60 * 1000; let service; @@ -45,15 +51,22 @@ describe('Users service', () => { medicLogs: { get: sinon.stub(), put: sinon.stub(), }, users: { query: sinon.stub(), get: sinon.stub(), put: sinon.stub() }, }); + getPerson = sinon.stub(); + getPlace = sinon.stub(); + const bind = sinon.stub(); + bind.withArgs(Person.v1.get).returns(getPerson); + bind.withArgs(Place.v1.get).returns(getPlace); + dataContext.init({ bind }); lineage.init(require('@medic/lineage')(Promise, db.medic)); addMessage = sinon.stub(); config.getTransitionsLib.returns({ messages: { addMessage } }); service = rewire('../../src/users'); sinon.stub(facility, 'list').resolves([ - facilitya, - facilityb, - facilityc, contactMilan, + facilityA, + facilityB, + facilityC, + facilityD, ]); sinon.stub(couchSettings, 'getCouchConfig').resolves(); userData = { @@ -94,13 +107,16 @@ describe('Users service', () => { const data = { place: 'abc', contact: '123', - fullname: 'John' + fullname: 'John', + contact_id: '123', + facility_id: ['abc'] + }; const settings = service.__get__('getSettingsUpdates')('john', data); chai.expect(settings.place).to.equal(undefined); chai.expect(settings.contact).to.equal(undefined); chai.expect(settings.contact_id).to.equal('123'); - chai.expect(settings.facility_id).to.equal('abc'); + chai.expect(settings.facility_id).to.deep.equal(['abc']); chai.expect(settings.fullname).to.equal('John'); }); @@ -136,12 +152,14 @@ describe('Users service', () => { it('reassigns place and contact fields', () => { const data = { place: 'abc', - contact: 'xyz' + contact: 'xyz', + facility_id: ['abc'], + contact_id: 'xyz' }; const user = service.__get__('getUserUpdates')('john', data); chai.expect(user.place).to.equal(undefined); chai.expect(user.contact).to.equal(undefined); - chai.expect(user.facility_id).to.equal('abc'); + chai.expect(user.facility_id).to.deep.equal(['abc']); chai.expect(user.contact_id).to.equal('xyz'); }); @@ -189,14 +207,24 @@ describe('Users service', () => { it('with facility_id', async () => { const filters = { facilityId: 'c' }; const usersResponse = { - rows: [{ - doc: { - _id: 'org.couchdb.user:x', - name: 'lucas', - facility_id: 'c', - roles: ['national-admin', 'data-entry'], + rows: [ + { + doc: { + _id: 'org.couchdb.user:x', + name: 'lucas', + facility_id: 'c', + roles: ['national-admin', 'data-entry'], + } + }, + { + doc: { + _id: 'org.couchdb.user:y', + name: 'marvin', + facility_id: ['c', 'd'], + roles: ['national-admin', 'data-entry'], + } } - }], + ], }; db.users.query.withArgs('users/users_by_field', { include_docs: true, @@ -204,29 +232,53 @@ describe('Users service', () => { }).resolves(usersResponse); const userSettingsResponse = { - rows: [{ - doc: { - _id: 'org.couchdb.user:x', - name: 'lucas', - fullname: 'Lucas M', - email: 'l@m.com', - phone: '123456789', - facility_id: 'c', + rows: [ + { + doc: { + _id: 'org.couchdb.user:x', + name: 'lucas', + fullname: 'Lucas M', + email: 'l@m.com', + phone: '123456789', + facility_id: 'c', + }, }, - }], + { + doc: { + _id: 'org.couchdb.user:y', + name: 'marvin', + fullname: 'Marvin Min', + email: 'l@m.com', + phone: '123456789', + facility_id: ['c', 'd'], + }, + } + ], }; - db.medic.allDocs.withArgs({ keys: ['org.couchdb.user:x'], include_docs: true }).resolves(userSettingsResponse); + db.medic.allDocs.resolves(userSettingsResponse); const data = await service.getList(filters); - chai.expect(data.length).to.equal(1); + chai.expect(data.length).to.equal(2); const lucas = data[0]; - chai.expect(lucas.id).to.equal('org.couchdb.user:x'); - chai.expect(lucas.username).to.equal('lucas'); - chai.expect(lucas.fullname).to.equal('Lucas M'); - chai.expect(lucas.email).to.equal('l@m.com'); - chai.expect(lucas.phone).to.equal('123456789'); - chai.expect(lucas.place).to.deep.equal(facilityc); - chai.expect(lucas.roles).to.deep.equal(['national-admin', 'data-entry']); + chai.expect(lucas).to.deep.include({ + id: 'org.couchdb.user:x', + username: 'lucas', + fullname: 'Lucas M', + email: 'l@m.com', + phone: '123456789', + place: [facilityC], + roles: ['national-admin', 'data-entry'] + }); + const marvin = data[1]; + chai.expect(marvin).to.deep.include({ + id: 'org.couchdb.user:y', + username: 'marvin', + fullname: 'Marvin Min', + email: 'l@m.com', + phone: '123456789', + place: [facilityC, facilityD], + roles: ['national-admin', 'data-entry'] + }); }); it('with contact_id', async () => { @@ -272,7 +324,7 @@ describe('Users service', () => { chai.expect(milan.email).to.equal('m@a.com'); chai.expect(milan.phone).to.equal('987654321'); chai.expect(milan.contact._id).to.equal('milan-contact'); - chai.expect(milan.place).to.deep.equal(facilityb); + chai.expect(milan.place).to.deep.equal([facilityB]); chai.expect(milan.roles).to.deep.equal(['district-admin']); }); @@ -284,7 +336,7 @@ describe('Users service', () => { doc: { _id: 'org.couchdb.user:y', name: 'milan', - facility_id: 'b', + facility_id: ['b', 'c'], contact_id: 'milan-contact', roles: ['district-admin'], } @@ -314,7 +366,7 @@ describe('Users service', () => { email: 'm@a.com', phone: '987654321', external_id: 'LTT093', - facility_id: 'b', + facility_id: ['b', 'c'], contact_id: 'milan-contact', }, }], @@ -331,7 +383,7 @@ describe('Users service', () => { chai.expect(milan.email).to.equal('m@a.com'); chai.expect(milan.phone).to.equal('987654321'); chai.expect(milan.contact._id).to.equal('milan-contact'); - chai.expect(milan.place).to.deep.equal(facilityb); + chai.expect(milan.place).to.deep.equal([facilityB, facilityC]); chai.expect(milan.roles).to.deep.equal(['district-admin']); }); }); @@ -381,14 +433,14 @@ describe('Users service', () => { chai.expect(lucas.fullname).to.equal('Lucas M'); chai.expect(lucas.email).to.equal('l@m.com'); chai.expect(lucas.phone).to.equal('123456789'); - chai.expect(lucas.place).to.deep.equal(facilityc); + chai.expect(lucas.place).to.deep.equal([facilityC]); chai.expect(lucas.roles).to.deep.equal([ 'national-admin', 'data-entry' ]); chai.expect(milan.id).to.equal('org.couchdb.user:y'); chai.expect(milan.username).to.equal('milan'); chai.expect(milan.fullname).to.equal('Milan A'); chai.expect(milan.email).to.equal('m@a.com'); chai.expect(milan.phone).to.equal('987654321'); - chai.expect(milan.place).to.deep.equal(facilityb); + chai.expect(milan.place).to.deep.equal([facilityB]); chai.expect(milan.roles).to.deep.equal([ 'district-admin' ]); chai.expect(milan.external_id).to.equal('LTT093'); }); @@ -443,7 +495,7 @@ describe('Users service', () => { chai.expect(milan.fullname).to.equal('Milan A'); chai.expect(milan.email).to.equal('m@a.com'); chai.expect(milan.phone).to.equal('987654321'); - chai.expect(milan.place).to.deep.equal(facilityb); + chai.expect(milan.place).to.deep.equal([facilityB]); chai.expect(milan.roles).to.deep.equal([ 'district-admin' ]); }); @@ -514,7 +566,7 @@ describe('Users service', () => { _id: userId, _rev: 'steve-user-rev', name: 'steve', - facility_id: facilitya._id, + facility_id: facilityA._id, roles: ['a', 'b'], contact_id: contactMilan._id, known: 'true' @@ -524,9 +576,9 @@ describe('Users service', () => { _id: 'org.couchdb.user:steve (settings)', _rev: 'steve-user-settings-rev', name: 'steve settings', - facility_id: facilityb._id, + facility_id: facilityB._id, roles: ['c'], - contact_id: facilityc._id, + contact_id: facilityC._id, fullname: 'Steve Full Name', email: 'steve@mail.com', phone: '123456789', @@ -542,7 +594,7 @@ describe('Users service', () => { fullname: 'Steve Full Name', email: 'steve@mail.com', phone: '123456789', - place: facilitya, + place: [facilityA], roles: ['a', 'b'], contact: contactMilan, external_id: 'CHP020', @@ -578,7 +630,7 @@ describe('Users service', () => { fullname: undefined, email: undefined, phone: undefined, - place: undefined, + place: null, roles: undefined, contact: undefined, external_id: undefined, @@ -660,8 +712,41 @@ describe('Users service', () => { db.medic.get.resolves({ name: 'steve2', facility_id: 'otherville', contact_id: 'not_steve', roles: ['c'] }); db.medic.allDocs.resolves({ rows: [ + { id: 'steve', key: 'steve', doc: { _id: 'steve', patient_id: 'steve', name: 'steve' } }, { id: 'steveVille', key: 'steveVille', doc: { _id: 'steveVille', place_id: 'steve_ville', name: 'steve V' } }, + ], + }); + + return service + .getUserSettings({ name: 'steve' }) + .then(result => { + chai.expect(result).to.deep.equal({ + name: 'steve', + facility_id: ['steveVille'], + contact_id: 'steve', + roles: ['b'], + facility: [{ _id: 'steveVille', place_id: 'steve_ville', name: 'steve V' }], + contact: { _id: 'steve', patient_id: 'steve', name: 'steve' }, + }); + + chai.expect(db.users.get.callCount).to.equal(1); + chai.expect(db.users.get.withArgs('org.couchdb.user:steve').callCount).to.equal(1); + chai.expect(db.medic.get.callCount).to.equal(1); + chai.expect(db.medic.get.withArgs('org.couchdb.user:steve').callCount).to.equal(1); + chai.expect(db.medic.allDocs.callCount).to.equal(1); + chai.expect(db.medic.allDocs.args[0]).to.deep.equal([{ keys: ['steve', 'steveVille'], include_docs: true }]); + chai.expect(db.medic.query.callCount).to.equal(0); + }); + }); + + it('returns medic user doc with facilities from couchdb user doc', () => { + db.users.get.resolves({ name: 'steve', facility_id: ['sVille', 'lVille'], contact_id: 'steve', roles: ['b'] }); + db.medic.get.resolves({ name: 'steve2', facility_id: 'otherville', contact_id: 'not_steve', roles: ['c'] }); + db.medic.allDocs.resolves({ + rows: [ { id: 'steve', key: 'steve', doc: { _id: 'steve', patient_id: 'steve', name: 'steve' } }, + { id: 'sVille', key: 'sVille', doc: { _id: 'sVille', place_id: 'steve_ville', name: 'steve V' } }, + { id: 'lVille', key: 'lVille', doc: { _id: 'lVille', place_id: 'lovre_ville', name: 'lovre V' } }, ], }); @@ -670,10 +755,13 @@ describe('Users service', () => { .then(result => { chai.expect(result).to.deep.equal({ name: 'steve', - facility_id: 'steveVille', + facility_id: ['sVille', 'lVille'], contact_id: 'steve', roles: ['b'], - facility: { _id: 'steveVille', place_id: 'steve_ville', name: 'steve V' }, + facility: [ + { _id: 'sVille', place_id: 'steve_ville', name: 'steve V' }, + { _id: 'lVille', place_id: 'lovre_ville', name: 'lovre V' } + ], contact: { _id: 'steve', patient_id: 'steve', name: 'steve' }, }); @@ -682,7 +770,9 @@ describe('Users service', () => { chai.expect(db.medic.get.callCount).to.equal(1); chai.expect(db.medic.get.withArgs('org.couchdb.user:steve').callCount).to.equal(1); chai.expect(db.medic.allDocs.callCount).to.equal(1); - chai.expect(db.medic.allDocs.args[0]).to.deep.equal([{ keys: ['steveVille', 'steve'], include_docs: true }]); + chai.expect(db.medic.allDocs.args[0]).to.deep.equal( + [{ keys: ['steve', 'sVille', 'lVille'], include_docs: true }] + ); chai.expect(db.medic.query.callCount).to.equal(0); }); }); @@ -707,8 +797,8 @@ describe('Users service', () => { }); db.medic.allDocs.resolves({ rows: [ - { id: 'myUserVille', key: 'myUserVille', doc: { _id: 'myUserVille', place_id: 'user_ville' } }, { id: 'my-user-contact', key: 'my-user-contact', doc: { _id: 'my-user-contact', patient_id: 'contact' } }, + { id: 'myUserVille', key: 'myUserVille', doc: { _id: 'myUserVille', place_id: 'user_ville' } }, ], }); @@ -722,8 +812,8 @@ describe('Users service', () => { type: 'user-settings', some: 'field', contact_id: 'my-user-contact', - facility_id: 'myUserVille', - facility: { _id: 'myUserVille', place_id: 'user_ville' }, + facility_id: ['myUserVille'], + facility: [{ _id: 'myUserVille', place_id: 'user_ville' }], contact: { _id: 'my-user-contact', patient_id: 'contact' }, }); }); @@ -761,7 +851,7 @@ describe('Users service', () => { type: 'user-settings', some: 'field', contact_id: 'my-user-contact', - facility_id: 'myUserVille', + facility_id: ['myUserVille'], }); }); }); @@ -796,7 +886,7 @@ describe('Users service', () => { type: 'user-settings', some: 'field', contact_id: 'my-user-contact', - facility_id: 'myUserVille', + facility_id: ['myUserVille'], }); }); }); @@ -831,7 +921,7 @@ describe('Users service', () => { type: 'user-settings', some: 'field', contact_id: 'my-user-contact', - facility_id: 'myUserVille', + facility_id: ['myUserVille'], }); }); }); @@ -866,7 +956,7 @@ describe('Users service', () => { type: 'user-settings', some: 'field', contact_id: 'my-user-contact', - facility_id: 'myUserVille', + facility_id: ['myUserVille'], }); }); }); @@ -891,8 +981,8 @@ describe('Users service', () => { }); db.medic.allDocs.resolves({ rows: [ - { id: 'myUserVille', key: 'myUserVille', doc: { _id: 'myUserVille' } }, { key: 'my-user-contact', error: 'not_found' }, + { id: 'myUserVille', key: 'myUserVille', doc: { _id: 'myUserVille' } }, ], }); @@ -906,8 +996,8 @@ describe('Users service', () => { type: 'user-settings', some: 'field', contact_id: 'my-user-contact', - facility_id: 'myUserVille', - facility: { _id: 'myUserVille' }, + facility_id: ['myUserVille'], + facility: [{ _id: 'myUserVille' }], contact: undefined, }); }); @@ -1107,27 +1197,27 @@ describe('Users service', () => { }); }); - it('sets up response', () => { + it('sets up response', async () => { sinon.stub(people, 'getOrCreatePerson').resolves({ _id: 'abc', _rev: '1-xyz' }); const response = {}; - return service - .__get__('createContact')(userData, response) - .then(() => { - chai.expect(response).to.deep.equal({ - contact: { - id: 'abc', - rev: '1-xyz' - } - }); - chai.expect(db.medic.get.notCalled).to.be.true; - chai.expect(db.medic.put.notCalled).to.be.true; - }); + await service.__get__('createContact')(userData, response); + + chai.expect(response).to.deep.equal({ + contact: { + id: 'abc', + rev: '1-xyz' + } + }); + chai.expect(dataContext.bind.notCalled).to.be.true; + chai.expect(getPerson.notCalled).to.be.true; + chai.expect(db.medic.get.notCalled).to.be.true; + chai.expect(db.medic.put.notCalled).to.be.true; }); - it('removes user_for_contact.create property', () => { + it('removes user_for_contact.create property', async () => { const contact = { _id: 'abc', _rev: '1-abc', user_for_contact: { create: 'true'} }; sinon.stub(people, 'getOrCreatePerson').resolves(contact); - db.medic.get.resolves(contact); + getPerson.resolves(contact); db.medic.put.resolves({ id: 'abc', rev: '2-xyz' @@ -1138,28 +1228,26 @@ describe('Users service', () => { user_for_contact: {} }; const response = {}; - return service - .__get__('createContact')(userData, response) - .then(() => { - chai.expect(db.medic.get.callCount).to.equal(1); - chai.expect(db.medic.get.args[0]).to.deep.equal(['abc']); - chai.expect(db.medic.put.callCount).to.equal(1); - chai.expect(db.medic.put.args[0]).to.deep.equal([expectedContact]); + await service.__get__('createContact')(userData, response); - chai.expect(userData.contact).to.deep.equal(expectedContact); - chai.expect(response).to.deep.equal({ - contact: { - id: 'abc', - rev: '2-xyz' - } - }); - }); + chai.expect(dataContext.bind.calledOnceWithExactly(Person.v1.get)).to.be.true; + chai.expect(getPerson.calledOnceWithExactly(Qualifier.byUuid('abc'))).to.be.true; + chai.expect(db.medic.put.callCount).to.equal(1); + chai.expect(db.medic.put.args[0]).to.deep.equal([expectedContact]); + + chai.expect(userData.contact).to.deep.equal(expectedContact); + chai.expect(response).to.deep.equal({ + contact: { + id: 'abc', + rev: '2-xyz' + } + }); }); - it('does not remove user_for_contact.create property if the contact has already changed in the DB', () => { + it('does not remove user_for_contact.create property if the contact has already changed in the DB', async () => { const contact = { _id: 'abc', _rev: '1-abc', user_for_contact: { create: 'true'} }; sinon.stub(people, 'getOrCreatePerson').resolves(contact); - db.medic.get.resolves({ ...contact, user_for_contact: undefined }); + getPerson.resolves({ ...contact, user_for_contact: undefined }); db.medic.put.resolves({ id: 'abc', rev: '2-xyz' @@ -1169,21 +1257,19 @@ describe('Users service', () => { user_for_contact: {} }; const response = {}; - return service - .__get__('createContact')(userData, response) - .then(() => { - chai.expect(db.medic.get.callCount).to.equal(1); - chai.expect(db.medic.get.args[0]).to.deep.equal(['abc']); - chai.expect(db.medic.put.notCalled).to.be.true; - - chai.expect(userData.contact).to.deep.equal(expectedContact); - chai.expect(response).to.deep.equal({ - contact: { - id: 'abc', - rev: '1-abc' - } - }); - }); + await service.__get__('createContact')(userData, response); + + chai.expect(dataContext.bind.calledOnceWithExactly(Person.v1.get)).to.be.true; + chai.expect(getPerson.calledOnceWithExactly(Qualifier.byUuid('abc'))).to.be.true; + chai.expect(db.medic.put.notCalled).to.be.true; + + chai.expect(userData.contact).to.deep.equal(expectedContact); + chai.expect(response).to.deep.equal({ + contact: { + id: 'abc', + rev: '1-abc' + } + }); }); }); @@ -1219,15 +1305,20 @@ describe('Users service', () => { it('returns error if missing fields', () => { return service.createUser({}) .catch(err => chai.expect(err.code).to.equal(400)) // empty - .then(() => service.createUser({ password: 'x', place: 'x', contact: { parent: 'x' }})) // missing username + // missing username + .then(() => service.createUser({ password: 'x', place: 'x', contact: { parent: 'x' }, type: 'a'})) .catch(err => chai.expect(err.code).to.equal(400)) - .then(() => service.createUser({ username: 'x', place: 'x', contact: { parent: 'x' }})) // missing password + // missing password + .then(() => service.createUser({ username: 'x', place: 'x', contact: { parent: 'x' }, type: 'a'})) .catch(err => chai.expect(err.code).to.equal(400)) - .then(() => service.createUser({ username: 'x', password: 'x', contact: { parent: 'x' }})) // missing place + // missing place + .then(() => service.createUser({ username: 'x', password: 'x', contact: { parent: 'x' }, type: 'a'})) .catch(err => chai.expect(err.code).to.equal(400)) - .then(() => service.createUser({ username: 'x', place: 'x', contact: { parent: 'x' }})) // missing contact + // missing contact + .then(() => service.createUser({ username: 'x', place: 'x', contact: { parent: 'x' }, type: 'a'})) .catch(err => chai.expect(err.code).to.equal(400)) - .then(() => service.createUser({ username: 'x', place: 'x', contact: {}})) // missing contact.parent + // missing contact.parent + .then(() => service.createUser({ username: 'x', place: 'x', contact: {}, type: 'a'})) .catch(err => chai.expect(err.code).to.equal(400)); }); @@ -1443,7 +1534,7 @@ describe('Users service', () => { { error: { message: 'The password is too easy to guess. Include a range of' + - ' types of characters to increase the score.', + ' types of characters to increase the score.', translationKey: 'password.weak', translationParams: undefined } @@ -2012,6 +2103,231 @@ describe('Users service', () => { }); }); + describe('createMultiFacilityUser', () => { + it('returns error if missing fields', async () => { + const pwd = 'medic.123456'; + await chai.expect(service.createMultiFacilityUser({})) + .to.be.eventually.rejectedWith(Error).and.have.property('code', 400); + await chai.expect(service.createMultiFacilityUser({ password: 'x', place: 'x', contact: 'y' })) + .to.be.eventually.rejectedWith(Error).and.have.property('code', 400); + await chai.expect(service.createMultiFacilityUser({ username: 'x', place: 'x', contact: 'y' })) + .to.be.eventually.rejectedWith(Error).and.have.property('code', 400); + await chai.expect(service.createMultiFacilityUser({ username: 'x', password: 'x', contact: 'y' })) + .to.be.eventually.rejectedWith(Error).and.have.property('code', 400); + await chai.expect(service.createMultiFacilityUser({ username: 'x', password: 'x', place: 'x' })) + .to.be.eventually.rejectedWith(Error).and.have.property('code', 400); + let payload = { username: 'x', password: pwd, place: '', type: 'user', contact: 'z' }; + await chai.expect(service.createMultiFacilityUser(payload)) + .to.be.eventually.rejectedWith(Error).and.have.property('code', 400); + payload = { username: 'x', password: pwd, place: [], type: 'user', contact: 'z' }; + await chai.expect(service.createMultiFacilityUser(payload)) + .to.be.eventually.rejectedWith(Error).and.have.property('code', 400); + payload = { username: 'x', password: pwd, place: [''], type: 'user', contact: 'z' }; + await chai.expect(service.createMultiFacilityUser(payload)) + .to.be.eventually.rejectedWith(Error).and.have.property('code', 400); + }); + + it('returns error if short password', async () => { + const data = { + username: 'x', + place: 'x', + contact: 't', + type: 'national-manager', + password: 'short' + }; + await chai.expect(service.createMultiFacilityUser(data)).to.be.eventually.rejectedWith(Error) + .and.have.property('code', 400); + }); + + it('returns error if weak password', async () => { + const data = { + username: 'x', + place: 'x', + contact: 'y', + type: 'national-manager', + password: 'password' + }; + await chai.expect(service.createMultiFacilityUser(data)).to.be.eventually.rejectedWith(Error) + .and.have.property('code', 400); + }); + + it('returns error if has multiple facilities but does not have role', async () => { + service.__set__('validateNewUsername', sinon.stub().resolves()); + sinon.stub(roles, 'hasAllPermissions').returns(false); + + const data = { + username: 'x', + place: ['x', 'z'], + contact: 'y', + password: 'password.123', + roles: ['a', 'b'] + }; + + await chai.expect(service.createMultiFacilityUser(data)).to.be.eventually.rejectedWith(Error) + .and.have.property('code', 400); + + chai.expect(roles.hasAllPermissions.args).to.deep.equal([[['a', 'b'], ['can_have_multiple_places']]]); + }); + + it('returns error if place lookup fails', async () => { + service.__set__('validateNewUsername', sinon.stub().resolves()); + sinon.stub(places, 'placesExist').rejects(new Error('missing')); + sinon.stub(roles, 'hasAllPermissions').returns(true); + + const data = { + username: 'x', + place: ['x', 'z'], + contact: 'y', + type: 'national-manager', + password: 'password.123' + }; + + await chai.expect(service.createMultiFacilityUser(data)).to.be.eventually.rejectedWith('missing'); + + chai.expect(places.placesExist.args[0]).to.deep.equal([['x', 'z']]); + }); + + it('returns error if places lookup fails', async () => { + service.__set__('validateNewUsername', sinon.stub().resolves()); + sinon.stub(places, 'placesExist').rejects(new Error('missing')); + sinon.stub(roles, 'hasAllPermissions').returns(true); + + const data = { + username: 'x', + place: ['x', 'y', 'z'], + contact: 'y', + type: 'national-manager', + password: 'password.123' + }; + await chai.expect(service.createMultiFacilityUser(data)).to.be.eventually.rejectedWith('missing'); + + chai.expect(places.placesExist.args[0]).to.deep.equal([['x', 'y', 'z']]); + }); + + it('returns error if contact is not within place', async () => { + service.__set__('validateNewUsername', sinon.stub().resolves()); + sinon.stub(places, 'placesExist').resolves(); + sinon.stub(roles, 'hasAllPermissions').returns(true); + const data = { + username: 'x', + place: ['x', 'y', 'z'], + contact: 'h', + type: 'national-manager', + password: 'password.123' + }; + + db.medic.get.withArgs('h').resolves({ parent: { _id: 'u', parent: { _id: 't' } } }); + + await chai.expect(service.createMultiFacilityUser(data)) + .to.be.eventually.rejectedWith(Error) + .and.have.property('code', 400); + }); + + it('fails if new username does not validate', async () => { + service.__set__('validateNewUsername', sinon.stub().rejects(new Error('sorry'))); + + await chai.expect(service.createMultiFacilityUser(userData)).to.be.eventually.rejectedWith('sorry'); + }); + + it('errors if username exists in _users db', async () => { + db.users.get.resolves('bob lives here already.'); + db.medic.get.resolves(); + await chai.expect(service.createMultiFacilityUser(userData)).to.be.eventually.rejectedWith(Error) + .and.have.deep.property('message', { + message: 'Username "x" already taken.', + translationKey: 'username.taken', + translationParams: { username: 'x' } + }); + }); + + it('errors if username exists in medic db', async () => { + db.users.get.resolves(); + db.medic.get.resolves('jane lives here too.'); + await chai.expect(service.createMultiFacilityUser(userData)).to.be.eventually.rejectedWith(Error) + .and.have.deep.property('message', { + message: 'Username "x" already taken.', + translationKey: 'username.taken', + translationParams: { username: 'x' } + }); + }); + + it('succeeds if contact is within place', async () => { + service.__set__('validateNewUsername', sinon.stub().resolves()); + sinon.stub(places, 'placesExist').resolves(); + sinon.stub(people, 'isAPerson').returns(true); + db.medic.put.resolves({ id: 'success' }); + db.users.put.resolves({ id: 'success' }); + sinon.stub(roles, 'hasAllPermissions').returns(true); + + const userData = { + username: 'x', + place: ['x', 'y', 'z'], + contact: 'h', + roles: ['national-manager'], + password: 'password.123' + }; + db.medic.get.withArgs('h').resolves({ parent: { _id: 'u', parent: { _id: 'z' } } }); + + await service.createMultiFacilityUser(userData); + chai.expect(db.medic.put.args).to.deep.equal([[{ + facility_id: ['x', 'y', 'z'], + contact_id: 'h', + roles: ['national-manager'], + type: 'user-settings', + _id: 'org.couchdb.user:x', + name: 'x' + }]]); + + chai.expect(db.users.put.args).to.deep.equal([[{ + facility_id: ['x', 'y', 'z'], + contact_id: 'h', + roles: ['national-manager'], + type: 'user', + _id: 'org.couchdb.user:x', + name: 'x', + password: 'password.123' + }]]); + chai.expect(roles.hasAllPermissions.args).to.deep.equal([[['national-manager'], ['can_have_multiple_places']]]); + }); + + it('succeeds without permission for single facility', async () => { + service.__set__('validateNewUsername', sinon.stub().resolves()); + sinon.stub(places, 'placesExist').resolves(); + sinon.stub(people, 'isAPerson').returns(true); + db.medic.put.resolves({ id: 'success' }); + db.users.put.resolves({ id: 'success' }); + + const userData = { + username: 'x', + place: ['x'], + contact: 'h', + roles: ['national-manager'], + password: 'password.123' + }; + db.medic.get.withArgs('h').resolves({ parent: { _id: 'u', parent: { _id: 'x' } } }); + + await service.createMultiFacilityUser(userData); + chai.expect(db.medic.put.args).to.deep.equal([[{ + facility_id: ['x'], + contact_id: 'h', + roles: ['national-manager'], + type: 'user-settings', + _id: 'org.couchdb.user:x', + name: 'x' + }]]); + + chai.expect(db.users.put.args).to.deep.equal([[{ + facility_id: ['x'], + contact_id: 'h', + roles: ['national-manager'], + type: 'user', + _id: 'org.couchdb.user:x', + name: 'x', + password: 'password.123' + }]]); + }); + }); + describe('setContactParent', () => { it('resolves contact parent in waterfall', () => { @@ -2090,7 +2406,7 @@ describe('Users service', () => { describe('updatePlace', () => { - it('updatePlace resolves place\'s contact in waterfall', () => { + it('updatePlace resolves place\'s contact in waterfall', async () => { userData = { username: 'x', password: COMPLEX_PASSWORD, @@ -2111,20 +2427,20 @@ describe('Users service', () => { _id: 'b', name: 'mickey' }); - db.medic.get.resolves(place); + getPlace.resolves(place); db.medic.put.resolves(); service.__set__('createUser', sinon.stub().resolves()); service.__set__('createUserSettings', sinon.stub().resolves()); - return service.createUser(userData).then(() => { - chai.expect(userData.contact).to.deep.equal({ _id: 'b', name: 'mickey' }); - chai.expect(userData.place.contact).to.deep.equal({ _id: 'b' }); - chai.expect(db.medic.get.callCount).to.equal(1); - chai.expect(db.medic.get.args[0]).to.deep.equal(['place_id']); - chai.expect(db.medic.put.callCount).to.equal(1); - chai.expect(db.medic.put.args[0]).to.deep.equal([{ - _id: 'place_id', _rev: 2, name: 'x', contact: { _id: 'b' }, parent: 'parent', - }]); - }); + await service.createUser(userData); + + chai.expect(userData.contact).to.deep.equal({ _id: 'b', name: 'mickey' }); + chai.expect(userData.place.contact).to.deep.equal({ _id: 'b' }); + chai.expect(dataContext.bind.calledOnceWithExactly(Place.v1.get)).to.be.true; + chai.expect(getPlace.calledOnceWithExactly(Qualifier.byUuid('place_id'))).to.be.true; + chai.expect(db.medic.put.callCount).to.equal(1); + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'place_id', _rev: 2, name: 'x', contact: { _id: 'b' }, parent: 'parent', + }]); }); it('should catch conflicts', () => { @@ -2146,7 +2462,7 @@ describe('Users service', () => { _id: 'b', name: 'mickey' }); - db.medic.get + getPlace .onCall(0).resolves(placeRev1) .onCall(1).resolves(placeRev2); db.medic.put @@ -2158,8 +2474,8 @@ describe('Users service', () => { return service.createUser(userData).then(() => { chai.expect(userData.contact).to.deep.equal({ _id: 'b', name: 'mickey' }); chai.expect(userData.place.contact).to.deep.equal({ _id: 'b' }); - chai.expect(db.medic.get.callCount).to.equal(2); - chai.expect(db.medic.get.args).to.deep.equal([['place_id'], ['place_id']]); + chai.expect(dataContext.bind.args).to.deep.equal([[Place.v1.get], [Place.v1.get]]); + chai.expect(getPlace.args).to.deep.equal([[Qualifier.byUuid('place_id')], [Qualifier.byUuid('place_id')]]); chai.expect(db.medic.put.callCount).to.equal(2); chai.expect(db.medic.put.args[0]).to.deep.equal([{ _id: 'place_id', _rev: 1, name: 'x', contact: { _id: 'b' }, parent: 'parent', @@ -2170,7 +2486,7 @@ describe('Users service', () => { }); }); - it('should retry 3 times on conflicts', () => { + it('should retry 3 times on conflicts', async () => { userData = { username: 'x', password: COMPLEX_PASSWORD, @@ -2187,7 +2503,7 @@ describe('Users service', () => { sinon.stub(places, 'getOrCreatePlace').resolves(placeRev1); sinon.stub(places, 'getPlace').resolves(placeRev1); sinon.stub(people, 'getOrCreatePerson').resolves({ _id: 'b', name: 'mickey' }); - db.medic.get + getPlace .onCall(0).resolves(placeRev1) .onCall(1).resolves(placeRev2) .onCall(2).resolves(placeRev3) @@ -2199,22 +2515,28 @@ describe('Users service', () => { .onCall(3).resolves(); service.__set__('createUser', sinon.stub().resolves()); service.__set__('createUserSettings', sinon.stub().resolves()); - return service.createUser(userData).then(() => { - chai.expect(userData.contact).to.deep.equal({ _id: 'b', name: 'mickey' }); - chai.expect(userData.place.contact).to.deep.equal({ _id: 'b' }); - chai.expect(db.medic.get.callCount).to.equal(4); - chai.expect(db.medic.get.args).to.deep.equal([['place_id'], ['place_id'], ['place_id'], ['place_id']]); - chai.expect(db.medic.put.callCount).to.equal(4); - chai.expect(db.medic.put.args[0]).to.deep.equal([{ - _id: 'place_id', _rev: 1, name: 'x', contact: { _id: 'b' }, parent: 'parent', - }]); - chai.expect(db.medic.put.args[1]).to.deep.equal([{ - _id: 'place_id', _rev: 2, name: 'x', contact: { _id: 'b' }, parent: 'parent', place_id: 'aaaa', - }]); - }); + await service.createUser(userData); + chai.expect(userData.contact).to.deep.equal({ _id: 'b', name: 'mickey' }); + chai.expect(userData.place.contact).to.deep.equal({ _id: 'b' }); + chai.expect(dataContext.bind.args).to.deep.equal([ + [Place.v1.get], [Place.v1.get], [Place.v1.get], [Place.v1.get] + ]); + chai.expect(getPlace.args).to.deep.equal([ + [Qualifier.byUuid('place_id')], + [Qualifier.byUuid('place_id')], + [Qualifier.byUuid('place_id')], + [Qualifier.byUuid('place_id')] + ]); + chai.expect(db.medic.put.callCount).to.equal(4); + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'place_id', _rev: 1, name: 'x', contact: { _id: 'b' }, parent: 'parent', + }]); + chai.expect(db.medic.put.args[1]).to.deep.equal([{ + _id: 'place_id', _rev: 2, name: 'x', contact: { _id: 'b' }, parent: 'parent', place_id: 'aaaa', + }]); }); - it('should throw after 4 conflicts', () => { + it('should throw after 4 conflicts', async () => { userData = { username: 'x', password: COMPLEX_PASSWORD, @@ -2233,7 +2555,7 @@ describe('Users service', () => { sinon.stub(places, 'getOrCreatePlace').resolves(placeRev1); sinon.stub(places, 'getPlace').resolves(placeRev1); sinon.stub(people, 'getOrCreatePerson').resolves({ _id: 'b', name: 'mickey' }); - db.medic.get + getPlace .onCall(0).resolves(placeRev1) .onCall(1).resolves(placeRev2) .onCall(2).resolves(placeRev3) @@ -2249,25 +2571,29 @@ describe('Users service', () => { .onCall(4).resolves(); service.__set__('createUser', sinon.stub().resolves()); service.__set__('createUserSettings', sinon.stub().resolves()); - return service - .createUser(userData) - .then(() => chai.expect.fail('should have thrown')) - .catch(err => { - chai.expect(err).to.equal(conflictErr); - chai.expect(db.medic.get.callCount).to.equal(4); - chai.expect(db.medic.get.args).to.deep.equal([['place_id'], ['place_id'], ['place_id'], ['place_id']]); - chai.expect(db.medic.put.callCount).to.equal(4); - chai.expect(db.medic.put.args[0]).to.deep.equal([{ - _id: 'place_id', _rev: 1, name: 'x', contact: { _id: 'b' }, parent: 'parent', - }]); - chai.expect(db.medic.put.args[1]).to.deep.equal([{ - _id: 'place_id', _rev: 2, name: 'x', contact: { _id: 'b' }, parent: 'parent', place_id: 'aaaa', - }]); - chai.expect(service.__get__('createUser').callCount).to.equal(0); - }); + + await chai.expect(service.createUser(userData)).to.be.rejectedWith(conflictErr); + + chai.expect(dataContext.bind.args).to.deep.equal([ + [Place.v1.get], [Place.v1.get], [Place.v1.get], [Place.v1.get] + ]); + chai.expect(getPlace.args).to.deep.equal([ + [Qualifier.byUuid('place_id')], + [Qualifier.byUuid('place_id')], + [Qualifier.byUuid('place_id')], + [Qualifier.byUuid('place_id')] + ]); + chai.expect(db.medic.put.callCount).to.equal(4); + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'place_id', _rev: 1, name: 'x', contact: { _id: 'b' }, parent: 'parent', + }]); + chai.expect(db.medic.put.args[1]).to.deep.equal([{ + _id: 'place_id', _rev: 2, name: 'x', contact: { _id: 'b' }, parent: 'parent', place_id: 'aaaa', + }]); + chai.expect(service.__get__('createUser').callCount).to.equal(0); }); - it('should throw any other error than a conflict', () => { + it('should throw any other error than a conflict', async () => { userData = { username: 'x', password: COMPLEX_PASSWORD, @@ -2284,23 +2610,46 @@ describe('Users service', () => { _id: 'b', name: 'mickey' }); - db.medic.get.resolves(place); + getPlace.resolves(place); db.medic.put.rejects({ status: 400, reason: 'not-a-conflict' }); service.__set__('createUser', sinon.stub().resolves()); service.__set__('createUserSettings', sinon.stub().resolves()); - return service - .createUser(userData) - .then(() => chai.expect.fail('should have thrown')) - .catch(err => { - chai.expect(err).to.deep.equal({ status: 400, reason: 'not-a-conflict' }); - chai.expect(db.medic.get.callCount).to.equal(1); - chai.expect(db.medic.get.args).to.deep.equal([['place_id']]); - chai.expect(db.medic.put.callCount).to.equal(1); - chai.expect(db.medic.put.args[0]).to.deep.equal([{ - _id: 'place_id', _rev: 1, name: 'x', contact: { _id: 'b' }, parent: 'parent', - }]); - chai.expect(service.__get__('createUser').callCount).to.equal(0); - }); + + await chai.expect(service.createUser(userData)).to.be.rejectedWith({ status: 400, reason: 'not-a-conflict' }); + + chai.expect(dataContext.bind.calledOnceWithExactly(Place.v1.get)).to.be.true; + chai.expect(getPlace.calledOnceWithExactly(Qualifier.byUuid('place_id'))).to.be.true; + chai.expect(db.medic.put.callCount).to.equal(1); + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'place_id', _rev: 1, name: 'x', contact: { _id: 'b' }, parent: 'parent', + }]); + chai.expect(service.__get__('createUser').callCount).to.equal(0); + }); + + it('should throw an error if no place is found', async () => { + userData = { + username: 'x', + password: COMPLEX_PASSWORD, + place: { name: 'y', parent: 'parent' }, + contact: { name: 'mickey' }, + type: 'national-manager' + }; + service.__set__('validateNewUsername', sinon.stub().resolves()); + + const place = { _id: 'place_id', _rev: 1, name: 'x', parent: 'parent' }; + sinon.stub(places, 'getOrCreatePlace').resolves(place); + sinon.stub(places, 'getPlace').resolves(place); + sinon.stub(people, 'getOrCreatePerson').resolves({ + _id: 'b', + name: 'mickey' + }); + getPlace.resolves(null); + + await chai.expect(service.createUser(userData)).to.be.rejectedWith(`Place not found [${place._id}].`); + + chai.expect(dataContext.bind.calledOnceWithExactly(Place.v1.get)).to.be.true; + chai.expect(getPlace.calledOnceWithExactly(Qualifier.byUuid('place_id'))).to.be.true; + chai.expect(db.medic.put.notCalled).to.be.true; }); }); @@ -2410,7 +2759,7 @@ describe('Users service', () => { }; db.medic.get.resolves({}); db.users.get.resolves({}); - sinon.stub(places, 'getPlace').resolves(); + sinon.stub(places, 'placesExist').resolves(); db.medic.put.resolves({}); db.users.put.resolves({}); return service.updateUser('paul', data, true).then(() => { @@ -2419,6 +2768,35 @@ describe('Users service', () => { }); }); + it('succeeds if places are defined and found', () => { + const data = { + place: ['x', 'y', 'z'] + }; + db.medic.get.resolves({ roles: ['a'] }); + db.users.get.resolves({ roles: ['a'] }); + sinon.stub(places, 'placesExist').resolves(); + sinon.stub(roles, 'hasAllPermissions').returns(true); + db.medic.put.resolves({}); + db.users.put.resolves({}); + return service.updateUser('paul', data, true).then(() => { + chai.expect(db.medic.put.args).to.deep.equal([[{ + _id: 'org.couchdb.user:paul', + facility_id: [ 'x', 'y', 'z' ], + name: 'paul', + type: 'user-settings', + roles: ['a'] + }]]); + + chai.expect(db.users.put.args).to.deep.equal([[{ + _id: 'org.couchdb.user:paul', + facility_id: [ 'x', 'y', 'z' ], + name: 'paul', + type: 'user', + roles: ['a'] + }]]); + }); + }); + it('roles param updates roles on user and user-settings doc', () => { const data = { roles: [ 'rebel' ] @@ -2505,16 +2883,17 @@ describe('Users service', () => { const data = { place: 'paris' }; - db.users.get.resolves({ facility_id: 'maine' }); - db.medic.get.resolves({ facility_id: 'maine' }); - sinon.stub(places, 'getPlace').resolves(); + db.users.get.resolves({ facility_id: 'maine', contact_id: 'june' }); + db.medic.get.resolves({ facility_id: 'maine', contact_id: 'june' }); + sinon.stub(places, 'placesExist').resolves(); + sinon.stub(roles, 'hasAllPermissions').returns(true); db.medic.put.resolves({}); db.users.put.resolves({}); return service.updateUser('paul', data, true).then(() => { chai.expect(db.medic.put.callCount).to.equal(1); - chai.expect(db.medic.put.args[0][0].facility_id).to.equal('paris'); + chai.expect(db.medic.put.args[0][0]).to.deep.include({ facility_id: ['paris'], contact_id: 'june' }); chai.expect(db.users.put.callCount).to.equal(1); - chai.expect(db.users.put.args[0][0].facility_id).to.equal('paris'); + chai.expect(db.users.put.args[0][0]).to.deep.include({ facility_id: ['paris'], contact_id: 'june' }); }); }); @@ -2526,7 +2905,7 @@ describe('Users service', () => { db.users.get.resolves({ facility_id: 'maine', contact_id: 1, - roles: ['mm-online'] + roles: ['rambler', 'mm-online'] }); db.medic.get.resolves({ facility_id: 'maine', @@ -2534,6 +2913,8 @@ describe('Users service', () => { }); db.medic.put.resolves({}); db.users.put.resolves({}); + sinon.stub(roles, 'isOffline').withArgs(['rambler']).returns(false); + return service.updateUser('paul', data, true).then(() => { chai.expect(db.medic.put.callCount).to.equal(1); const settings = db.medic.put.args[0][0]; @@ -2563,14 +2944,14 @@ describe('Users service', () => { phone: '123', known: false }); - sinon.stub(places, 'getPlace').resolves(); + sinon.stub(places, 'placesExist').resolves(); db.medic.put.resolves({}); db.users.put.resolves({}); sinon.stub(roles, 'isOffline').withArgs(['rambler']).returns(false); return service.updateUser('paul', data, true).then(() => { chai.expect(db.medic.put.callCount).to.equal(1); const settings = db.medic.put.args[0][0]; - chai.expect(settings.facility_id).to.equal('el paso'); + chai.expect(settings.facility_id).to.deep.equal(['el paso']); chai.expect(settings.phone).to.equal('123'); chai.expect(settings.known).to.equal(false); chai.expect(settings.type).to.equal('user-settings'); @@ -2578,7 +2959,7 @@ describe('Users service', () => { chai.expect(db.users.put.callCount).to.equal(1); const user = db.users.put.args[0][0]; - chai.expect(user.facility_id).to.equal('el paso'); + chai.expect(user.facility_id).to.deep.equal(['el paso']); chai.expect(user.roles).to.deep.equal(['rambler', 'mm-online']); chai.expect(user.shoes).to.equal('dusty boots'); chai.expect(user.password).to.equal(COMPLEX_PASSWORD); @@ -2603,7 +2984,7 @@ describe('Users service', () => { known: false }); config.get.returns({ chp: { offline: true } }); - sinon.stub(places, 'getPlace').resolves(); + sinon.stub(places, 'placesExist').resolves(); db.medic.put.resolves({}); db.users.put.resolves({}); return service.updateUser('paul', data, true).then(() => { @@ -3408,8 +3789,10 @@ describe('Users service', () => { it('should parse csv, trim spaces and not split strings with commas inside', async () => { const csv = 'password,username,type,place,contact.name,contact.phone,contact.address\n' + - 'Secret1234,mary,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,mary,2652527222,"1 King ST, Kent Town, 55555"\n' + - 'Secret5678, peter ,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,Peter, 2652279,"15 King ST, Kent Town, 55555 "'; + // eslint-disable-next-line max-len + 'Secret1234,mary,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,mary,2652527222,"1 King ST, Kent Town, 55555"\n' + + // eslint-disable-next-line max-len + 'Secret5678, peter ,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,Peter, 2652279,"15 King ST, Kent Town, 55555 "'; db.medicLogs.get.resolves({ progress: {} }); db.medicLogs.put.resolves({}); @@ -3436,10 +3819,10 @@ describe('Users service', () => { it('should parse csv, trim spaces and not split strings with commas inside', async () => { /* eslint-disable max-len */ const csv = 'password,username,type,place,token_login,contact.name,contact.phone,contact.address\n' + - ',mary,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,TRUE,mary,2652527222,"1 King ST, Kent Town, 55555"\n' + - 'Secret9876,devi,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,truthy mistake,devi,265252,"12 King ST, Kent Town, 55555"\n' + - 'Secret1144,jeff,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,,jeff,26599102,"27 King ST, Kent Town, 55555"\n' + - 'Secret5678, peter ,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,FALSE,Peter, 2652279,"15 King ST, Kent Town, 55555 "'; + ',mary,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,TRUE,mary,2652527222,"1 King ST, Kent Town, 55555"\n' + + 'Secret9876,devi,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,truthy mistake,devi,265252,"12 King ST, Kent Town, 55555"\n' + + 'Secret1144,jeff,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,,jeff,26599102,"27 King ST, Kent Town, 55555"\n' + + 'Secret5678, peter ,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,FALSE,Peter, 2652279,"15 King ST, Kent Town, 55555 "'; /* eslint-enable max-len */ db.medicLogs.get.resolves({ progress: {} }); db.medicLogs.put.resolves({}); @@ -3494,7 +3877,8 @@ describe('Users service', () => { it('should ignore empty header columns', async () => { const csv = 'password,username,type,,contact.name,,contact.address\n' + - 'Secret1234,mary,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,mary,2652527222,"1 King ST, Kent Town, 55555"\n'; + // eslint-disable-next-line max-len + 'Secret1234,mary,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,mary,2652527222,"1 King ST, Kent Town, 55555"\n'; db.medicLogs.get.resolves({ progress: {} }); db.medicLogs.put.resolves({}); @@ -3512,7 +3896,7 @@ describe('Users service', () => { it('should keep attributes if there is not value', async () => { const csv = 'password,username,type,place,contact.name,contact.phone,contact.address\n' + - 'Secret1234,mary,person,,mary, ,"1 King ST, Kent Town, 55555"\n'; + 'Secret1234,mary,person,,mary, ,"1 King ST, Kent Town, 55555"\n'; db.medicLogs.get.resolves({ progress: {} }); db.medicLogs.put.resolves({}); @@ -3531,9 +3915,9 @@ describe('Users service', () => { it('should parse csv with deep object structure', async () => { const csv = 'password,username,type,place,contact.name,contact.address.country' + - ',contact.address.city.street,contact.address.city.name\n' + - 'Secret1234,mary,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,mary,US,"5th ST", Kent Town\n' + - 'Secret555,peter,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,Peter,CA,,Victoria Town\n'; + ',contact.address.city.street,contact.address.city.name\n' + + 'Secret1234,mary,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,mary,US,"5th ST", Kent Town\n' + + 'Secret555,peter,person,498a394e-f98b-4e48-8c50-f12aeb018fcc,Peter,CA,,Victoria Town\n'; db.medicLogs.get.resolves({ progress: {} }); db.medicLogs.put.resolves({}); @@ -3577,9 +3961,9 @@ describe('Users service', () => { it('should parse csv with special characters', async () => { const csv = 'password,username,type,place,contact.name,contact.notes\n' + - 'Secret1234,mary,person,498a394e-f98,Mary\'s name!,"#1 @ "King ST"$^&%~`=}{][:;.> { contact: { name: 'Peter', notes: 'ce fût une belle saison, le maïs sera prêt à partir' + - ' de l’été c’est-à-dire dès demain, d’où l’invaitation' + ' de l’été c’est-à-dire dès demain, d’où l’invaitation' } } ]); @@ -3611,9 +3995,11 @@ describe('Users service', () => { it('should ignore excluded header columns', async () => { const csv = 'password,username,type,place,contact.meta:excluded,contact.name,contact.notes\n' + - 'Secret1234,mary,person,498a394e-f98,excluded column,Mary\'s name!,"#1 @ "King ST"$^&%~`=}{][:;.> { contact: { name: 'Peter', notes: 'ce fût une belle saison, le maïs sera prêt à partir' + - ' de l’été c’est-à-dire dès demain, d’où l’invaitation' + ' de l’été c’est-à-dire dès demain, d’où l’invaitation' } } ]); diff --git a/tests/e2e/default/analytics/analytics.wdio-spec.js b/tests/e2e/default/analytics/analytics.wdio-spec.js index 27b1bd99b46..04199307b22 100644 --- a/tests/e2e/default/analytics/analytics.wdio-spec.js +++ b/tests/e2e/default/analytics/analytics.wdio-spec.js @@ -106,7 +106,7 @@ describe('Targets', () => { await updateSettings(settings); await analyticsPage.goToTargets(); - const { errorMessage, url, username, errorStack } = await analyticsPage.getErrorLog(); + const { errorMessage, url, username, errorStack } = await commonPage.getErrorLog(); expect(username).to.equal(chw.username); expect(url).to.equal('localhost'); diff --git a/tests/e2e/default/contacts/config/contact-summary-error-config.js b/tests/e2e/default/contacts/config/contact-summary-error-config.js new file mode 100644 index 00000000000..3a00db29691 --- /dev/null +++ b/tests/e2e/default/contacts/config/contact-summary-error-config.js @@ -0,0 +1,18 @@ +const cards = []; +const context = {}; +const patientIDs = {}; + +const fields = [ + { + appliesToType: ['person'], + label: 'patient_id', + value: patientIDs.contact.patient_id, + width: 3, + } +]; + +module.exports = { + cards, + fields, + context +}; diff --git a/tests/e2e/default/contacts/contact-details.wdio-spec.js b/tests/e2e/default/contacts/contact-details.wdio-spec.js index ea18a39e5ca..e032a6c5b7c 100644 --- a/tests/e2e/default/contacts/contact-details.wdio-spec.js +++ b/tests/e2e/default/contacts/contact-details.wdio-spec.js @@ -1,6 +1,10 @@ const commonElements = require('@page-objects/default/common/common.wdio.page.js'); const contactPage = require('@page-objects/default/contacts/contacts.wdio.page.js'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); const utils = require('@utils'); +const path = require('path'); +const constants = require('@constants'); +const chtConfUtils = require('@utils/cht-conf'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const userFactory = require('@factories/cht/users/users'); @@ -34,8 +38,8 @@ describe('Contact details page', () => { const user = userFactory.build({ username: 'offlineuser', roles: [role] }); const patient = personFactory.build({ parent: { _id: user.place._id, parent: { _id: parent._id } } }); - const reports = Array - .from({ length: 60 }) + const newReports = Array + .from({ length: 40 }) .map(() => reportFactory.report().build( { form: 'pregnancy_danger_sign' }, { @@ -44,7 +48,18 @@ describe('Contact details page', () => { fields: { t_danger_signs_referral_follow_up: 'yes' }, } )); - + const oldReportDate = new Date(); + oldReportDate.setMonth(new Date().setMonth() - 4); + const oldReports = Array + .from({ length: 20 }) + .map(() => reportFactory.report().build( + { form: 'pregnancy', reported_date: oldReportDate }, + { + patient, + submitter: user.contact, + fields: { t_danger_signs_referral_follow_up: 'yes' }, + } + )); const pregnancyReport = pregnancyFactory.build({ fields: { patient_id: patient._id, @@ -52,6 +67,7 @@ describe('Contact details page', () => { patient_name: patient.name, }, }); + const reports = [...newReports, ...oldReports, pregnancyReport]; const updatePermissions = async (role, addPermissions, removePermissions = []) => { const settings = await utils.getSettings(); @@ -70,7 +86,7 @@ describe('Contact details page', () => { await updatePermissions(role, permissions); await utils.saveDocs([parent, patient]); - await utils.saveDocs([...reports, pregnancyReport]); + await utils.saveDocs(reports); await utils.createUsers([user]); @@ -78,6 +94,10 @@ describe('Contact details page', () => { await commonElements.waitForPageLoaded(); }); + after(async () => { + await utils.revertSettings(true); + }); + it('should show reports and tasks when permissions are enabled', async () => { await commonElements.goToPeople(patient._id, true); expect(await (await contactPage.contactCard()).getText()).to.equal(patient.name); @@ -86,8 +106,11 @@ describe('Contact details page', () => { expect(await (await contactPage.rhsReportListElement()).isDisplayed()).to.equal(true); expect(await (await contactPage.rhsTaskListElement()).isDisplayed()).to.equal(true); - expect((await contactPage.getAllRHSReportsNames()).length).to.equal(DOCS_DISPLAY_LIMIT); + expect((await contactPage.getAllRHSReportsNames()).length).to.equal(41); expect((await contactPage.getAllRHSTaskNames()).length).to.deep.equal(DOCS_DISPLAY_LIMIT); + + await contactPage.filterReportViewAll(); + expect((await contactPage.getAllRHSReportsNames()).length).to.equal(DOCS_DISPLAY_LIMIT); }); it( @@ -125,8 +148,46 @@ describe('Contact details page', () => { expect(await (await contactPage.rhsReportListElement()).isDisplayed()).to.equal(true); expect(await (await contactPage.rhsTaskListElement()).isDisplayed()).to.equal(false); - expect((await contactPage.getAllRHSReportsNames()).length).to.equal(DOCS_DISPLAY_LIMIT); + expect((await contactPage.getAllRHSReportsNames()).length).to.equal(41); }); }); + describe('Contact summary error', () => { + const places = placeFactory.generateHierarchy(); + const clinic = places.get('clinic'); + + const patient = personFactory.build({ + name: 'Patient', + phone: '+50683444444', + parent: { _id: clinic._id, parent: clinic.parent } + }); + + before(async () => { + await chtConfUtils.initializeConfigDir(); + const contactSummaryFile = path.join(__dirname, 'config/contact-summary-error-config.js'); + + const { contactSummary } = await chtConfUtils.compileNoolsConfig({ contactSummary: contactSummaryFile }); + await utils.updateSettings({ contact_summary: contactSummary }, true); + + await utils.saveDocs([...places.values(), patient]); + + await loginPage.cookieLogin(); + await (await commonPage.goToPeople(patient._id)); + }); + + after(async () => { + await utils.revertSettings(true); + }); + + it('should show error log for bad config', async () => { + const { errorMessage, url, username, errorStack } = await commonPage.getErrorLog(); + + expect(username).to.equal(constants.USERNAME); + expect(url).to.equal(constants.API_HOST); + expect(errorMessage).to.equal('Error fetching people'); + expect(await (await errorStack.isDisplayed())).to.be.true; + expect(await (await errorStack.getText())).to + .include('Error: Configuration error'); + }); + }); }); diff --git a/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js new file mode 100644 index 00000000000..ebed922623f --- /dev/null +++ b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js @@ -0,0 +1,73 @@ +const utils = require('@utils'); +const usersAdminPage = require('@page-objects/default/users/user.wdio.page'); +const adminPage = require('@page-objects/default/admin/admin.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); +const contactPage = require('@page-objects/default/contacts/contacts.wdio.page'); +const loginPage = require('@page-objects/default/login/login.wdio.page'); + +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); + +const offlineUserRole = 'chw'; +const username = 'jackuser'; +const password = 'Jacktest@123'; +const places = placeFactory.generateHierarchy(); +const districtHospital = places.get('district_hospital'); +const districtHospital2 = placeFactory.place().build({ + name: 'district_hospital', + type: 'district_hospital', +}); + +const person = personFactory.build({ + parent: { + _id: districtHospital._id, + parent: districtHospital.parent, + }, + roles: [offlineUserRole], +}); + +const docs = [...places.values(), person, districtHospital2]; + +describe('User Test Cases ->', () => { + before(async () => { + const settings = await utils.getSettings(); + const permissions = { + ...settings.permissions, + can_have_multiple_places: [offlineUserRole], + }; + await utils.updateSettings({ permissions }, true); + await utils.saveDocs(docs); + await loginPage.cookieLogin(); + }); + + beforeEach(async () => { + if (await usersAdminPage.addUserDialog().isDisplayed()) { + await usersAdminPage.closeAddUserDialog(); + } + await usersAdminPage.goToAdminUser(); + await usersAdminPage.openAddUserDialog(); + }); + + describe('Creating Users ->', () => { + after(async () => await utils.deleteUsers([{ username: username }])); + + it('should add user with multiple places with permission', async () => { + await usersAdminPage.inputAddUserFields( + username, + 'Jack', + offlineUserRole, + [districtHospital.name, districtHospital2.name], + person.name, + password + ); + await usersAdminPage.saveUser(); + await adminPage.logout(); + await loginPage.login({ username, password }); + await commonPage.goToPeople(); + await contactPage.selectLHSRowByText(districtHospital2.name); + await commonPage.openMoreOptionsMenu(); + + expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.false; + }); + }); +}); diff --git a/tests/e2e/default/contacts/fab-actionbar.wdio-spec.js b/tests/e2e/default/contacts/fab-actionbar.wdio-spec.js index c44ceb984fb..5cd05f9fbf1 100644 --- a/tests/e2e/default/contacts/fab-actionbar.wdio-spec.js +++ b/tests/e2e/default/contacts/fab-actionbar.wdio-spec.js @@ -5,6 +5,7 @@ const utils = require('@utils'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const commonElements = require('@page-objects/default/common/common.wdio.page'); const { genericForm } = require('@page-objects/default/contacts/contacts.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); const places = placeFactory.generateHierarchy(); const healthCenter = places.get('health_center'); @@ -18,6 +19,8 @@ describe('FAB + Actionbar', () => { }); afterEach(async () => { + await browser.refresh(); + await commonPage.waitForPageLoaded(); await utils.revertSettings(false); }); diff --git a/tests/e2e/default/contacts/person-under-area.wdio-spec.js b/tests/e2e/default/contacts/person-under-area.wdio-spec.js index a0c0dbbaf1e..3e634a0cc60 100644 --- a/tests/e2e/default/contacts/person-under-area.wdio-spec.js +++ b/tests/e2e/default/contacts/person-under-area.wdio-spec.js @@ -46,7 +46,7 @@ const person2 = personFactory.build( const docs = [...places.values(), healthCenter2, person1, person2]; describe('Create Person Under Area', () => { - beforeEach(async () => { + before(async () => { await utils.saveDocs(docs); await loginPage.cookieLogin(); }); diff --git a/tests/e2e/default/db/db-sync.wdio-spec.js b/tests/e2e/default/db/db-sync.wdio-spec.js index f6b93d7ffe2..e49f8888c6f 100644 --- a/tests/e2e/default/db/db-sync.wdio-spec.js +++ b/tests/e2e/default/db/db-sync.wdio-spec.js @@ -13,17 +13,20 @@ describe('db-sync', () => { const restrictedUserName = uuid(); const restrictedPass = uuid(); const restrictedFacilityId = uuid(); + const restrictedFacilityId2 = uuid(); const restrictedContactId = uuid(); const patientId = uuid(); + const patientId2 = uuid(); const report1 = uuid(); const report2 = uuid(); const report3 = uuid(); + const report4 = uuid(); const restrictedUser = { _id: `org.couchdb.user:${restrictedUserName}`, type: 'user', name: restrictedUserName, password: restrictedPass, - facility_id: restrictedFacilityId, + facility_id: [restrictedFacilityId, restrictedFacilityId2], roles: ['chw'] }; @@ -79,6 +82,18 @@ describe('db-sync', () => { _id: 'this-does-not-matter' } } + }, + { + _id: patientId2, + name: 'A patient', + reported_date: Date.now(), + type: 'person', + parent: { + _id: restrictedFacilityId2, + parent: { + _id: 'this-does-not-matter' + } + } } ]; @@ -146,6 +161,27 @@ describe('db-sync', () => { some: 'data', } }, + { + _id: report4, + form: 'form_type_3', + type: 'data_record', + content_type: 'xml', + reported_date: Date.now(), + contact: { + _id: restrictedContactId, + parent: { + _id: restrictedFacilityId, + parent: { + _id: 'this-does-not-matter' + } + } + }, + fields: { + patient_id: patientId2, + patient_name: 'A patient', + some: 'data', + } + }, ]; const getServerRevs = async (docIds) => { @@ -169,6 +205,8 @@ describe('db-sync', () => { await chtDbUtils.updateDoc(report1, { extra: '1' }); await chtDbUtils.updateDoc(report2, { extra: '2' }); await chtDbUtils.updateDoc(patientId, { extra: '3' }); + await chtDbUtils.updateDoc(report4, { extra: '2' }); + await chtDbUtils.updateDoc(patientId2, { extra: '3' }); const newReport = { ...initialReports[0], _id: uuid(), extra: '4' }; const { rev } = await chtDbUtils.createDoc(newReport); newReport._rev = rev; @@ -181,12 +219,16 @@ describe('db-sync', () => { updatedReport2, updatedPatient, updatedNewReport, - ] = await utils.getDocs([report1, report2, patientId, newReport._id]); + updatedReport4, + updatedPatient2, + ] = await utils.getDocs([report1, report2, patientId, newReport._id, report4, patientId2]); chai.expect(updatedReport1.extra).to.equal('1'); chai.expect(updatedReport2.extra).to.equal('2'); chai.expect(updatedPatient.extra).to.equal('3'); chai.expect(updatedNewReport).to.deep.equal(newReport); + chai.expect(updatedReport4.extra).to.equal('2'); + chai.expect(updatedPatient2.extra).to.equal('3'); }); it('should not filter deletes', async () => { @@ -282,7 +324,7 @@ describe('db-sync', () => { it('should replicate meta db down', async () => { await browser.refresh(); // meta databases sync every 30 minutes await commonElements.sync(); - expect(await reportsPage.getUnreadCount()).to.equal('2'); + expect(await reportsPage.getUnreadCount()).to.equal('3'); const readReport = { _id: `read:report:${report2}` }; await utils.saveMetaDocs(restrictedUserName, [readReport]); @@ -294,7 +336,7 @@ describe('db-sync', () => { await commonElements.goToReports(); await (await reportsPage.reportList()).waitForDisplayed(); - await browser.waitUntil(async () => await reportsPage.getUnreadCount() === '1'); + await browser.waitUntil(async () => await reportsPage.getUnreadCount() === '2'); }); }); }); diff --git a/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js b/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js index 6394eedf1c6..f2f5335594b 100644 --- a/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js +++ b/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js @@ -27,7 +27,9 @@ describe('Submit Photo Upload form', () => { it('submit and edit (no changes)', async () => { const reportId = await reportsPage.getCurrentReportId(); const initialReport = await utils.getDoc(reportId); - expect(Object.keys(initialReport._attachments)).to.deep.equal(['user-file/photo-upload/my_photo']); + const attachmentNames = Object.keys(initialReport._attachments); + expect(attachmentNames).to.have.lengthOf(1); + expect(attachmentNames[0]).to.match(/^user-file-photo-for-upload-form-\d\d?_\d\d?_\d\d?\.png$/); await reportsPage.openReport(reportId); await reportsPage.editReport(); @@ -44,7 +46,9 @@ describe('Submit Photo Upload form', () => { it('submit and edit (with changes)', async () => { const reportId = await reportsPage.getCurrentReportId(); const initialReport = await utils.getDoc(reportId); - expect(Object.keys(initialReport._attachments)).to.deep.equal(['user-file/photo-upload/my_photo']); + const attachmentNames = Object.keys(initialReport._attachments); + expect(attachmentNames).to.have.lengthOf(1); + expect(attachmentNames[0]).to.match(/^user-file-photo-for-upload-form-\d\d?_\d\d?_\d\d?\.png$/); await reportsPage.openReport(reportId); await reportsPage.editReport(); diff --git a/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js b/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js index 0e88a620ff1..21457b75abc 100644 --- a/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js +++ b/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js @@ -68,7 +68,7 @@ describe('More Options Menu - Offline User', () => { afterEach(async () => await commonPage.goToBase()); - describe('all permissions enabled', () => { + describe.skip('all permissions enabled', () => { it('- Message tab', async () => { await commonPage.goToMessages(); await sms.sendSms('testing', contact.phone); diff --git a/tests/e2e/default/reports/breadcrumbs.wdio-spec.js b/tests/e2e/default/reports/breadcrumbs.wdio-spec.js index ac086d401b3..403c6a4f60f 100644 --- a/tests/e2e/default/reports/breadcrumbs.wdio-spec.js +++ b/tests/e2e/default/reports/breadcrumbs.wdio-spec.js @@ -1,4 +1,5 @@ const moment = require('moment'); +const uuid = require('uuid').v4; const utils = require('@utils'); const commonElements = require('@page-objects/default/common/common.wdio.page'); @@ -12,66 +13,96 @@ const reportFactory = require('@factories/cht/reports/generic-report'); describe('Reports tab breadcrumbs', () => { const places = placeFactory.generateHierarchy(); const clinic = places.get('clinic'); - const health_center = places.get('health_center'); - const district_hospital = places.get('district_hospital'); - const contact = { + const healthCenter1 = places.get('health_center'); + const districtHospital = places.get('district_hospital'); + const healthCenter2 = placeFactory.place().build({ + name: 'health_center_2', + type: 'health_center', + parent: { _id: districtHospital._id }, + }); + const offlineUserContact = { _id: 'fixture:user:user1', name: 'OfflineUser', phone: '+12068881234', - place: health_center._id, + place: healthCenter1._id, type: 'person', - parent: { - _id: health_center._id, - parent: health_center.parent - }, + parent: { _id: healthCenter1._id, parent: healthCenter1.parent }, }; - const contact2 = { + const onlineUserContact = { _id: 'fixture:user:user2', name: 'OnlineUser', phone: '+12068881235', - place: district_hospital._id, + place: districtHospital._id, type: 'person', - parent: { - _id: district_hospital._id, - }, + parent: { _id: districtHospital._id }, }; const offlineUser = userFactory.build({ username: 'offlineuser_breadcrumbs', isOffline: true, - place: health_center._id, - contact: contact._id, + place: healthCenter1._id, + contact: offlineUserContact._id, }); const onlineUser = userFactory.build({ username: 'onlineuser_breadcrumbs', roles: [ 'program_officer' ], - place: district_hospital._id, - contact: contact2._id, + place: districtHospital._id, + contact: onlineUserContact._id, }); const patient = personFactory.build({ _id: 'patient1', - parent: { _id: clinic._id, parent: { _id: health_center._id, parent: { _id: district_hospital._id }}} + parent: { _id: clinic._id, parent: { _id: healthCenter1._id, parent: { _id: districtHospital._id } } }, + }); + const contactWithManyPlaces = personFactory.build({ + parent: { _id: healthCenter1._id, parent: { _id: districtHospital._id } }, }); + const userWithManyPlaces = { + _id: 'org.couchdb.user:offline_many_facilities', + language: 'en', + known: true, + type: 'user-settings', + roles: [ 'chw' ], + facility_id: [ healthCenter1._id, healthCenter2._id ], + contact_id: contactWithManyPlaces._id, + name: 'offline_many_facilities' + }; + const userWithManyPlacesPass = uuid(); const today = moment(); - const reports = [ - reportFactory - .report() - .build( - { - form: 'P', - reported_date: moment([today.year(), today.month(), 1, 23, 30]).subtract(4, 'month').valueOf(), - patient_id: 'patient1', - }, - { - patient, - submitter: offlineUser.contact, - fields: { lmp_date: 'Feb 3, 2022', patient_id: 'patient1' }, - }, - ), - ]; + const report1 = reportFactory + .report() + .build( + { + form: 'P', + reported_date: moment([today.year(), today.month(), 1, 23, 30]).subtract(4, 'month').valueOf(), + patient_id: 'patient1', + }, + { patient, submitter: offlineUser.contact, fields: { lmp_date: 'Feb 3, 2022', patient_id: 'patient1' } }, + ); + const report2 = reportFactory + .report() + .build( + { + form: 'P', + reported_date: moment([today.year(), today.month(), 1, 23, 30]).subtract(4, 'month').valueOf(), + patient_id: 'patient1', + }, + { + patient, + submitter: userWithManyPlaces.contact_id, + fields: { lmp_date: 'Feb 3, 2022', patient_id: 'patient1' }, + }, + ); before(async () => { - await utils.saveDocs([ ...places.values(), contact, contact2, patient, ...reports ]); + await utils.saveDocs([ + ...places.values(), healthCenter2, offlineUserContact, onlineUserContact, + contactWithManyPlaces, userWithManyPlaces, patient, report1, report2, + ]); + await utils.request({ + path: `/_users/${userWithManyPlaces._id}`, + method: 'PUT', + body: { ...userWithManyPlaces, password: userWithManyPlacesPass, type: 'user' }, + }); await utils.createUsers([ onlineUser, offlineUser ]); }); @@ -84,8 +115,21 @@ describe('Reports tab breadcrumbs', () => { await (await reportsPage.firstReport()).waitForDisplayed(); await commonElements.waitForPageLoaded(); - const reportLineages = await reportsPage.reportsListDetails(); - const expectedLineage = clinic.name.concat(health_center.name, district_hospital.name); + const reportLineages = await reportsPage.reportsListDetails(); + const expectedLineage = clinic.name.concat(healthCenter1.name, districtHospital.name); + + expect(reportLineages[0].lineage).to.equal(expectedLineage); + }); + + it('should not remove facility from breadcrumbs when offline user has many facilities associated', async () => { + await loginPage.login({ password: userWithManyPlacesPass, username: userWithManyPlaces.name }); + await commonElements.waitForPageLoaded(); + await commonElements.goToReports(); + await (await reportsPage.firstReport()).waitForDisplayed(); + await commonElements.waitForPageLoaded(); + + const reportLineages = await reportsPage.reportsListDetails(); + const expectedLineage = clinic.name.concat(healthCenter1.name); expect(reportLineages[0].lineage).to.equal(expectedLineage); }); @@ -97,7 +141,7 @@ describe('Reports tab breadcrumbs', () => { await (await reportsPage.firstReport()).waitForDisplayed(); await commonElements.waitForPageLoaded(); - const reportLineages = await reportsPage.reportsListDetails(); + const reportLineages = await reportsPage.reportsListDetails(); const expectedLineage = clinic.name; expect(reportLineages[0].lineage).to.equal(expectedLineage); diff --git a/tests/e2e/default/sms/export.wdio-spec.js b/tests/e2e/default/sms/export.wdio-spec.js index a4d8bf301e2..0e55e8f364b 100644 --- a/tests/e2e/default/sms/export.wdio-spec.js +++ b/tests/e2e/default/sms/export.wdio-spec.js @@ -19,7 +19,7 @@ describe('Export Messages', () => { }); const today = moment(); - beforeEach(async () => { + before(async () => { await fileDownloadUtils.setupDownloadFolder(); await utils.saveDocs([ ...places.values(), patient ]); await utils.createUsers([ onlineUser ]); diff --git a/tests/e2e/default/sms/messages-sender-data.wdio-spec.js b/tests/e2e/default/sms/messages-sender-data.wdio-spec.js index 7182669c193..393b2050c8f 100644 --- a/tests/e2e/default/sms/messages-sender-data.wdio-spec.js +++ b/tests/e2e/default/sms/messages-sender-data.wdio-spec.js @@ -1,3 +1,4 @@ +const uuid = require('uuid').v4; const utils = require('@utils'); const commonElements = require('@page-objects/default/common/common.wdio.page'); const loginPage = require('@page-objects/default/login/login.wdio.page'); @@ -10,50 +11,72 @@ const contactsPage = require('@page-objects/default/contacts/contacts.wdio.page' describe('Message Tab - Sender Data', () => { const places = placeFactory.generateHierarchy(); const clinic = places.get('clinic'); - const health_center = places.get('health_center'); - const district_hospital = places.get('district_hospital'); - const contact = { + const healthCenter1 = places.get('health_center'); + const districtHospital = places.get('district_hospital'); + const healthCenter2 = placeFactory.place().build({ + name: 'health_center_2', + type: 'health_center', + parent: { _id: districtHospital._id }, + }); + const offlineUserContact = { _id: 'fixture:user:user1', name: 'OfflineUser', phone: '+12068881234', - place: health_center._id, + place: healthCenter1._id, type: 'person', - parent: { - _id: health_center._id, - parent: health_center.parent - }, + parent: { _id: healthCenter1._id, parent: healthCenter1.parent }, }; - const contact2 = { + const onlineUserContact = { _id: 'fixture:user:user2', name: 'OnlineUser', phone: '+12068881235', - place: district_hospital._id, + place: districtHospital._id, type: 'person', - parent: { - _id: district_hospital._id, - }, + parent: { _id: districtHospital._id }, }; const offlineUser = userFactory.build({ username: 'offlineuser_messages', isOffline: true, - place: health_center._id, - contact: contact._id, + place: healthCenter1._id, + contact: offlineUserContact._id, }); const onlineUser = userFactory.build({ username: 'onlineuser_messages', roles: [ 'program_officer' ], - place: district_hospital._id, - contact: contact2._id, + place: districtHospital._id, + contact: onlineUserContact._id, }); const patient = personFactory.build({ _id: 'patient1', phone: '+14152223344', name: 'patient1', - parent: { _id: clinic._id, parent: { _id: health_center._id, parent: { _id: district_hospital._id }}} + parent: { _id: clinic._id, parent: { _id: healthCenter1._id, parent: { _id: districtHospital._id }}} + }); + const contactWithManyPlaces = personFactory.build({ + parent: { _id: healthCenter1._id, parent: { _id: districtHospital._id } }, }); + const userWithManyPlaces = { + _id: 'org.couchdb.user:offline_many_facilities', + language: 'en', + known: true, + type: 'user-settings', + roles: [ 'chw' ], + facility_id: [ healthCenter1._id, healthCenter2._id ], + contact_id: contactWithManyPlaces._id, + name: 'offline_many_facilities' + }; + const userWithManyPlacesPass = uuid(); before(async () => { - await utils.saveDocs([ ...places.values(), contact, contact2, patient ]); + await utils.saveDocs([ + ...places.values(), healthCenter2, offlineUserContact, onlineUserContact, patient, + contactWithManyPlaces, userWithManyPlaces, + ]); + await utils.request({ + path: `/_users/${userWithManyPlaces._id}`, + method: 'PUT', + body: { ...userWithManyPlaces, password: userWithManyPlacesPass, type: 'user' }, + }); await utils.createUsers([ onlineUser, offlineUser ]); }); @@ -68,11 +91,22 @@ describe('Message Tab - Sender Data', () => { await messagesPage.sendMessage('Contact', patient.phone, patient.name); const { lineage} = await messagesPage.getMessageInListDetails(patient._id); - const expectedLineage = clinic.name.concat(health_center.name, district_hospital.name); + const expectedLineage = clinic.name.concat(healthCenter1.name, districtHospital.name); expect(lineage).to.equal(expectedLineage); }); + it('should not remove facility from breadcrumbs when offline user has many facilities associated', async () => { + await loginPage.login({ password: userWithManyPlacesPass, username: userWithManyPlaces.name }); + await commonElements.waitForPageLoaded(); + await commonElements.goToMessages(); + await messagesPage.sendMessage('Contact', patient.phone, patient.name); + + const { lineage} = await messagesPage.getMessageInListDetails(patient._id); + const expectedLineage = clinic.name.concat(healthCenter1.name); + + expect(lineage).to.equal(expectedLineage); + }); it('should display messages with updated breadcrumbs for offline user', async () => { await loginPage.login(offlineUser); diff --git a/tests/e2e/default/targets/target-aggregates.wdio-spec.js b/tests/e2e/default/targets/target-aggregates.wdio-spec.js index 9df942bd284..9cba6ad7832 100644 --- a/tests/e2e/default/targets/target-aggregates.wdio-spec.js +++ b/tests/e2e/default/targets/target-aggregates.wdio-spec.js @@ -1,12 +1,14 @@ +const moment = require('moment'); +const _ = require('lodash'); +const fs = require('fs'); +const uuid = require('uuid').v4; + const utils = require('@utils'); const commonPage = require('@page-objects/default/common/common.wdio.page'); const analyticsPage = require('@page-objects/default/analytics/analytics.wdio.page'); const targetAggregatesPage = require('@page-objects/default/targets/target-aggregates.wdio.page'); const contactsPage = require('@page-objects/default/contacts/contacts.wdio.page.js'); const loginPage = require('@page-objects/default/login/login.wdio.page'); -const moment = require('moment'); -const _ = require('lodash'); -const fs = require('fs'); const placeFactory = require('@factories/cht/contacts/place'); const userFactory = require('@factories/cht/users/users'); const personFactory = require('@factories/cht/contacts/person'); @@ -139,14 +141,31 @@ describe('Target aggregates', () => { // next month targets, in case the reporting period switches mid-test moment().date(10).add(1, 'month').format('YYYY-MM'), ]; + const contactWithManyPlaces = personFactory.build({ + parent: { _id: parentPlace._id, parent: { _id: parentPlace._id } }, + }); + const userWithManyPlaces = { + _id: 'org.couchdb.user:offline_many_facilities', + language: 'en', + known: true, + type: 'user-settings', + roles: [ 'chw' ], + facility_id: [ parentPlace._id, otherParentPlace._id ], + contact_id: contactWithManyPlaces._id, + name: 'offline_many_facilities' + }; + const userWithManyPlacesPass = uuid(); before(async () => { - const allDocs = [...docs, parentPlace, otherParentPlace]; + const allDocs = [ ...docs, parentPlace, otherParentPlace, contactWithManyPlaces, userWithManyPlaces ]; await utils.saveDocs(allDocs); await utils.createUsers([user]); + await utils.request({ + path: `/_users/${userWithManyPlaces._id}`, + method: 'PUT', + body: { ...userWithManyPlaces, password: userWithManyPlacesPass, type: 'user' }, + }); await browser.url('/medic/login'); - await loginPage.login({ username: user.username, password: user.password }); - await commonPage.waitForPageLoaded(); }); const DOCS_TO_KEEP = [ @@ -159,9 +178,20 @@ describe('Target aggregates', () => { [/^form:/], ]; - afterEach(async () => await utils.revertDb(DOCS_TO_KEEP, true)); + afterEach(async () => { + await commonPage.logout(); + await utils.revertDb(DOCS_TO_KEEP, true); + }); + + it('should disable content when user has many facilities associated', async () => { + await loginPage.login({ password: userWithManyPlacesPass, username: userWithManyPlaces.name }); + await commonPage.waitForPageLoaded(); + await targetAggregatesPage.checkContentDisabled(); + }); it('should display no data when no targets are uploaded', async () => { + await loginPage.login({ username: user.username, password: user.password }); + await commonPage.waitForPageLoaded(); const targetsConfig = [ { id: 'not_aggregate', type: 'count', title: generateTitle('my task') }, { id: 'count_no_goal', type: 'count', title: generateTitle('count no goal'), aggregate: true }, @@ -203,6 +233,8 @@ describe('Target aggregates', () => { }); it('should display correct data', async () => { + await loginPage.login({ username: user.username, password: user.password }); + await commonPage.waitForPageLoaded(); const targetsConfig = [ { id: 'count_no_goal', type: 'count', title: generateTitle('count no goal'), aggregate: true }, { id: 'count_with_goal', type: 'count', title: generateTitle('count with goal'), goal: 20, aggregate: true }, @@ -316,6 +348,8 @@ describe('Target aggregates', () => { }); it('should route to contact-detail on list item click and display contact summary target card', async () => { + await loginPage.login({ username: user.username, password: user.password }); + await commonPage.waitForPageLoaded(); const targetsConfig = [ { id: 'a_target', type: 'count', title: generateTitle('what a target!'), aggregate: true }, { id: 'b_target', type: 'percent', title: generateTitle('the most target'), aggregate: true }, diff --git a/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js b/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js index 6343411d1cb..b0722b0c33f 100644 --- a/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js +++ b/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js @@ -1,3 +1,6 @@ +const { v4: uuid } = require('uuid'); +const path = require('path'); + const utils = require('@utils'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const userFactory = require('@factories/cht/users/users'); @@ -5,54 +8,60 @@ const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); const tasksPage = require('@page-objects/default/tasks/tasks.wdio.page'); const chtConfUtils = require('@utils/cht-conf'); -const path = require('path'); const sentinelUtils = require('@utils/sentinel'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); +const commonElements = require('@page-objects/default/common/common.wdio.page'); describe('Tasks tab breadcrumbs', () => { const places = placeFactory.generateHierarchy(); const clinic = places.get('clinic'); - const health_center = places.get('health_center'); - const district_hospital = places.get('district_hospital'); - const contact = { + const healthCenter1 = places.get('health_center'); + const districtHospital = places.get('district_hospital'); + const healthCenter2 = placeFactory.place().build({ + name: 'health_center_2', + type: 'health_center', + parent: { _id: districtHospital._id }, + }); + const chwContact = { _id: 'fixture:user:user1', name: 'chw', phone: '+12068881234', type: 'person', - place: health_center._id, + place: healthCenter1._id, parent: { - _id: health_center._id, - parent: health_center.parent + _id: healthCenter1._id, + parent: healthCenter1.parent }, }; - const contact2 = { + const supervisorContact = { _id: 'fixture:user:user2', name: 'supervisor', phone: '+12068881235', type: 'person', - place: district_hospital._id, + place: districtHospital._id, parent: { - _id: district_hospital._id, + _id: districtHospital._id, }, }; const chw = userFactory.build({ username: 'offlineuser_tasks', isOffline: true, - place: health_center._id, - contact: contact._id, + place: healthCenter1._id, + contact: chwContact._id, }); const supervisor = userFactory.build({ username: 'supervisor_tasks', roles: [ 'chw_supervisor' ], - place: district_hospital._id, - contact: contact2._id, + place: districtHospital._id, + contact: supervisorContact._id, }); const patient = personFactory.build({ _id: 'patient1', name: 'patient1', type: 'person', patient_id: 'patient1', - parent: { _id: clinic._id, parent: { _id: health_center._id, parent: { _id: district_hospital._id }}}, + parent: { _id: clinic._id, parent: { _id: healthCenter1._id, parent: { _id: districtHospital._id }}}, reported_date: new Date().getTime(), }); const patient2 = personFactory.build({ @@ -60,12 +69,34 @@ describe('Tasks tab breadcrumbs', () => { name: 'patient2', type: 'person', patient_id: 'patient2', - parent: { _id: health_center._id, parent: { _id: district_hospital._id }}, + parent: { _id: healthCenter1._id, parent: { _id: districtHospital._id }}, reported_date: new Date().getTime(), }); + const contactWithManyPlaces = personFactory.build({ + parent: { _id: healthCenter1._id, parent: { _id: districtHospital._id } }, + }); + const userWithManyPlaces = { + _id: 'org.couchdb.user:offline_many_facilities', + language: 'en', + known: true, + type: 'user-settings', + roles: [ 'chw' ], + facility_id: [ healthCenter1._id, healthCenter2._id ], + contact_id: contactWithManyPlaces._id, + name: 'offline_many_facilities' + }; + const userWithManyPlacesPass = uuid(); before(async () => { - await utils.saveDocs([ ...places.values(), contact, contact2, patient, patient2 ]); + await utils.saveDocs([ + ...places.values(), healthCenter2, chwContact, supervisorContact, patient, patient2, + contactWithManyPlaces, userWithManyPlaces, + ]); + await utils.request({ + path: `/_users/${userWithManyPlaces._id}`, + method: 'PUT', + body: { ...userWithManyPlaces, password: userWithManyPlacesPass, type: 'user' }, + }); await utils.createUsers([ chw, supervisor ]); await sentinelUtils.waitForSentinel(); @@ -80,20 +111,58 @@ describe('Tasks tab breadcrumbs', () => { }); describe('for chw', () => { - before(async () => { - await loginPage.login(chw); - }); + afterEach(async () => await commonElements.logout()); after(async () => { await browser.deleteCookies(); await browser.refresh(); }); + it('should not remove facility from breadcrumbs when offline user has many facilities associated', async () => { + await loginPage.login({ password: userWithManyPlacesPass, username: userWithManyPlaces.name }); + await commonPage.waitForPageLoaded(); + await tasksPage.goToTasksTab(); + const infos = await tasksPage.getTasksListInfos(await tasksPage.getTasks()); + + expect(infos).to.have.deep.members([ + { + contactName: 'Mary Smith', + formTitle: 'person_create', + lineage: healthCenter1.name, + dueDateText: 'Due today', + overdue: true + }, + { + contactName: 'patient1', + formTitle: 'person_create', + lineage: clinic.name + healthCenter1.name, + dueDateText: 'Due today', + overdue: true + }, + { + contactName: 'patient2', + formTitle: 'person_create', + lineage: healthCenter1.name, + dueDateText: 'Due today', + overdue: true + }, + ]); + }); + it('should display correct tasks with breadcrumbs for chw', async () => { + await loginPage.login(chw); + await commonPage.waitForPageLoaded(); await tasksPage.goToTasksTab(); const infos = await tasksPage.getTasksListInfos(await tasksPage.getTasks()); expect(infos).to.have.deep.members([ + { + contactName: 'Mary Smith', + formTitle: 'person_create', + lineage: '', + dueDateText: 'Due today', + overdue: true + }, { contactName: 'patient1', formTitle: 'person_create', @@ -112,6 +181,8 @@ describe('Tasks tab breadcrumbs', () => { }); it('should open task with expression', async () => { + await loginPage.login(chw); + await commonPage.waitForPageLoaded(); await tasksPage.goToTasksTab(); const task = await tasksPage.getTaskByContactAndForm('patient1', 'person_create'); await task.click(); @@ -134,17 +205,24 @@ describe('Tasks tab breadcrumbs', () => { const infos = await tasksPage.getTasksListInfos(await tasksPage.getTasks()); expect(infos).to.have.deep.members([ + { + contactName: 'Mary Smith', + formTitle: 'person_create', + lineage: healthCenter1.name, + dueDateText: 'Due today', + overdue: true + }, { contactName: 'patient1', formTitle: 'person_create', - lineage: clinic.name+health_center.name, + lineage: clinic.name + healthCenter1.name, dueDateText: 'Due today', overdue: true }, { contactName: 'patient2', formTitle: 'person_create', - lineage: health_center.name, + lineage: healthCenter1.name, dueDateText: 'Due today', overdue: true }, diff --git a/tests/e2e/default/tasks/tasks.wdio-spec.js b/tests/e2e/default/tasks/tasks.wdio-spec.js index 36f319cc828..e0a827b1b24 100644 --- a/tests/e2e/default/tasks/tasks.wdio-spec.js +++ b/tests/e2e/default/tasks/tasks.wdio-spec.js @@ -2,7 +2,6 @@ const path = require('path'); const chtConfUtils = require('@utils/cht-conf'); const utils = require('@utils'); const loginPage = require('@page-objects/default/login/login.wdio.page'); -const tasksPage = require('@page-objects/default/tasks/tasks.wdio.page'); const commonPage = require('@page-objects/default/common/common.wdio.page'); const userFactory = require('@factories/cht/users/users'); const placeFactory = require('@factories/cht/contacts/place'); @@ -47,8 +46,8 @@ describe('Tasks', () => { }); before(async () => { - await utils.saveDocs([ ...places.values(), contact, owl ]); - await utils.createUsers([ chw ]); + await utils.saveDocs([...places.values(), contact, owl]); + await utils.createUsers([chw]); await loginPage.login(chw); }); @@ -66,7 +65,7 @@ describe('Tasks', () => { await updateSettings(settings); await commonPage.goToTasks(); - const { errorMessage, url, username, errorStack } = await tasksPage.getErrorLog(); + const { errorMessage, url, username, errorStack } = await commonPage.getErrorLog(); expect(username).to.equal(chw.username); expect(url).to.equal('localhost'); diff --git a/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js b/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js index 5df4205ebf4..5a904cbef35 100644 --- a/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js +++ b/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js @@ -165,7 +165,7 @@ const assertNewUserSettings = (newUserSettings, newContact, originalUser) => { expect(newUserSettings).to.deep.include({ roles: originalUser.roles, phone: newContact.phone, - facility_id: newContact.parent._id, + facility_id: [newContact.parent._id], contact_id: newContact._id, fullname: newContact.name, }); diff --git a/tests/e2e/default/users/add-user.wdio-spec.js b/tests/e2e/default/users/add-user.wdio-spec.js index 364806d6dbc..07b96112524 100644 --- a/tests/e2e/default/users/add-user.wdio-spec.js +++ b/tests/e2e/default/users/add-user.wdio-spec.js @@ -11,21 +11,33 @@ const password = 'Jacktest@123'; const incorrectpassword = 'Passwor'; const places = placeFactory.generateHierarchy(); const districtHospital = places.get('district_hospital'); +const districtHospital2 = placeFactory.place().build({ + name: 'district_hospital', + type: 'district_hospital', +}); const person = personFactory.build( { parent: { _id: districtHospital._id, parent: districtHospital.parent - } + }, + roles: [offlineUserRole] } ); -const docs = [...places.values(), person]; + +const docs = [...places.values(), person, districtHospital2]; describe('User Test Cases ->', () => { before(async () => { + const settings = await utils.getSettings(); + const permissions = { + ...settings.permissions, + can_have_multiple_places: [offlineUserRole], + }; + await utils.updateSettings({ permissions }, true); await utils.saveDocs(docs); await loginPage.cookieLogin(); }); @@ -54,6 +66,19 @@ describe('User Test Cases ->', () => { await usersAdminPage.saveUser(); expect(await usersAdminPage.getAllUsernames()).to.include.members([username]); }); + + it('should add user with multiple places with permission', async () => { + await usersAdminPage.inputAddUserFields( + 'new_jack', + 'Jack', + offlineUserRole, + [districtHospital.name, districtHospital2.name], + person.name, + password + ); + await usersAdminPage.saveUser(); + expect(await usersAdminPage.getAllUsernames()).to.include.members([username]); + }); }); describe('Invalid entries -> ', () => { @@ -94,5 +119,20 @@ describe('User Test Cases ->', () => { expect(await usersAdminPage.getPlaceErrorText()).to.contain('required'); expect(await usersAdminPage.getContactErrorText()).to.contain('required'); }); + + it('should require user to have permission for multiple places', async () => { + await usersAdminPage.inputAddUserFields( + username, + 'Jack', + onlineUserRole, + [districtHospital.name, districtHospital2.name], + person.name, + password + ); + await usersAdminPage.saveUser(false); + expect(await usersAdminPage.getPlaceErrorText()).to.contain( + 'The selected roles do not have permission to be assigned multiple places.' + ); + }); }); }); diff --git a/tests/e2e/upgrade/upgrade.wdio-spec.js b/tests/e2e/upgrade/upgrade.wdio-spec.js index e2fb523edfc..4fedbedeaf3 100644 --- a/tests/e2e/upgrade/upgrade.wdio-spec.js +++ b/tests/e2e/upgrade/upgrade.wdio-spec.js @@ -1,6 +1,6 @@ const utils = require('@utils'); -const { BRANCH, TAG } = process.env; +const { BRANCH, TAG, BASE_VERSION } = process.env; const loginPage = require('@page-objects/default/login/login.wdio.page'); const upgradePage = require('@page-objects/upgrade/upgrade.wdio.page'); const commonPage = require('@page-objects/default/common/common.wdio.page'); @@ -11,6 +11,8 @@ const version = require('../../../scripts/build/versions'); const dataFactory = require('@factories/cht/generate'); const semver = require('semver'); +const testFrontend = BASE_VERSION === 'latest'; + const docs = dataFactory.createHierarchy({ name: 'offlineupgrade', user: true, @@ -47,13 +49,35 @@ const deleteUpgradeLogs = async () => { await utils.logsDb.bulkDocs(logs); }; +const upgradeVersion = async (branchVersion) => { + await upgradePage.goToUpgradePage(); + await upgradePage.expandPreReleasesAccordion(); + + await (await upgradePage.getInstallButton(branchVersion, TAG)).click(); + await (await upgradePage.upgradeModalConfirm()).click(); + + await (await upgradePage.cancelUpgradeButton()).waitForDisplayed(); + await (await upgradePage.deploymentInProgress()).waitForDisplayed(); + await (await upgradePage.deploymentInProgress()).waitForDisplayed({ reverse: true, timeout: 100000 }); + + if (testFrontend) { + // https://github.com/medic/cht-core/issues/9186 + // this is an unfortunate incompatibility between current API and admin app in the old version + await (await upgradePage.deploymentComplete()).waitForDisplayed(); + } +}; + describe('Performing an upgrade', () => { before(async () => { await utils.saveDocs([...docs.places, ...docs.clinics, ...docs.persons, ...docs.reports]); await utils.createUsers([docs.user]); - await loginPage.login(docs.user); - await commonPage.logout(); + if (testFrontend) { + // a variety of selectors that we use in e2e tests to interact with webapp + // are not compatible with older versions of the app. + await loginPage.login(docs.user); + await commonPage.logout(); + } await loginPage.cookieLogin({ username: constants.USERNAME, @@ -72,25 +96,16 @@ describe('Performing an upgrade', () => { }); it('should have valid semver after installing', async () => { + if (!testFrontend) { + return; + } + const deployInfo = await utils.request({ path: '/api/deploy-info' }); expect(semver.valid(deployInfo.version)).to.be.ok; }); it('should upgrade to current branch', async () => { - await upgradePage.goToUpgradePage(); - await upgradePage.expandPreReleasesAccordion(); - - const installButton = await upgradePage.getInstallButton(BRANCH, TAG); - await installButton.click(); - - const confirm = await upgradePage.upgradeModalConfirm(); - await confirm.click(); - - await (await upgradePage.cancelUpgradeButton()).waitForDisplayed(); - await (await upgradePage.deploymentInProgress()).waitForDisplayed(); - await (await upgradePage.deploymentInProgress()).waitForDisplayed({ reverse: true, timeout: 100000 }); - - await (await upgradePage.deploymentComplete()).waitForDisplayed(); + await upgradeVersion(BRANCH); const currentVersion = await upgradePage.getCurrentVersion(); expect(version.getVersion(true)).to.include(currentVersion); @@ -117,6 +132,10 @@ describe('Performing an upgrade', () => { state: 'finalized', }); + if (!testFrontend) { + return; + } + await adminPage.logout(); await loginPage.login(docs.user); await commonPage.sync(true); @@ -124,9 +143,29 @@ describe('Performing an upgrade', () => { await browser.refresh(); await commonPage.waitForPageLoaded(); await commonPage.goToAboutPage(); + await (await aboutPage.aboutCard()).waitForDisplayed(); const expected = TAG || `${utils.escapeBranchName(BRANCH)} (`; expect(await aboutPage.getVersion()).to.include(expected); await commonPage.logout(); + + // https://github.com/medic/cht-core/issues/9117 + // install 'master' branch to make sure a new version can be installed from the build version + + await loginPage.cookieLogin({ + username: constants.USERNAME, + password: constants.PASSWORD, + createUser: false + }); + + await upgradeVersion('master'); + + expect(await upgradePage.getBuild()).to.include('alpha'); + await commonPage.goToAboutPage(); + await commonPage.waitForPageLoaded(); + await (await aboutPage.aboutCard()).waitForDisplayed(); + expect(await aboutPage.getVersion()).to.include('master'); + + await commonPage.logout(); }); it('should display upgrade page even without upgrade logs', async () => { diff --git a/tests/e2e/upgrade/wdio.conf.js b/tests/e2e/upgrade/wdio.conf.js index c00010a10a0..4d70e4c3d5a 100644 --- a/tests/e2e/upgrade/wdio.conf.js +++ b/tests/e2e/upgrade/wdio.conf.js @@ -13,7 +13,7 @@ const rpn = require('request-promise-native'); const utils = require('@utils'); const wdioBaseConfig = require('../../wdio.conf'); -const { MARKET_URL_READ, STAGING_SERVER, HAPROXY_PORT } = process.env; +const { MARKET_URL_READ, STAGING_SERVER, HAPROXY_PORT, BASE_VERSION } = process.env; const CHT_COMPOSE_PROJECT_NAME = 'cht-upgrade'; const UPGRADE_SERVICE_DOCKER_COMPOSE_FOLDER = utils.makeTempDir('upgrade-service-'); @@ -28,7 +28,11 @@ const getUpgradeServiceDockerCompose = async () => { await fs.promises.writeFile(UPGRADE_SERVICE_DC, contents); }; -const getLatestRelease = async () => { +const getRelease = async () => { + if (BASE_VERSION !== 'latest') { + return `medic:medic:${BASE_VERSION}`; + } + const url = `${MARKET_URL_READ}/${STAGING_SERVER}/_design/builds/_view/releases`; const query = { startKey: ['release', 'medic', 'medic', {}], @@ -43,9 +47,9 @@ const getLatestRelease = async () => { }; const getMainCHTDockerCompose = async () => { - const latestRelease = await getLatestRelease(); + const release = await getRelease(); for (const composeFile of COMPOSE_FILES) { - const composeFileUrl = `${MARKET_URL_READ}/${STAGING_SERVER}/${latestRelease}/docker-compose/${composeFile}.yml`; + const composeFileUrl = `${MARKET_URL_READ}/${STAGING_SERVER}/${release}/docker-compose/${composeFile}.yml`; const contents = await rpn.get(composeFileUrl); const filePath = path.join(CHT_DOCKER_COMPOSE_FOLDER, `${composeFile}.yml`); await fs.promises.writeFile(filePath, contents); @@ -120,8 +124,7 @@ const servicesStartTimeout = () => { const upgradeConfig = Object.assign(wdioBaseConfig.config, { specs: [ - 'upgrade.wdio-spec.js', - '*.wdio-spec.js' + '*.wdio-spec.js', ], exclude: [], diff --git a/tests/e2e/upgrade/webapp.wdio-spec.js b/tests/e2e/upgrade/webapp.wdio-spec.js index 47b85f23449..998bf78d28f 100644 --- a/tests/e2e/upgrade/webapp.wdio-spec.js +++ b/tests/e2e/upgrade/webapp.wdio-spec.js @@ -1,11 +1,8 @@ const common = require('@page-objects/default/common/common.wdio.page'); const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); const peoplePage = require('@page-objects/default/contacts/contacts.wdio.page'); -const aboutPage = require('@page-objects/default/about/about.wdio.page'); const utils = require('@utils'); -const { BRANCH, TAG } = process.env; - const loginPage = require('@page-objects/default/login/login.wdio.page'); const constants = require('@constants'); @@ -55,9 +52,4 @@ describe('Webapp after upgrade', () => { expect(contacts).to.deep.equal(['DC']); }); - it('should display correct version on the about page', async () => { - await common.goToAboutPage(); - const expected = TAG || `${utils.escapeBranchName(BRANCH)} (`; - expect(await aboutPage.getVersion()).to.include(expected); - }); }); diff --git a/tests/factories/cht/reports/pregnancy.js b/tests/factories/cht/reports/pregnancy.js index e719d218fa9..86c2d10abb1 100644 --- a/tests/factories/cht/reports/pregnancy.js +++ b/tests/factories/cht/reports/pregnancy.js @@ -18,7 +18,7 @@ const defaultSubmitter = { }; const nextANCVisit = moment().add(2, 'day'); -const lmp = moment().subtract(3, 'months'); +const lmp = moment().subtract(90, 'days'); const defaultFields = { 'inputs': { diff --git a/tests/integration/api/controllers/bulk-docs.spec.js b/tests/integration/api/controllers/bulk-docs.spec.js index 8cc49ce13bb..9b6a363faed 100644 --- a/tests/integration/api/controllers/bulk-docs.spec.js +++ b/tests/integration/api/controllers/bulk-docs.spec.js @@ -60,10 +60,18 @@ const users = [ }, roles: ['district_admin'], }, + { + username: 'multi', + password: password, + place: ['fixture:offline', 'fixture:online'], + contact: 'fixture:user:offline', + roles: ['district_admin'], + } ]; let offlineRequestOptions; let onlineRequestOptions; +let multiRequestOptions; const DOCS_TO_KEEP = [ 'PARENT_PLACE', @@ -76,6 +84,7 @@ describe('bulk-docs handler', () => { before(async () => { await utils.saveDoc(parentPlace); await sUtils.waitForSentinel(); + await utils.updatePermissions(['district_admin'], ['can_have_multiple_places'], [], true); await utils.createUsers(users); }); @@ -92,6 +101,12 @@ describe('bulk-docs handler', () => { method: 'POST', }; + multiRequestOptions = { + path: '/_bulk_docs', + auth: { username: 'multi', password }, + method: 'POST', + }; + onlineRequestOptions = { path: '/_bulk_docs', auth: { username: 'online', password }, @@ -315,6 +330,54 @@ describe('bulk-docs handler', () => { }); }); + it('should filter offline user requests with multi facility', async () => { + const existentDocs = [ + { + _id: 'ac1', + type: 'clinic', + parent: { _id: 'fixture:offline' }, + name: 'Allowed Contact 1', + }, + { + _id: 'ac2', + type: 'clinic', + parent: { _id: 'fixture:online' }, + name: 'Allowed Contact 2', + }, + { + _id: 'dc1', + type: 'clinic', + parent: { _id: parentPlace._id }, + name: 'Denied Contact 1', + }, + ]; + + const docs = [ + { + _id: 'nac1', + type: 'clinic', + parent: { _id: 'fixture:offline' }, + name: 'New Allowed Contact', + }, + { + _id: 'ndc1', + type: 'clinic', + parent: { _id: parentPlace._id }, + name: 'New Denied Contact', + }, + ]; + + await utils.saveDocsRevs(existentDocs); + multiRequestOptions.body = { docs: [ ...existentDocs, ...docs] }; + const updates = await utils.requestOnTestDb(multiRequestOptions); + + expect(updates[0].ok).to.equal(true); + expect(updates[1].ok).to.equal(true); + expect(updates[2].error).to.equal('forbidden'); + expect(updates[3].ok).to.equal(true); + expect(updates[4].error).to.equal('forbidden'); + }); + it('filters offline tasks and targets', () => { const supervisorRequestOptions = { path: '/_bulk_docs', diff --git a/tests/integration/api/controllers/login.spec.js b/tests/integration/api/controllers/login.spec.js index 340dda77b45..02d8dbd8203 100644 --- a/tests/integration/api/controllers/login.spec.js +++ b/tests/integration/api/controllers/login.spec.js @@ -10,6 +10,12 @@ const parentPlace = { name: 'Big Parent Hostpital' }; +const randomIp = () => { + const section = () => (Math.floor(Math.random() * 255) + 1); + return `${section()}.${section()}.${section()}.${section()}`; +}; + + const loginWithData = data => { const opts = { path: '/medic/login?aaa=aaa', @@ -18,6 +24,7 @@ const loginWithData = data => { noAuth: true, body: data, followRedirect: false, + headers: { 'X-Forwarded-For': randomIp() }, }; return utils.request(opts); }; @@ -31,6 +38,7 @@ const loginWithTokenLink = (token = '') => { noAuth: true, followRedirect: false, body: {}, + headers: { 'X-Forwarded-For': randomIp() }, }; return utils.request(opts); }; diff --git a/tests/integration/api/controllers/person.spec.js b/tests/integration/api/controllers/person.spec.js new file mode 100644 index 00000000000..aefdd14fac6 --- /dev/null +++ b/tests/integration/api/controllers/person.spec.js @@ -0,0 +1,107 @@ +const utils = require('@utils'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const { getRemoteDataContext, Person, Qualifier } = require('@medic/cht-datasource'); +const { expect } = require('chai'); +const userFactory = require('@factories/cht/users/users'); + +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 placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const place0 = utils.deepFreeze({ ...placeMap.get('clinic'), contact: { _id: contact0._id } }); + const place1 = utils.deepFreeze({ ...placeMap.get('health_center'), contact: { _id: contact1._id } }); + const place2 = utils.deepFreeze({ ...placeMap.get('district_hospital'), contact: { _id: contact2._id } }); + + const patient = utils.deepFreeze(personFactory.build({ + parent: { + _id: place0._id, + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + }, + phone: '1234567890', + role: 'patient', + short_name: 'Mary' + })); + const userNoPerms = utils.deepFreeze(userFactory.build({ + username: 'online-no-perms', + place: place1._id, + contact: { + _id: 'fixture:user:online-no-perms', + name: 'Online User', + }, + roles: ['mm-online'] + })); + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-has-perms', + place: place0._id, + contact: { + _id: 'fixture:user:offline-has-perms', + name: 'Offline User', + }, + roles: ['chw'] + })); + const dataContext = getRemoteDataContext(utils.getOrigin()); + + before(async () => { + await utils.saveDocs([contact0, contact1, contact2, place0, place1, place2, patient]); + await utils.createUsers([userNoPerms, offlineUser]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([userNoPerms, offlineUser]); + }); + + describe('GET /api/v1/person/:uuid', async () => { + const getPerson = Person.v1.get(dataContext); + const getPersonWithLineage = Person.v1.getWithLineage(dataContext); + + it('returns the person matching the provided UUID', async () => { + const person = await getPerson(Qualifier.byUuid(patient._id)); + expect(person).excluding(['_rev', 'reported_date']).to.deep.equal(patient); + }); + + it('returns the person with lineage when the withLineage query parameter is provided', async () => { + const person = await getPersonWithLineage(Qualifier.byUuid(patient._id)); + expect(person).excludingEvery(['_rev', 'reported_date']).to.deep.equal({ + ...patient, + parent: { + ...place0, + contact: contact0, + parent: { + ...place1, + contact: contact1, + parent: { + ...place2, + contact: contact2 + } + } + } + }); + }); + + it('returns null when no person is found for the UUID', async () => { + const person = await getPerson(Qualifier.byUuid('invalid-uuid')); + expect(person).to.be.null; + }); + + [ + ['does not have can_view_contacts permission', userNoPerms], + ['is not an online user', offlineUser] + ].forEach(([description, user]) => { + it(`throws error when user ${description}`, async () => { + const opts = { + path: `/api/v1/person/${patient._id}`, + auth: { username: user.username, password: user.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + }); + }); +}); diff --git a/tests/integration/api/controllers/place.spec.js b/tests/integration/api/controllers/place.spec.js new file mode 100644 index 00000000000..019b02f4cb7 --- /dev/null +++ b/tests/integration/api/controllers/place.spec.js @@ -0,0 +1,99 @@ +const utils = require('@utils'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const { getRemoteDataContext, Place, Qualifier } = require('@medic/cht-datasource'); +const { expect } = require('chai'); +const userFactory = require('@factories/cht/users/users'); + +describe('Place 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 placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const place1 = utils.deepFreeze({ ...placeMap.get('health_center'), contact: { _id: contact1._id } }); + const place2 = utils.deepFreeze({ ...placeMap.get('district_hospital'), contact: { _id: contact2._id } }); + const place0 = utils.deepFreeze({ + ...placeMap.get('clinic'), + contact: { _id: contact0._id }, + parent: { + _id: place1._id, + parent: { + _id: place2._id + } + }, + }); + + const userNoPerms = utils.deepFreeze(userFactory.build({ + username: 'online-no-perms', + place: place1._id, + contact: { + _id: 'fixture:user:online-no-perms', + name: 'Online User', + }, + roles: ['mm-online'] + })); + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-has-perms', + place: place0._id, + contact: { + _id: 'fixture:user:offline-has-perms', + name: 'Offline User', + }, + roles: ['chw'] + })); + const dataContext = getRemoteDataContext(utils.getOrigin()); + + before(async () => { + await utils.saveDocs([contact0, contact1, contact2, place0, place1, place2]); + await utils.createUsers([userNoPerms, offlineUser]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([userNoPerms, offlineUser]); + }); + + describe('GET /api/v1/place/:uuid', async () => { + const getPlace = Place.v1.get(dataContext); + const getPlaceWithLineage = Place.v1.getWithLineage(dataContext); + + it('returns the place matching the provided UUID', async () => { + const place = await getPlace(Qualifier.byUuid(place0._id)); + expect(place).excluding(['_rev', 'reported_date']).to.deep.equal(place0); + }); + + it('returns the place with lineage when the withLineage query parameter is provided', async () => { + const place = await getPlaceWithLineage(Qualifier.byUuid(place0._id)); + expect(place).excludingEvery(['_rev', 'reported_date']).to.deep.equal({ + ...place0, + contact: contact0, + parent: { + ...place1, + contact: contact1, + parent: { + ...place2, + contact: contact2 + } + } + }); + }); + + it('returns null when no place is found for the UUID', async () => { + const place = await getPlace(Qualifier.byUuid('invalid-uuid')); + expect(place).to.be.null; + }); + + [ + ['does not have can_view_contacts permission', userNoPerms], + ['is not an online user', offlineUser] + ].forEach(([description, user]) => { + it(`throws error when user ${description}`, async () => { + const opts = { + path: `/api/v1/place/${place0._id}`, + auth: { username: user.username, password: user.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + }); + }); +}); diff --git a/tests/integration/api/controllers/replication.spec.js b/tests/integration/api/controllers/replication.spec.js index ebd77270ca7..beb2d7ecb7b 100644 --- a/tests/integration/api/controllers/replication.spec.js +++ b/tests/integration/api/controllers/replication.spec.js @@ -155,6 +155,13 @@ const users = [ }, roles: ['national_admin'] }, + { + username: 'steveclare', + password: password, + place: ['fixture:clareville', 'fixture:steveville'], + contact: 'fixture:user:clare', + roles: ['district_admin'] + }, ]; const parentPlace = { @@ -186,6 +193,7 @@ describe('replication', () => { ]; before(async () => { + await utils.updatePermissions(['district_admin'], ['can_have_multiple_places'], [], true); await utils.saveDoc(parentPlace); await utils.createUsers(users, true); }); @@ -201,6 +209,7 @@ describe('replication', () => { describe('get-ids', () => { let bobsIds; let stevesIds; + let steveClaresIds; let chwIds; let chwBossIds; let supervisorIds; @@ -208,6 +217,11 @@ describe('replication', () => { beforeEach(() => { bobsIds = ['org.couchdb.user:bob', 'fixture:user:bob', 'fixture:bobville']; stevesIds = ['org.couchdb.user:steve', 'fixture:user:steve', 'fixture:steveville']; + steveClaresIds = [ + 'org.couchdb.user:steveclare', + 'fixture:user:clare', 'fixture:user:steve', + 'fixture:steveville', 'fixture:clareville', + ]; chwIds = ['org.couchdb.user:chw', 'fixture:user:chw', 'fixture:chwville']; chwBossIds = [ 'org.couchdb.user:chw-boss', @@ -241,6 +255,23 @@ describe('replication', () => { expect(bobReplicationCount.count).to.be.greaterThan([...bobsIds, ...getIds(allowedDocs)].length); }); + it('should return all relevant ids with multiple facilities', async () => { + const allowedDocs = [ + ...createSomeContacts(5, 'fixture:steveville'), + ...createSomeContacts(5, 'fixture:clareville'), + ]; + const deniedDocs = createSomeContacts(10, 'irrelevant-place'); + + await utils.saveDocs([...allowedDocs, ...deniedDocs]); + const response = await requestDocs('steveclare'); + assertDocIds(response, ...steveClaresIds, ...getIds(allowedDocs)); + + const replicationLimit = await utils.request('/api/v1/users-doc-count'); + const bobReplicationCount = replicationLimit.users.find(log => log.user === 'steveclare'); + expect(bobReplicationCount).to.be.ok; + expect(bobReplicationCount.count).to.be.greaterThan([...steveClaresIds, ...getIds(allowedDocs)].length); + }); + it('should return relevant ids for concurrent users', async () => { const allowedBob = createSomeContacts(3, 'fixture:bobville'); const allowedSteve = createSomeContacts(3, 'fixture:steveville'); @@ -288,14 +319,30 @@ describe('replication', () => { _id: 'depth_person', type: 'person', parent: { _id: 'depth_clinic', parent: { _id: 'fixture:chwville' } }, - } + }, + { + _id: 'depth_clinic_multi', + type: 'clinic', + parent: { _id: 'fixture:clareville' }, + }, + { + _id: 'depth_person_multi', + type: 'person', + parent: { _id: 'depth_clinic_multi', parent: { _id: 'fixture:clareville' } }, + }, ]; + before(async () => { + await utils.saveDocs(docs); + }); + it('should show contacts to a user only if they are within the configured depth', async () => { await utils.updateSettings({ replication_depth: [{ role: 'district_admin', depth: 1 }] }, true); - await utils.saveDocs(docs); const response = await requestDocs('chw'); assertDocIds(response, ...chwIds, 'depth_clinic'); + + const responseMulti = await requestDocs('steveclare'); + assertDocIds(responseMulti, ...steveClaresIds, 'depth_clinic_multi'); }); it('should correspond to the largest number for any role the user has', async () => { @@ -384,6 +431,89 @@ describe('replication', () => { assertDocIds(response, ...chwBossIds, 'chwville_patient', 'valid_report_1', 'valid_report_2', 'valid_report_3'); }); + it('should show reports to a user only if they are within the configured depth multifacility', async () => { + const contacts = [ + { + // depth 1 + _id: 'steveville_clinic', + type: 'clinic', + parent: { _id: 'fixture:steveville', parent: { _id: parentPlace._id } }, + }, + { + // depth = 2 + _id: 'steveville_patient', + type: 'person', + parent: { + _id: 'steveville_clinic', parent: { _id: 'fixture:steveville', parent: { _id: parentPlace._id } } + }, + name: 'patient', + } + ]; + const reports = [ + { + // depth = 0, submitted by someone they can see + _id: 'valid_1', + form: 'form', + contact: { _id: 'fixture:user:clare' }, + fields: { + place_id: 'fixture:steveville', + }, + type: 'data_record', + }, + { + // depth = 2, submitted by the user himself + _id: 'valid_2', + form: 'form', + contact: { _id: 'fixture:user:clare' }, + fields: { + patient_id: 'steveville_patient', + }, + type: 'data_record', + }, + { + // depth = 1, submitted by someone they can't see + _id: 'valid_3', + form: 'form', + contact: { _id: 'some_contact' }, + fields: { + place_id: 'steveville_clinic', + }, + type: 'data_record', + }, + { + // depth = 2, submitted by someone they can see + _id: 'invalid_1', + form: 'form', + contact: { _id: 'fixture:user:steve' }, + fields: { + patient_id: 'steveville_patient', + }, + type: 'data_record' + }, + ]; + + const permissions = await utils.getUpdatedPermissions(['district_admin'], ['can_have_multiple_places'], []); + await utils.updateSettings( + { + replication_depth: [{ role: 'district_admin', depth: 2, report_depth: 1 }], + permissions, + }, + true + ); + await utils.saveDocs(contacts); + await utils.saveDocs(reports); + const response = await requestDocs('steveclare'); + assertDocIds( + response, + ...steveClaresIds, + 'steveville_clinic', + 'steveville_patient', + 'valid_1', + 'valid_2', + 'valid_3' + ); + }); + it('users should replicate tasks and targets correctly', async () => { const docs = [ { @@ -772,6 +902,115 @@ describe('replication', () => { ]; assertDocIds(response, ...chwIds, ...expectedReports); }); + + it('should not return sensitive reports for multifacility', async () => { + const docs = [ + { + // report about home place submitted by logged in user + _id: 'clare-report-1', + type: 'data_record', + place_id: 'fixture:steveville', + contact: { _id: 'fixture:user:clare' }, + form: 'form', + }, + { + // private report about place submitted by logged in user + _id: 'clare-report-2', + type: 'data_record', + place_id: 'fixture:clareville', + contact: { _id: 'fixture:user:clare' }, + form: 'form', + fields: { private: true }, + }, + { + // private report about place submitted by logged in user + _id: 'clare-report-3', + type: 'data_record', + contact: { _id: 'fixture:user:clare' }, + form: 'form', + fields: { private: true, place_id: 'shortcode:clareville', }, + }, + { + // private report about self submitted by logged in user + _id: 'clare-report-4', + type: 'data_record', + patient_id: 'shortcode:clare', + contact: { _id: 'fixture:user:clare' }, + form: 'form', + fields: { private: true }, + }, + { + // private report about self submitted by logged in user + _id: 'clare-report-5', + type: 'data_record', + contact: { _id: 'fixture:user:clare' }, + form: 'form', + fields: { private: true, patient_id: 'shortcode:clare', }, + }, + { + // report about place submitted by someone else + _id: 'clare-report-6', + type: 'data_record', + place_id: 'fixture:steveville', + contact: { _id: 'someone_else' }, + form: 'form', + }, + { + // report about place submitted by someone else + _id: 'clare-report-7', + type: 'data_record', + contact: { _id: 'someone_else' }, + fields: { place_id: 'shortcode:clareville' }, + form: 'form', + }, + { + // private report about place submitted by someone else + _id: 'clare-report-8', + type: 'data_record', + place_id: 'fixture:steveville', + contact: { _id: 'someone_else' }, + form: 'form', + fields: { private: true }, + }, + { + // private report about place submitted by someone else + _id: 'clare-report-9', + type: 'data_record', + contact: { _id: 'someone_else' }, + form: 'form', + fields: { private: true, place_id: 'shortcode:clareville', }, + }, + { + // private report about self submitted by someone else + _id: 'clare-report-10', + type: 'data_record', + contact: { _id: 'someone_else' }, + form: 'form', + fields: { private: true, patient_id: 'shortcode:user:clare', }, + }, + { + // private report about self submitted by someone else + _id: 'clare-report-11', + type: 'data_record', + contact: { _id: 'someone_else' }, + form: 'form', + fields: { private: true, patient_uuid: 'fixture:user:clare', }, + }, + ]; + + await utils.saveDocs(docs); + const response = await requestDocs('steveclare'); + const expectedReports = [ + 'clare-report-1', + 'clare-report-2', + 'clare-report-3', + 'clare-report-4', + 'clare-report-5', + 'clare-report-6', + 'clare-report-7', + ]; + assertDocIds(response, ...steveClaresIds, ...expectedReports); + }); }); describe('get-deletes', () => { diff --git a/tests/integration/api/controllers/users.spec.js b/tests/integration/api/controllers/users.spec.js index 4b761878f8c..42f40668274 100644 --- a/tests/integration/api/controllers/users.spec.js +++ b/tests/integration/api/controllers/users.spec.js @@ -18,6 +18,10 @@ const parentPlace = { name: 'Big Parent Hospital' }; +const randomIp = () => { + const section = () => (Math.floor(Math.random() * 255) + 1); + return `${section()}.${section()}.${section()}.${section()}`; +}; describe('Users API', () => { @@ -29,6 +33,7 @@ describe('Users API', () => { noAuth: true, body: { user: user.username, password: user.password }, followRedirect: false, + headers: { 'X-Forwarded-For': randomIp() }, }; return utils @@ -51,6 +56,7 @@ describe('Users API', () => { simple: false, noAuth: true, body: { user: user.username, password: user.password }, + headers: { 'X-Forwarded-For': randomIp() }, }; return utils @@ -189,10 +195,10 @@ describe('Users API', () => { }, }); userSettingsDoc = await utils.getDoc(getUserId(username)); - chai.expect(userSettingsDoc.facility_id).to.equal(newPlaceId); + chai.expect(userSettingsDoc.facility_id).to.deep.equal([newPlaceId]); chai.expect(userSettingsDoc.contact_id).to.equal(newContactId); userDoc = await utils.usersDb.get(getUserId(username)); - chai.expect(userDoc.facility_id).to.equal(newPlaceId); + chai.expect(userDoc.facility_id).to.deep.equal([newPlaceId]); chai.expect(userDoc.contact_id).to.equal(newContactId); }); @@ -337,9 +343,9 @@ describe('Users API', () => { .then(([userSettings, user]) => { chai.expect(userSettings).to.include({ name: 'philip', type: 'user-settings' }); chai.expect(user).to.deep.include({ name: 'philip', type: 'user', roles: ['district_admin'] }); - chai.expect(userSettings.facility_id).to.equal(user.facility_id); + chai.expect(userSettings.facility_id).to.deep.equal(user.facility_id); - return utils.getDocs([userSettings.contact_id, userSettings.facility_id]); + return utils.getDocs([userSettings.contact_id, ...userSettings.facility_id]); }) .then(([ contact, place ]) => { chai.expect(contact.patient_id).to.not.be.undefined; @@ -407,7 +413,7 @@ describe('Users API', () => { _id: 'fixture:user:offlineonline', name: 'OnlineUser' }, - roles: ['district_admin', 'mm-online'] + roles: ['data_entry'] }, ]; @@ -663,6 +669,7 @@ describe('Users API', () => { noAuth: true, followRedirect: false, body: {}, + headers: { 'X-Forwarded-For': randomIp() }, }; return utils.request(opts).then(response => { chai.expect(response).to.include({ statusCode: 302, body: '/' }); @@ -681,6 +688,7 @@ describe('Users API', () => { followRedirect: false, resolveWithFullResponse: true, body: {}, + headers: { 'X-Forwarded-For': randomIp() }, }; return utils.request(opts).then(response => { chai.expect(response.headers['set-cookie']).to.be.undefined; @@ -1645,7 +1653,7 @@ describe('Users API', () => { expect(users).excludingEvery(['_rev']).to.deep.include({ ...user, - place: facility, + place: [facility], contact: person, }); }); @@ -1658,7 +1666,7 @@ describe('Users API', () => { expect(users).excludingEvery(['_rev']).to.deep.include({ ...user, - place: facility, + place: [facility], contact: person, }); }); @@ -1701,7 +1709,7 @@ describe('Users API', () => { expect(users).excludingEvery(['_rev']).to.deep.include({ ...user, - place: facility, + place: [facility], contact: person, }); }); @@ -1739,9 +1747,9 @@ describe('Users API', () => { const savedUser = savedUsers.find(savedUser => savedUser.username === user.username); expect(savedUser).to.deep.nested.include({ id: `org.couchdb.user:${user.username}`, - 'place.type': user.place.type, - 'place.name': user.place.name, - 'place.parent._id': parentPlace._id, + 'place[0].type': user.place.type, + 'place[0].name': user.place.name, + 'place[0].parent._id': parentPlace._id, 'contact.name': user.contact.name, }); } @@ -1812,14 +1820,14 @@ describe('Users API', () => { expect(filteredUsers.find(user => user.id === user1Response.user.id)).to.deep.nested.include({ id: user1Response.user.id, 'contact._id': contactA.id, - 'place._id': facilityE.id, - 'place.parent._id': parentPlace._id, + 'place[0]._id': facilityE.id, + 'place[0].parent._id': parentPlace._id, }); expect(filteredUsers.find(user => user.id === user2Response.user.id)).to.deep.nested.include({ id: user2Response.user.id, 'contact._id': contactA.id, - 'place._id': facilityE.id, - 'place.parent._id': parentPlace._id, + 'place[0]._id': facilityE.id, + 'place[0].parent._id': parentPlace._id, }); filteredUsers = await utils.request({ @@ -1830,20 +1838,20 @@ describe('Users API', () => { expect(filteredUsers.find(user => user.id === user1Response.user.id)).to.deep.nested.include({ id: user1Response.user.id, 'contact._id': contactA.id, - 'place._id': facilityE.id, - 'place.parent._id': parentPlace._id, + 'place[0]._id': facilityE.id, + 'place[0].parent._id': parentPlace._id, }); expect(filteredUsers.find(user => user.id === user2Response.user.id)).to.deep.nested.include({ id: user2Response.user.id, 'contact._id': contactA.id, - 'place._id': facilityE.id, - 'place.parent._id': parentPlace._id, + 'place[0]._id': facilityE.id, + 'place[0].parent._id': parentPlace._id, }); expect(filteredUsers.find(user => user.id === user3Response.user.id)).to.deep.nested.include({ id: user3Response.user.id, 'contact._id': contactB.id, - 'place._id': facilityE.id, - 'place.parent._id': parentPlace._id, + 'place[0]._id': facilityE.id, + 'place[0].parent._id': parentPlace._id, }); filteredUsers = await utils.request({ @@ -1854,14 +1862,14 @@ describe('Users API', () => { expect(filteredUsers.find(user => user.id === user1Response.user.id)).to.deep.nested.include({ id: user1Response.user.id, 'contact._id': contactA.id, - 'place._id': facilityE.id, - 'place.parent._id': parentPlace._id, + 'place[0]._id': facilityE.id, + 'place[0].parent._id': parentPlace._id, }); expect(filteredUsers.find(user => user.id === user2Response.user.id)).to.deep.nested.include({ id: user2Response.user.id, 'contact._id': contactA.id, - 'place._id': facilityE.id, - 'place.parent._id': parentPlace._id, + 'place[0]._id': facilityE.id, + 'place[0].parent._id': parentPlace._id, }); filteredUsers = await utils.request({ @@ -1872,8 +1880,8 @@ describe('Users API', () => { expect(filteredUsers.find(user => user.id === user4Response.user.id)).to.deep.nested.include({ id: user4Response.user.id, 'contact._id': contactC.id, - 'place._id': facilityF.id, - 'place.parent._id': parentPlace._id, + 'place[0]._id': facilityF.id, + 'place[0].parent._id': parentPlace._id, }); filteredUsers = await utils.request({ @@ -1892,4 +1900,169 @@ describe('Users API', () => { expect(allUsers.map(user => user.id)).to.not.include(user5Response.user.id); }); }); + + describe('POST api/v3/users', () => { + let places; + let contact; + + before(async () => { + const placeAttributes = { + parent: { _id: parentPlace._id }, + type: 'health_center', + }; + places = [ + placeFactory.place().build({ ...placeAttributes, name: 'place1' }), + placeFactory.place().build({ ...placeAttributes, name: 'place2' }), + placeFactory.place().build({ ...placeAttributes, name: 'place3' }), + ]; + contact = personFactory.build({ + parent: { _id: places[0]._id, parent: places[0].parent }, + }); + await utils.saveDocs([...places, contact]); + }); + + afterEach(async () => { + await utils.revertSettings(true); + }); + + it('should create users with multiple facilities', async () => { + await utils.updatePermissions(['national_admin', 'chw'], ['can_have_multiple_places'], [], true); + const onlineUserPayload = { + username: uuid(), + password: password, + place: places.map(place => place._id), + contact: contact._id, + roles: ['national_admin'] + }; + + const onlineResult = await utils.request({ path: '/api/v3/users', method: 'POST', body: onlineUserPayload }); + const onlineUserDoc = await utils.getDoc(onlineResult.user.id); + const onlineUserSettingsDoc = await utils.getDoc(onlineResult['user-settings'].id); + + expect(onlineUserDoc).to.deep.include({ + roles: [...onlineUserPayload.roles, 'mm-online'], + facility_id: onlineUserPayload.place, + contact_id: onlineUserPayload.contact, + }); + + expect(onlineUserSettingsDoc).to.deep.include({ + roles: [...onlineUserPayload.roles, 'mm-online'], + facility_id: onlineUserPayload.place, + contact_id: onlineUserPayload.contact, + }); + + const offlineUserPayload = { + username: uuid(), + password: password, + place: places.map(place => place._id), + contact: contact._id, + roles: ['chw'] + }; + + const offlineResult = await utils.request({ path: '/api/v3/users', method: 'POST', body: offlineUserPayload }); + const offlineUserDoc = await utils.usersDb.get(offlineResult.user.id); + const offlineUserSettingsDoc = await utils.getDoc(offlineResult['user-settings'].id); + + expect(offlineUserDoc).to.deep.include({ + roles: offlineUserPayload.roles, + facility_id: offlineUserPayload.place, + contact_id: offlineUserPayload.contact, + }); + + expect(offlineUserSettingsDoc).to.deep.include({ + roles: offlineUserPayload.roles, + facility_id: offlineUserPayload.place, + contact_id: offlineUserPayload.contact, + }); + }); + + it('should not allow creating users with multiple places without correct permission', async () => { + const offlineUserPayload = { + username: uuid(), + password: password, + place: places.map(place => place._id), + contact: contact._id, + roles: ['chw'] + }; + + try { + await utils.request({ path: '/api/v3/users', method: 'POST', body: offlineUserPayload }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error.statusCode).to.equal(400); + expect(error.error.error.message).to.equal('This user cannot have multiple places'); + } + }); + + it('should edit users to add multiple facilities', async () => { + await utils.updatePermissions(['national_admin'], ['can_have_multiple_places']); + const onlineUserPayload = { + username: uuid(), + password: password, + place: places[0]._id, + contact: contact._id, + roles: ['national_admin'] + }; + + const result = await utils.request({ path: '/api/v3/users', method: 'POST', body: onlineUserPayload }); + + const onlineUserDoc = await utils.getDoc(result.user.id); + + expect(onlineUserDoc).to.deep.include({ + roles: [...onlineUserPayload.roles, 'mm-online'], + facility_id: [onlineUserPayload.place], + contact_id: onlineUserPayload.contact, + }); + + const updatePayload = { + place: places.map(place => place._id), + }; + + await utils.request({ + path: `/api/v3/users/${onlineUserPayload.username}`, + method: 'POST', + body: updatePayload + }); + + const userDoc = await utils.usersDb.get(result.user.id); + expect(userDoc.facility_id).to.deep.equal(updatePayload.place); + const userSettingsDoc = await utils.getDoc(result.user.id); + expect(userSettingsDoc.facility_id).to.deep.equal(updatePayload.place); + }); + + it('should fail when facilities are malformed', async () => { + await utils.updatePermissions(['national_admin', 'chw'], ['can_have_multiple_places'], [], true); + const onlineUserPayload = { + username: uuid(), + password: password, + place: [], + contact: contact._id, + roles: ['national_admin'] + }; + + try { + await utils.request({ path: '/api/v3/users', method: 'POST', body: onlineUserPayload }); + expect.expect.fail('Should have thrown'); + } catch (err) { + expect(err.responseBody.code).to.equal(400); + expect(err.responseBody.error.message).to.equal('Invalid facilities list'); + } + + const offlineUserPayload = { + username: uuid(), + password: password, + place: [''], + contact: contact._id, + roles: ['chw'] + }; + + try { + await utils.request({ path: '/api/v3/users', method: 'POST', body: offlineUserPayload }); + expect.expect.fail('Should have thrown'); + } catch (err) { + expect(err.responseBody.code).to.equal(400); + expect(err.responseBody.error.message).to.equal('Missing required fields: place'); + } + }); + }); }); diff --git a/tests/integration/cht-form/default/draw-widget.wdio-spec.js b/tests/integration/cht-form/default/draw-widget.wdio-spec.js new file mode 100644 index 00000000000..273fe8a116d --- /dev/null +++ b/tests/integration/cht-form/default/draw-widget.wdio-spec.js @@ -0,0 +1,33 @@ +const mockConfig = require('../mock-config'); +const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page'); +const path = require('path'); + +describe('cht-form web component - Draw Widget', () => { + it('supports attaching drawn images to report', async () => { + await mockConfig.loadForm('default', 'test', 'draw-widget'); + + await commonEnketoPage.drawShapeOnCanvas('Draw widget'); + await commonEnketoPage.drawShapeOnCanvas('Signature widget'); + const filePath = path.join(__dirname, '/../../../e2e/default/enketo/images/photo-for-upload-form.png'); + await commonEnketoPage.addFileInputValue('Annotate image widget', filePath); + await commonEnketoPage.drawShapeOnCanvas('Annotate image widget'); + + const [doc] = await mockConfig.submitForm(); + + const drawName = doc.fields.media_widgets.draw; + expect(drawName).to.match(/^drawing-\d\d?_\d\d?_\d\d?\.png$/); + const drawAttachmentName = `user-file-${drawName}`; + const signatureName = doc.fields.media_widgets.signature; + expect(signatureName).to.match(/^signature-\d\d?_\d\d?_\d\d?\.png$/); + const signatureAttachmentName = `user-file-${signatureName}`; + const annotateName = doc.fields.media_widgets.annotate; + expect(annotateName).to.match(/^annotation-\d\d?_\d\d?_\d\d?\.png$/); + const annotateAttachmentName = `user-file-${annotateName}`; + expect(doc._attachments).to.have.all.keys(drawAttachmentName, signatureAttachmentName, annotateAttachmentName); + const contentTypes = Object.values(doc._attachments).map(({ content_type }) => content_type); + expect(contentTypes).to.deep.equal(['image/png', 'image/png', 'image/png']); + expect(doc._attachments[drawAttachmentName].data.size).to.be.closeTo(19600, 2000); + expect(doc._attachments[signatureAttachmentName].data.size).to.be.closeTo(12800, 2000); + expect(doc._attachments[annotateAttachmentName].data.size).to.be.closeTo(29000, 2000); + }); +}); diff --git a/tests/integration/cht-form/default/file-upload.wdio-spec.js b/tests/integration/cht-form/default/file-upload.wdio-spec.js new file mode 100644 index 00000000000..b5e84cbb8eb --- /dev/null +++ b/tests/integration/cht-form/default/file-upload.wdio-spec.js @@ -0,0 +1,29 @@ +const mockConfig = require('../mock-config'); +const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page'); +const path = require('path'); + +describe('cht-form web component - File upload', () => { + const imagePath0 = path.join(__dirname, '../../../e2e/default/enketo/images/photo-for-upload-form.png'); + const imagePath1 = path.join(__dirname, '../../../../webapp/src/img/layers.png'); + + it('attaches multiple images selected in a repeat', async () => { + await mockConfig.loadForm('default', 'test', 'file-upload'); + + await commonEnketoPage.addRepeatSection(); + await commonEnketoPage.addFileInputValue('Upload image', imagePath0, { repeatIndex: 0 }); + await commonEnketoPage.addRepeatSection(); + await commonEnketoPage.addFileInputValue('Upload image', imagePath1, { repeatIndex: 1 }); + + const [doc] = await mockConfig.submitForm(); + + const attachmentNames = Object.keys(doc._attachments); + expect(attachmentNames).to.have.lengthOf(2); + expect(attachmentNames[1]).to.match(/^user-file-photo-for-upload-form-\d\d?_\d\d?_\d\d?\.png$/); + expect(attachmentNames[0]).to.match(/^user-file-layers-\d\d?_\d\d?_\d\d?\.png$/); + + expect(doc.fields.files.images).to.have.lengthOf(2); + const [{ image: image0 }, { image: image1 }] = doc.fields.files.images; + expect(image0).to.equal(attachmentNames[1].substring(10)); + expect(image1).to.equal(attachmentNames[0].substring(10)); + }); +}); diff --git a/tests/integration/cht-form/default/forms/draw-widget.xlsx b/tests/integration/cht-form/default/forms/draw-widget.xlsx new file mode 100644 index 00000000000..a1767b5e516 Binary files /dev/null and b/tests/integration/cht-form/default/forms/draw-widget.xlsx differ diff --git a/tests/integration/cht-form/default/forms/draw-widget.xml b/tests/integration/cht-form/default/forms/draw-widget.xml new file mode 100644 index 00000000000..74274425d2b --- /dev/null +++ b/tests/integration/cht-form/default/forms/draw-widget.xml @@ -0,0 +1,55 @@ + + + + Draw Widget + + + + + Annotate image widget + + + Draw widget + + + Signature widget + + + Media input widgets + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/integration/cht-form/default/forms/file-upload.xlsx b/tests/integration/cht-form/default/forms/file-upload.xlsx new file mode 100644 index 00000000000..8dfdd43b461 Binary files /dev/null and b/tests/integration/cht-form/default/forms/file-upload.xlsx differ diff --git a/tests/integration/cht-form/default/forms/file-upload.xml b/tests/integration/cht-form/default/forms/file-upload.xml new file mode 100644 index 00000000000..3491bb7427c --- /dev/null +++ b/tests/integration/cht-form/default/forms/file-upload.xml @@ -0,0 +1,46 @@ + + + + File Upload + + + + + Upload image + + + Files + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/integration/couchdb/couch_chttpd.spec.js b/tests/integration/couchdb/couch_chttpd.spec.js index 54e1315fdb1..df2aa2ebc5f 100644 --- a/tests/integration/couchdb/couch_chttpd.spec.js +++ b/tests/integration/couchdb/couch_chttpd.spec.js @@ -21,8 +21,13 @@ const startContainer = async (useAuthentication) => { if (useAuthentication) { env.COUCH_AUTH = `${constants.USERNAME}:${constants.PASSWORD}`; } - return await runDockerCommand('docker-compose', ['up', '--build', '--force-recreate'], env); + await runDockerCommand('docker-compose', ['up', '--build', '--force-recreate'], env); }; + +const stopContainer = async () => { + await runDockerCommand('docker-compose', ['down', '--remove-orphans']); +}; + const getLogs = async () => { const containerName = (await runDockerCommand('docker-compose', ['ps', '-q', '-a']))[0]; const logs = await runDockerCommand('docker', ['logs', containerName]); @@ -46,6 +51,10 @@ const expectCorrectMetadata = (metadata) => { }; describe('accessing couch clustering endpoint', () => { + afterEach(async () => { + await stopContainer(); + }); + it('should block unauthenticated access through the host network', async () => { await expect( utils.request({ uri: `https://${constants.API_HOST}/_node/_local/_dbs/${constants.DB_NAME}`, noAuth: true }) diff --git a/tests/integration/couchdb/couch_httpd_script/docker-compose.yml b/tests/integration/couchdb/couch_httpd_script/docker-compose.yml index 4b65b5988d7..d9e6dd650c1 100644 --- a/tests/integration/couchdb/couch_httpd_script/docker-compose.yml +++ b/tests/integration/couchdb/couch_httpd_script/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3' - services: chttpd_call: build: . @@ -11,3 +9,4 @@ services: networks: net: name: cht-net-e2e + external: true diff --git a/tests/integration/haproxy/keep-alive-script/cmd.sh b/tests/integration/haproxy/keep-alive-script/cmd.sh index 3cb9e2036df..f30faea0265 100644 --- a/tests/integration/haproxy/keep-alive-script/cmd.sh +++ b/tests/integration/haproxy/keep-alive-script/cmd.sh @@ -4,5 +4,5 @@ set -e data='{"user":"'$USER'","password":"'$PASSWORD'"}' -curl -v POST 'http://api:5988/medic/login' -d $data -H 'Content-Type:application/json' +curl -v POST 'http://api:5988/medic/login' -d "$data" -H 'Content-Type:application/json' diff --git a/tests/integration/sentinel/transitions/create-user-for-contacts.spec.js b/tests/integration/sentinel/transitions/create-user-for-contacts.spec.js index 41e4e5ec426..e44fde3c782 100644 --- a/tests/integration/sentinel/transitions/create-user-for-contacts.spec.js +++ b/tests/integration/sentinel/transitions/create-user-for-contacts.spec.js @@ -167,7 +167,7 @@ describe('create_user_for_contacts', () => { assert.deepInclude(newUserSettings, { roles: ORIGINAL_USER.roles, phone: NEW_PERSON.phone, - facility_id: NEW_PERSON.parent._id, + facility_id: [NEW_PERSON.parent._id], contact_id: NEW_PERSON._id, fullname: NEW_PERSON.name, }); @@ -248,7 +248,7 @@ describe('create_user_for_contacts', () => { assert.deepInclude(newUserSettings, { roles: ORIGINAL_USER.roles, phone: NEW_PERSON.phone, - facility_id: NEW_PERSON.parent._id, + facility_id: [NEW_PERSON.parent._id], contact_id: NEW_PERSON._id, fullname: NEW_PERSON.name, }); @@ -303,7 +303,7 @@ describe('create_user_for_contacts', () => { assert.deepInclude(newUserSettings, { roles: ORIGINAL_USER.roles, phone: NEW_PERSON.phone, - facility_id: NEW_PERSON.parent._id, + facility_id: [NEW_PERSON.parent._id], contact_id: NEW_PERSON._id, fullname: NEW_PERSON.name, }); @@ -375,7 +375,7 @@ describe('create_user_for_contacts', () => { assert.deepInclude(newUserSettings, { roles: ORIGINAL_USER.roles, phone: NEW_PERSON.phone, - facility_id: NEW_PERSON.parent._id, + facility_id: [NEW_PERSON.parent._id], contact_id: NEW_PERSON._id, fullname: NEW_PERSON.name, }); @@ -661,7 +661,7 @@ describe('create_user_for_contacts', () => { assert.deepInclude(newUserSettings, { roles: originalContact.roles, phone: originalContact.phone, - facility_id: originalContact.parent._id, + facility_id: [originalContact.parent._id], contact_id: originalContact._id, fullname: originalContact.name, }); @@ -703,7 +703,7 @@ describe('create_user_for_contacts', () => { assert.deepInclude(newUserSettings, { roles: [originalContact.role], phone: originalContact.phone, - facility_id: originalContact.parent._id, + facility_id: [originalContact.parent._id], contact_id: originalContact._id, fullname: originalContact.name, }); diff --git a/tests/page-objects/default/about/about.wdio.page.js b/tests/page-objects/default/about/about.wdio.page.js index d80f3dfe755..4140ff7997d 100644 --- a/tests/page-objects/default/about/about.wdio.page.js +++ b/tests/page-objects/default/about/about.wdio.page.js @@ -1,6 +1,7 @@ const userName = () => $('label=User name'); const partners = () => $('.partners'); const version = () => $('[test-id="about-version"]'); +const aboutCard = () => $('div*=About'); const RELOAD_BUTTON = '.about.page .mat-primary'; const getPartnerImage = async (name) => { @@ -21,5 +22,6 @@ module.exports = { partners, getPartnerImage, getVersion, + aboutCard, RELOAD_BUTTON, }; diff --git a/tests/page-objects/default/analytics/analytics.wdio.page.js b/tests/page-objects/default/analytics/analytics.wdio.page.js index 41b34026e43..e9c4fd284a2 100644 --- a/tests/page-objects/default/analytics/analytics.wdio.page.js +++ b/tests/page-objects/default/analytics/analytics.wdio.page.js @@ -26,19 +26,6 @@ const targetNumberPercentCount = (targetElement) => targetElement.$('.body .targ const targetGoalValue = (targetElement) => targetElement.$('.body .count .goal'); -const errorLog = () => $(`.page error-log`); - -const getErrorLog = async () => { - await errorLog().waitForDisplayed(); - - const errorMessage = await (await $('.error-details span')).getText(); - const userDetails = await (await $$('.error-details dl dd')); - const errorStack = await (await $('pre code')); - - const username = await userDetails[0].getText(); - const url = await userDetails[1].getText(); - return { errorMessage, url, username, errorStack }; -}; const EMPTY_SELECTION = '.content-pane .item-content.empty-selection'; const emptySelectionError = () => $(`${EMPTY_SELECTION}.selection-error`); @@ -88,7 +75,6 @@ module.exports = { noSelectedTarget, goToTargets, getTargets, - getErrorLog, emptySelectionError, emptySelectionNoError, TARGET_MET_COLOR, diff --git a/tests/page-objects/default/common/common.wdio.page.js b/tests/page-objects/default/common/common.wdio.page.js index ac1cdfbdbd9..387b8cac210 100644 --- a/tests/page-objects/default/common/common.wdio.page.js +++ b/tests/page-objects/default/common/common.wdio.page.js @@ -52,6 +52,8 @@ const ABOUT_MENU = '#header-dropdown i.fa-question'; //Configuration App const CONFIGURATION_APP_MENU = '#header-dropdown i.fa-cog'; +const errorLog = () => $(`error-log`); + const isHamburgerMenuOpen = async () => { return await (await $('.header .dropdown.open #header-dropdown-link')).isExisting(); }; @@ -351,6 +353,7 @@ const sync = async (expectReload, timeout) => { } // sync status sometimes lies when multiple changes are fired in quick succession await syncAndWaitForSuccess(timeout); + await closeHamburgerMenu(); }; const syncAndWaitForFailure = async () => { @@ -450,6 +453,18 @@ const getActionBarLabels = async () => { return labels.filter(label => !!label); }; +const getErrorLog = async () => { + await errorLog().waitForDisplayed(); + + const errorMessage = await (await $('.error-details span')).getText(); + const userDetails = await (await $$('.error-details dl dd')); + const errorStack = await (await $('pre code')); + + const username = await userDetails[0].getText(); + const url = await userDetails[1].getText(); + return { errorMessage, url, username, errorStack }; +}; + module.exports = { openMoreOptionsMenu, closeFastActionList, @@ -513,4 +528,5 @@ module.exports = { goToUrl, getFastActionItemsLabels, getActionBarLabels, + getErrorLog }; diff --git a/tests/page-objects/default/contacts/contacts.wdio.page.js b/tests/page-objects/default/contacts/contacts.wdio.page.js index b74d831e16e..c9e6c821878 100644 --- a/tests/page-objects/default/contacts/contacts.wdio.page.js +++ b/tests/page-objects/default/contacts/contacts.wdio.page.js @@ -40,12 +40,13 @@ const visitLabel = () => $(`${CARD} .row label`); const numberOfReports = () => $((`${CARD} .row p`)); const rhsPeopleListSelector = () => $$('.card.children.persons h4 span'); -const rhsReportListSelector = '.card.reports mm-content-row h4 span'; -const rhsTaskListSelector = '.card.tasks mm-content-row h4 span'; -const rhsTaskListElement = () => $(rhsTaskListSelector); -const rhsTaskListElementList = () => $$(rhsTaskListSelector); -const rhsReportListElement = () => $(rhsReportListSelector); -const rhsReportElementList = () => $$(rhsReportListSelector); +const RHS_REPORT_LIST_CARD = '.card.reports'; +const RHS_REPORT_LIST_SELECTOR = `${RHS_REPORT_LIST_CARD} mm-content-row h4 span`; +const RHS_TASK_LIST_SELECTOR = '.card.tasks mm-content-row h4 span'; +const rhsTaskListElement = () => $(RHS_TASK_LIST_SELECTOR); +const rhsTaskListElementList = () => $$(RHS_TASK_LIST_SELECTOR); +const rhsReportListElement = () => $(RHS_REPORT_LIST_SELECTOR); +const rhsReportElementList = () => $$(RHS_REPORT_LIST_SELECTOR); const contactSummaryContainer = () => $('#contact_summary'); const emptySelection = () => $('contacts-content .empty-selection'); @@ -391,6 +392,12 @@ const getCurrentPersonEditFormValues = async (sexValue, roleValue) => { }; }; +const filterReportViewAll = async () => { + const tabsContainer = $(`${RHS_REPORT_LIST_CARD} .action-header .table-filter`); + await tabsContainer.scrollIntoView(); + await (await tabsContainer.$('*=View all')).click(); +}; + module.exports = { genericForm, selectLHSRowByText, @@ -455,4 +462,5 @@ module.exports = { sexField, roleField, getCurrentPersonEditFormValues, + filterReportViewAll, }; diff --git a/tests/page-objects/default/enketo/common-enketo.wdio.page.js b/tests/page-objects/default/enketo/common-enketo.wdio.page.js index 10594587fed..16ca30d770a 100644 --- a/tests/page-objects/default/enketo/common-enketo.wdio.page.js +++ b/tests/page-objects/default/enketo/common-enketo.wdio.page.js @@ -9,6 +9,8 @@ const getCurrentPageSection = async () => await currentSection().isExisting() ? const enabledFieldset = (section) => section.$$('fieldset.or-branch:not(.disabled)'); +const addRepeatSectionButton = () => $(`button.add-repeat-btn`); + const getCorrectFieldsetSection = async (section) => { const countFieldset = await enabledFieldset(section).length; if (countFieldset){ @@ -60,6 +62,13 @@ const setTextareaValue = async (question, value) => { await setValue('textarea', question, value); }; +const addFileInputValue = async (question, value, { repeatIndex = 0 } = {}) => { + const element = await (await getCurrentPageSection()) + .$$(`label*=${question}`)[repeatIndex] + .$('input[type=file]'); + await element.addValue(value); +}; + const validateSummaryReport = async (textArray) => { const element = await getCurrentPageSection(); for (const text of textArray) { @@ -94,6 +103,28 @@ const getInputValue = async (question) => { .getValue(); }; +const addRepeatSection = async () => { + const repeatButton = await addRepeatSectionButton(); + await repeatButton.click(); +}; + +const drawShapeOnCanvas = async (question) => { + const canvas = await (await getCurrentPageSection()) + .$(`label*=${question}`) + .$('canvas'); + await canvas.waitForDisplayed(); + await browser.action('pointer') + .move({ origin: canvas }) + .down() + .move({ origin: canvas, x: 50, y: 0 }) + .move({ origin: canvas, x: 50, y: 50 }) + .move({ origin: canvas, x: 0, y: 50 }) + .move({ origin: canvas, x: 0, y: 0 }) + .move({ origin: canvas, x: 50, y: 0 }) + .up() + .perform(); +}; + module.exports = { isElementDisplayed, selectRadioButton, @@ -101,7 +132,10 @@ module.exports = { setInputValue, setDateValue, setTextareaValue, + addFileInputValue, validateSummaryReport, uploadForm, getInputValue, + addRepeatSection, + drawShapeOnCanvas, }; diff --git a/tests/page-objects/default/targets/target-aggregates.wdio.page.js b/tests/page-objects/default/targets/target-aggregates.wdio.page.js index fd67885176a..c970263184a 100644 --- a/tests/page-objects/default/targets/target-aggregates.wdio.page.js +++ b/tests/page-objects/default/targets/target-aggregates.wdio.page.js @@ -1,3 +1,4 @@ +const commonPage = require('@page-objects/default/common/common.wdio.page'); const AGGREGATE_LIST = '#target-aggregates-list'; const loadingStatus = () => $(`${AGGREGATE_LIST} .loading-status`); const aggregateList = () => $$(`${AGGREGATE_LIST} ul li`); @@ -31,6 +32,12 @@ const goToTargetAggregates = async (enabled) => { await (await $(CONTENT_DISABLED)).waitForDisplayed(); }; +const checkContentDisabled = async () => { + await commonPage.goToUrl('/#/analytics/target-aggregates'); + await commonPage.waitForPageLoaded(); + await (await $(CONTENT_DISABLED)).waitForDisplayed(); +}; + const getTargetItem = async (target) => { const item = lineItem(target.id); await (await item.$('h4')).waitForDisplayed(); @@ -128,5 +135,6 @@ module.exports = { getAggregateDetailListElementByIndex, getAggregateDetailElementInfo, clickOnTargetAggregateListItem, + checkContentDisabled, }; diff --git a/tests/page-objects/default/tasks/tasks.wdio.page.js b/tests/page-objects/default/tasks/tasks.wdio.page.js index c5743efdacd..ae095a90cb8 100644 --- a/tests/page-objects/default/tasks/tasks.wdio.page.js +++ b/tests/page-objects/default/tasks/tasks.wdio.page.js @@ -3,22 +3,9 @@ const taskFormSelector = '#task-report'; const tasksGroupSelector = '#tasks-group .item-content'; const formTitleSelector = `${taskFormSelector} h3#form-title`; const noSelectedTaskSelector = '.empty-selection'; -const errorLogSelector = `${taskListSelector} error-log`; const tasksList = () => $(taskListSelector); -const getErrorLog = async () => { - await $(errorLogSelector).waitForDisplayed(); - - const errorMessage = await (await $('.error-details span')).getText(); - const userDetails = await (await $$('.error-details dl dd')); - const errorStack = await (await $('pre code')); - - const username = await userDetails[0].getText(); - const url = await userDetails[1].getText(); - return { errorMessage, url, username, errorStack }; -}; - const getTaskById = (emissionId) => $(`${taskListSelector} li[data-record-id="${emissionId}"`); const getTasks = () => $$(`${taskListSelector} li.content-row`); @@ -112,6 +99,5 @@ module.exports = { waitForTasksGroupLoaded, getTasksInGroup, noSelectedTask, - getErrorLog, openTaskById, }; diff --git a/tests/page-objects/default/users/user.wdio.page.js b/tests/page-objects/default/users/user.wdio.page.js index ea694406184..31cb879093f 100644 --- a/tests/page-objects/default/users/user.wdio.page.js +++ b/tests/page-objects/default/users/user.wdio.page.js @@ -48,7 +48,7 @@ const scrollToBottomOfModal = async () => { }); }; -const inputAddUserFields = async (username, fullname, role, place, contact, password, confirmPassword = password) => { +const inputAddUserFields = async (username, fullname, role, places, contact, password, confirmPassword = password) => { await (await userName()).setValue(username); await (await userFullName()).setValue(fullname); await (await $(`#role-select input[value="${role}"]`)).click(); @@ -57,8 +57,14 @@ const inputAddUserFields = async (username, fullname, role, place, contact, pass // scrollIntoView doesn't work because they're within a scrollable div (the modal) await scrollToBottomOfModal(); - if (!_.isEmpty(place)) { - await selectPlace(place); + if (!_.isEmpty(places)) { + if (Array.isArray(places)) { + for (const name of places) { + await selectPlace([name]); + } + } else { + await selectPlace([places]); + } } if (!_.isEmpty(contact)) { @@ -78,7 +84,9 @@ const setSelect2 = async (id, value) => { await input.waitForExist(); await input.click(); - const searchField = await $('.select2-search__field'); + const searchField = await $( + `.select2-container--open .select2-search__field` + ); await searchField.waitForExist(); await searchField.setValue(value); @@ -88,8 +96,26 @@ const setSelect2 = async (id, value) => { await option.click(); }; -const selectPlace = async (place) => { - await setSelect2('facilitySelect', place); +const setPlaceSelectMultiple = async (value) => { + const input = await $(`span.select2-selection--multiple`); + await input.waitForExist(); + await input.click(); + + const searchField = await $('span.select2-selection--multiple .select2-search__field'); + await searchField.waitForExist(); + await searchField.setValue(value); + + const option = await $('.name'); + await option.waitForExist(); + await option.waitForClickable(); + await option.click(); + await browser.waitUntil(async () => await (await $('.select2-selection__choice')).isDisplayed(), 1000); +}; + +const selectPlace = async (places) => { + for (const place of places) { + await setPlaceSelectMultiple(place); + } }; const selectContact = async (associatedContact) => { diff --git a/tests/scalability/prepare-ec2.sh b/tests/scalability/prepare-ec2.sh index 73462e968b2..8e31a0097b7 100755 --- a/tests/scalability/prepare-ec2.sh +++ b/tests/scalability/prepare-ec2.sh @@ -31,8 +31,8 @@ mkdir -p /cht/compose curl -s https://raw.githubusercontent.com/medic/cht-upgrade-service/main/docker-compose.yml \ -o /cht/upgrade-service/docker-compose.yml -curl -s $BUILD/docker-compose/cht-core.yml -o /cht/compose/cht-core.yml -curl -s $BUILD/docker-compose/cht-couchdb.yml -o /cht/compose/cht-couchdb.yml +curl -s "$BUILD"/docker-compose/cht-core.yml -o /cht/compose/cht-core.yml +curl -s "$BUILD"/docker-compose/cht-couchdb.yml -o /cht/compose/cht-couchdb.yml cd /cht/upgrade-service/ cat > ./.env << EOF diff --git a/tests/scalability/run_suite.sh b/tests/scalability/run_suite.sh index 6e90cd9b986..5a46b4b9821 100755 --- a/tests/scalability/run_suite.sh +++ b/tests/scalability/run_suite.sh @@ -9,7 +9,7 @@ chmod 777 /cht; cd cht echo Cloning cht-core to /cht-core -git clone --single-branch --branch $TAG https://github.com/medic/cht-core.git; +git clone --single-branch --branch "$TAG" https://github.com/medic/cht-core.git; export NODE_TLS_REJECT_UNAUTHORIZED=0 @@ -43,8 +43,8 @@ java -cp jmeter/lib/ext/jmeter-plugins-manager-1.4.jar org.jmeterplugins.reposit echo "jmeter do it!" tmp_dir=$(mktemp -d -t -p ./ report-XXXXXXXXXX) -./jmeter/bin/jmeter -n -t sync.jmx -Jworking_dir=$tmp_dir -Jnode_binary=$(which node) -Jnumber_of_threads=10 -l $tmp_dir/cli_run.jtl -e -o $tmp_dir -mv ./jmeter.log $tmp_dir/jmeter.log +./jmeter/bin/jmeter -n -t sync.jmx -Jworking_dir="$tmp_dir" -Jnode_binary="$(which node)" -Jnumber_of_threads=10 -l "$tmp_dir"/cli_run.jtl -e -o "$tmp_dir" +mv ./jmeter.log "$tmp_dir"/jmeter.log echo "Installing AWS CLI" apt-get install unzip -y @@ -52,5 +52,5 @@ curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip unzip awscliv2.zip ./aws/install echo "Uploading logs and screenshots to ${S3_PATH}..." -/usr/local/bin/aws s3 cp $tmp_dir "$S3_PATH" --recursive +/usr/local/bin/aws s3 cp "$tmp_dir" "$S3_PATH" --recursive echo "FINISHED! " diff --git a/tests/scalability/start-ec2-cht.sh b/tests/scalability/start-ec2-cht.sh index 3b3641b0f76..f7e6c50c217 100755 --- a/tests/scalability/start-ec2-cht.sh +++ b/tests/scalability/start-ec2-cht.sh @@ -1,9 +1,8 @@ #!/bin/bash set -e -NODE_TLS_REJECT_UNAUTHORIZED=0 TAG=$1 -sed -i '4s~^~'BUILD=$MARKET_URL_READ/$STAGING_SERVER/medic:medic:$TAG'\n\n~' prepare-ec2.sh +sed -i '4s~^~'BUILD="$MARKET_URL_READ"/"$STAGING_SERVER"/medic:medic:"$TAG"'\n\n~' prepare-ec2.sh echo Triggering EC2 Run Instance Command and getting Instance ID @@ -17,30 +16,29 @@ waitForBuildAvailable() { runInstance () { # --profile CA \ # for local runs - echo $(aws ec2 run-instances \ + aws ec2 run-instances \ --image-id ami-0a24ca1ef53e3d20f \ --instance-type c5.2xlarge \ --block-device-mappings file://block-device-mapping.json \ - --user-data file://$1 \ + --user-data file://"$1" \ --instance-initiated-shutdown-behavior terminate \ --security-group-ids sg-0fa20cd785acec256 \ --key-name cht-scalability-ca \ - --iam-instance-profile Arn=$SCALABILITY_ARN - ) + --iam-instance-profile Arn="$SCALABILITY_ARN" } getInstanceId () { - echo $(echo $1 | jq .Instances[0].InstanceId -r) + echo "$1" | jq .Instances[0].InstanceId -r } getPublicDnsName () { # --profile CA - echo $(aws ec2 describe-instances --instance-ids "$1" | jq .Reservations[0].Instances[0].PublicDnsName -r) + aws ec2 describe-instances --instance-ids "$1" | jq .Reservations[0].Instances[0].PublicDnsName -r } waitForInstanceUp () { set +e # don't exit when /api/info doesn't return json - echo Begin Checking $1/api/info is up + echo Begin Checking "$1"/api/info is up version="" sleep_time=10 @@ -48,7 +46,7 @@ waitForInstanceUp () { do echo Waiting for CHT to be up. Sleeping for $sleep_time. sleep $sleep_time - version=$(curl -s $1/api/info -k -H 'Accept: application/json' | jq .version -r) + version=$(curl -s "$1"/api/info -k -H 'Accept: application/json' | jq .version -r) done set -e @@ -60,7 +58,7 @@ seedData () { npm install cht-conf echo Seeding data - echo cht url is $1 + echo cht url is "$1" ./node_modules/.bin/cht --url="$1" --accept-self-signed-certs --force \ csv-to-docs \ upload-docs \ @@ -74,9 +72,9 @@ waitForSentinel () { until [ "$sentinel_queue_size" -lt "50" ] do - proc_seq=$(curl $1/medic-sentinel/_local/transitions-seq -s -k | jq .value -r) - sentinel_queue_size=$(curl $1/medic/_changes?since=$proc_seq -s -k | jq '.results | length') - echo Sentinel queue length is $sentinel_queue_size + proc_seq=$(curl "$1"/medic-sentinel/_local/transitions-seq -s -k | jq .value -r) + sentinel_queue_size=$(curl "$1"/medic/_changes?since="$proc_seq" -s -k | jq '.results | length') + echo Sentinel queue length is "$sentinel_queue_size" echo Sleeping again for $sleep_time sleep $sleep_time done @@ -115,12 +113,12 @@ url=https://$PublicDnsName waitForInstanceUp "$url" MEDIC_CONF_URL='https://admin:medicScalability@'$PublicDnsName -seedData $MEDIC_CONF_URL -waitForSentinel $MEDIC_CONF_URL +seedData "$MEDIC_CONF_URL" +waitForSentinel "$MEDIC_CONF_URL" -sed -i '4s~^~'MEDIC_URL=$url'\n~' run_suite.sh -sed -i '4s~^~'S3_PATH=s3://medic-e2e/scalability/$TAG-$GITHUB_RUN_ID'\n~' run_suite.sh -sed -i '4s~^~'TAG=$TAG'\n~' run_suite.sh +sed -i '4s~^~'MEDIC_URL="$url"'\n~' run_suite.sh +sed -i '4s~^~'S3_PATH=s3://medic-e2e/scalability/"$TAG"-"$GITHUB_RUN_ID"'\n~' run_suite.sh +sed -i '4s~^~'TAG="$TAG"'\n~' run_suite.sh echo Triggering EC2 Run Instance Command and getting Instance ID diff --git a/tests/utils/index.js b/tests/utils/index.js index 55dab2343bc..14804812a33 100644 --- a/tests/utils/index.js +++ b/tests/utils/index.js @@ -63,6 +63,9 @@ const MINIMUM_BROWSER_VERSION = '90'; const KUBECTL_CONTEXT = `-n ${PROJECT_NAME} --context k3d-${PROJECT_NAME}`; const cookieJar = rpn.jar(); +// Cookies from the jar will be included on Node `fetch` calls +global.fetch = require('fetch-cookie').default(global.fetch, cookieJar); + const makeTempDir = (prefix) => fs.mkdtempSync(path.join(path.join(os.tmpdir(), prefix || 'ci-'))); const env = { ...process.env, @@ -753,14 +756,15 @@ const getCreatedUsers = async () => { * @return {Promise} * */ const createUsers = async (users, meta = false) => { - const createUserOpts = { - path: '/api/v1/users', - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }; + const createUserOpts = { path: '/api/v1/users', method: 'POST' }; + const createUserV3Opts = { path: '/api/v3/users', method: 'POST' }; for (const user of users) { - await request({ ...createUserOpts, body: user }); + const options = { + body: user, + ...(Array.isArray(user.place) ? createUserV3Opts : createUserOpts) + }; + await request(options); } await delayPromise(1000); @@ -1228,10 +1232,10 @@ const getLogs = (container) => { const logWriteStream = fs.createWriteStream(logFile, { flags: 'w' }); const command = isDocker() ? 'docker' : 'kubectl'; - const params = `logs ${container}${isK3D() ? ` ${KUBECTL_CONTEXT}` : ''}`; + const params = `logs ${container} ${isK3D() ? KUBECTL_CONTEXT : ''}`.split(' ').filter(Boolean); return new Promise((resolve, reject) => { - const cmd = spawn(command, params.split(' ')); + const cmd = spawn(command, params); cmd.on('error', (err) => { console.error('Error while collecting container logs', err); reject(err); @@ -1294,14 +1298,14 @@ const waitForLogs = (container, tail, ...regex) => { let timeout; let logs = ''; let firstLine = false; - tail = isDocker() ? true : tail; + tail = (isDocker() || tail) ? '--tail=1': ''; // It takes a while until the process actually starts tailing logs, and initiating next test steps immediately // after watching results in a race condition, where the log is created before watching started. // As a fix, watch the logs with tail=1, so we always receive one log line immediately, then proceed with next // steps of testing afterward. - const params = `logs ${container} -f${tail ? ` --tail=1` : ''}${isK3D() ? ` ${KUBECTL_CONTEXT}` : ''}`; - const proc = spawn(cmd, params.split(' '), { stdio: ['ignore', 'pipe', 'pipe'] }); + const params = `logs ${container} -f ${tail} ${isK3D() ? KUBECTL_CONTEXT : ''}`.split(' ').filter(Boolean); + const proc = spawn(cmd, params, { stdio: ['ignore', 'pipe', 'pipe'] }); let receivedFirstLine; const firstLineReceivedPromise = new Promise(resolve => receivedFirstLine = resolve); @@ -1373,8 +1377,8 @@ const collectLogs = (container, ...regex) => { // after watching results in a race condition, where the log is created before watching started. // As a fix, watch the logs with tail=1, so we always receive one log line immediately, then proceed with next // steps of testing afterward. - const params = `logs ${container} -f --tail=1${isK3D() ? ` ${KUBECTL_CONTEXT}` : ''}`; - const proc = spawn(cmd, params.split(' '), { stdio: ['ignore', 'pipe', 'pipe'] }); + const params = `logs ${container} -f --tail=1 ${isK3D() ? KUBECTL_CONTEXT : ''}`.split(' ').filter(Boolean); + const proc = spawn(cmd, params, { stdio: ['ignore', 'pipe', 'pipe'] }); let receivedFirstLine; const firstLineReceivedPromise = new Promise(resolve => receivedFirstLine = resolve); @@ -1456,7 +1460,7 @@ const getContainerName = (service, project = PROJECT_NAME) => { return `deployment/cht-${service}`; }; -const updatePermissions = async (roles, addPermissions, removePermissions, ignoreReload) => { +const getUpdatedPermissions = async (roles, addPermissions, removePermissions) => { const settings = await getSettings(); addPermissions.forEach(permission => { if (!settings.permissions[permission]) { @@ -1466,7 +1470,12 @@ const updatePermissions = async (roles, addPermissions, removePermissions, ignor }); (removePermissions || []).forEach(permission => settings.permissions[permission] = []); - await updateSettings({ permissions: settings.permissions }, ignoreReload); + return settings.permissions; +}; + +const updatePermissions = async (roles, addPermissions, removePermissions, ignoreReload) => { + const permissions = await getUpdatedPermissions(roles, addPermissions, removePermissions); + await updateSettings({ permissions }, ignoreReload); }; const getSentinelDate = () => getContainerDate('sentinel'); @@ -1577,6 +1586,7 @@ module.exports = { apiLogTestEnd, updateContainerNames, updatePermissions, + getUpdatedPermissions, formDocProcessing, getSentinelDate, logFeedbackDocs, diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 7f7583f4914..0776c492a0a 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -55,6 +55,7 @@ "pouchdb-generate-replication-id": "^7.3.1", "rxjs": "^7.8.1", "select2": "4.0.3", + "signature_pad": "2.3.x", "simple-password-tester": "^1.0.0", "tslib": "^2.5.3", "uuid": "^9.0.1", @@ -76,8 +77,8 @@ "moment": "^2.29.1" } }, - "../shared-libs/cht-script-api": { - "name": "@medic/cht-script-api", + "../shared-libs/cht-datasource": { + "name": "@medic/cht-datasource", "version": "1.0.0", "extraneous": true, "license": "Apache-2.0" @@ -1384,6 +1385,11 @@ "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.3.tgz", "integrity": "sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg==" }, + "node_modules/enketo-core/node_modules/signature_pad": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-4.2.0.tgz", + "integrity": "sha512-YLWysmaUBaC5wosAKkgbX7XI+LBv2w5L0QUcI6Jc4moHYzv9BUBJtAyNLpWzHjtjKTeWOH6bfP4a4pzf0UinfQ==" + }, "node_modules/entities": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", @@ -1701,9 +1707,9 @@ } }, "node_modules/signature_pad": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-4.1.6.tgz", - "integrity": "sha512-eoZB8qFPfCs7o00weajp5roNnE2gY2kTNjZsh805L8V+lYPagxoZi9qrBFS3A6sgbVq++ukdzgruK7tuv3JFXQ==" + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz", + "integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==" }, "node_modules/simple-password-tester": { "version": "1.0.0", @@ -2825,6 +2831,11 @@ "version": "3.6.3", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.3.tgz", "integrity": "sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg==" + }, + "signature_pad": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-4.2.0.tgz", + "integrity": "sha512-YLWysmaUBaC5wosAKkgbX7XI+LBv2w5L0QUcI6Jc4moHYzv9BUBJtAyNLpWzHjtjKTeWOH6bfP4a4pzf0UinfQ==" } } }, @@ -3095,9 +3106,9 @@ } }, "signature_pad": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-4.1.6.tgz", - "integrity": "sha512-eoZB8qFPfCs7o00weajp5roNnE2gY2kTNjZsh805L8V+lYPagxoZi9qrBFS3A6sgbVq++ukdzgruK7tuv3JFXQ==" + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz", + "integrity": "sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==" }, "simple-password-tester": { "version": "1.0.0", diff --git a/webapp/package.json b/webapp/package.json index c2c65d70525..b8803eddfea 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -19,6 +19,7 @@ "unit:cht-form": "ng test cht-form", "unit": "UNIT_TEST_ENV=1 ng test webapp", "build": "ng build", + "build-watch": "npm run build -- --configuration=development --watch=true", "build:cht-form": "ng build cht-form", "compile": "ngc" }, @@ -68,6 +69,7 @@ "pouchdb-generate-replication-id": "^7.3.1", "rxjs": "^7.8.1", "select2": "4.0.3", + "signature_pad": "2.3.x", "simple-password-tester": "^1.0.0", "tslib": "^2.5.3", "uuid": "^9.0.1", diff --git a/webapp/src/css/enketo/_widgets.scss b/webapp/src/css/enketo/_widgets.scss index 780396f3cbf..72c79a9835b 100644 --- a/webapp/src/css/enketo/_widgets.scss +++ b/webapp/src/css/enketo/_widgets.scss @@ -3,6 +3,7 @@ @import "../../../node_modules/enketo-core/src/widget/table/tablewidget.scss"; @import "../../../node_modules/enketo-core/src/widget/radio/radiopicker.scss"; @import "../../../node_modules/enketo-core/src/widget/date/datepicker-extended.scss"; +@import "./draw.scss"; @import "../../../node_modules/enketo-core/src/widget/time/timepicker-extended.scss"; @import "../../../node_modules/enketo-core/src/widget/datetime/datetimepicker-extended.scss"; @import "../../../node_modules/enketo-core/src/widget/file/filepicker.scss"; diff --git a/webapp/src/css/enketo/draw.scss b/webapp/src/css/enketo/draw.scss new file mode 100644 index 00000000000..53c33c4f307 --- /dev/null +++ b/webapp/src/css/enketo/draw.scss @@ -0,0 +1,238 @@ +// Copied from https://github.com/enketo/enketo/blob/main/packages/enketo-core/src/widget/draw/draw-widget.scss +// After upgrading to enekto-core 8.1+, this widget should be removed and we should use the one from enketo-core. +// NOSONAR_BEGIN + +/* + * To save headaches with resizing canvases, it is important to maintain fixed aspect + * ratios at all times: + */ + +$ratio1: 0.75; +$ratio2: 0.45; +$fullscreen-margin-v: 50px; +$fullscreen-margin-h: 15px; +$picker-border: 2px solid grey; + +.or-drawing-initialized, +.or-signature-initialized, +.or-annotation-initialized { + input[type='text'] { + display: none; + } +} + +.or-signature-initialized { + .draw-widget__body { + padding-top: $ratio2 * 100%; + } +} + +.or-annotation-initialized { + // make space for absolutely positioned fake-file-input + .draw-widget { + margin-top: 50px; + } +} + +.draw-widget { + width: 100%; + + &__body { + position: relative; + width: 100%; // trick to fix aspect ratio with width of 100% to 4:3 + // combined with absolutely positioned canvas child + padding-top: $ratio1 * 100%; + + &__canvas { + background: white; + // for plain theme: + border: 1px solid $gray-lighter; // override border: + @include form-control; + + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + padding: 0; + width: 100%; + height: 100%; + + &.disabled { + cursor: not-allowed; + + ~ .draw-widget__colorpicker, + ~ .draw-widget__undo { + display: none; + } + + // show canvas normally (for readonly record views) + background: white; + opacity: 1; + } + } + + input[type='file'] { + display: none; + } + + .file-picker { + position: absolute; + top: -($fullscreen-margin-v); + left: 0; // TODO: RTL + width: 100%; + } + + .show-canvas-btn { + position: absolute; + z-index: 10; + top: calc(50% - 16px); + left: 50%; + width: 200px; + margin-left: -100px; + } + + .hide-canvas-btn { + display: none; + } + } + + &__footer { + margin-top: 10px; + + .draw-widget__btn-reset:disabled { + display: none; + } + } + + &__feedback { + @include question-error-message; + } + + &__undo { + position: absolute; + top: 37px; + right: 7px; + width: 20px; + height: 20px; + margin: 2px; + padding: 0; + border: $picker-border; + } + + &__colorpicker { + position: absolute; + display: flex; + flex-wrap: wrap; + max-width: calc(100% - (2 * 7px)); + top: 7px; + right: 7px; + + div { + display: none; + } + + div { // NOSONAR + width: 20px; + height: 20px; + margin: 2px; + border: none; + padding: 0; + } + + &.reveal div { + display: block; + } + + .current { + display: block; + border: $picker-border; + } + } + + &.full-screen { + @include display-flex; + + @include flex-direction(column); + + @include flex-wrap(nowrap); + + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 20; + background: white; + margin: 0; + padding-left: $fullscreen-margin-h; + padding-right: $fullscreen-margin-h; + + .draw-widget__body { + width: calc(100% - 2 * #{$fullscreen-margin-h}); + padding-top: calc( + #{$ratio1} * (100% - 2 * #{$fullscreen-margin-h}) + ); + margin: $fullscreen-margin-v auto; + + input[type='file'] { + left: 90px; // TODO: RTL + } + + .file-picker { + left: 80px; + width: calc(100% - 18px); + } + } + + .hide-canvas-btn { + display: block; + position: absolute; + z-index: 30; + top: -($fullscreen-margin-v - 10px); + left: 0; // TODO: RTL + width: 70px; + } + + .show-canvas-btn { + display: none; + } + + .draw-widget__footer { + width: calc(100vmin - 2 * #{$fullscreen-margin-h}); + margin: -($fullscreen-margin-v - 10px) auto 0 auto; + } + } + + .btn-download { + margin-right: 0; + + &[href=''] { + display: none; + } + } +} + +.or-signature-initialized { // NOSONAR + .draw-widget.full-screen { + .draw-widget__body { + width: calc(100% - 2 * #{$fullscreen-margin-h}); + padding-top: calc( + #{$ratio2} * (100% - 2 * #{$fullscreen-margin-h}) + ); + } + + .draw-widget__footer { + width: calc( + 100% - 2 * #{$fullscreen-margin-h} + ); //margin: -($fullscreen-margin-v - 10px) auto 0 auto; NOSONAR + } + } +} + +.or-annotate-initialized { + .draw-widget__body { + margin-top: $fullscreen-margin-v; + } +} +// NOSONAR_END diff --git a/webapp/src/css/enketo/medic.less b/webapp/src/css/enketo/medic.less index d3a1ab95802..d64dd58455c 100644 --- a/webapp/src/css/enketo/medic.less +++ b/webapp/src/css/enketo/medic.less @@ -60,14 +60,18 @@ .btn-primary { background-color: @button-color; } - .btn-icon-only { + .btn-icon-only:not(.draw-widget__undo) { padding: 0; width: inherit; } - .icon { + .icon:not(.draw-widget__undo .icon-undo) { position: relative; top: 1px; } + // Hide download button for draw widget + .draw-widget__footer .btn-download { + display: none; + } .or-group { margin: 15px 0 0 0; } diff --git a/webapp/src/js/enketo/file-manager.js b/webapp/src/js/enketo/file-manager.js index 4dd3138a73e..a2c480bef6d 100644 --- a/webapp/src/js/enketo/file-manager.js +++ b/webapp/src/js/enketo/file-manager.js @@ -1,6 +1,7 @@ 'use strict'; const fileManager = require( 'enketo-core/src/js/file-manager' ).default; const enketoConstants = require( './constants' ); +const windowLib = require('./lib/window'); fileManager.isTooLarge = function( file ) { return file && file.size > enketoConstants.maxAttachmentSize; @@ -10,5 +11,47 @@ fileManager.getMaxSizeReadable = function () { return enketoConstants.maxAttachmentSizeReadable; }; -// Exposing to overwrite enketo's file-manager with these file size defaults +const getCurrentDocId = () => { + const path = windowLib.getCurrentHref().split('/'); + if (path.at(-2) === 'edit') { + return path.at(-1); + } + if (path.at(-1) === 'edit') { + return path.at(-2); + } + return null; +}; + +// Used by the draw widget. Loads image file into the canvas to annotate. +// Also used to re-load draw images when editing a report. +fileManager.getObjectUrl = (subject) => { + if (subject && (typeof subject !== 'string' || /https?:\/\//.test(subject))) { + // Deliberately avoid calling fileManager.urlToBlob since it violates the CSP + return Promise.resolve(URL.createObjectURL(subject)); + } + + // If editing a report with a draw image, need to fetch the image attachment from the database + const currentDocId = getCurrentDocId(); + if (!subject || !currentDocId) { + return Promise.resolve(null); + } + // Do not need to support the legacy attachment names because the draw widget was added at the same time as + // the new attachment naming scheme. + const attachmentName = `user-file-${subject}`; + return window.CHTCore.DB + .get() + .getAttachment(currentDocId, attachmentName) + .then(blob => URL.createObjectURL(blob)) + .catch(e => { + if (e.status === 404) { + // eslint-disable-next-line no-console + console.error(`Could not find attachment [${attachmentName}] on doc [${currentDocId}].`); + return null; + } + + throw e; + }); +}; + +// Exposing to overwrite enketo's file-manager module.exports = fileManager; diff --git a/webapp/src/js/enketo/lib/window.js b/webapp/src/js/enketo/lib/window.js new file mode 100644 index 00000000000..cd65e5b7da5 --- /dev/null +++ b/webapp/src/js/enketo/lib/window.js @@ -0,0 +1,5 @@ +const getCurrentHref = () => window.location.href; + +module.exports = { + getCurrentHref, +}; diff --git a/webapp/src/js/enketo/widgets.js b/webapp/src/js/enketo/widgets.js index 133f829fc96..d37b694bcce 100644 --- a/webapp/src/js/enketo/widgets.js +++ b/webapp/src/js/enketo/widgets.js @@ -30,6 +30,7 @@ require( './widgets/android-app-launcher' ), require( './widgets/display-base64-image' ), require( './widgets/dynamic-url' ), + require( './widgets/draw' ), ]; module.exports = widgets; diff --git a/webapp/src/js/enketo/widgets/draw.js b/webapp/src/js/enketo/widgets/draw.js new file mode 100644 index 00000000000..0220f0a6950 --- /dev/null +++ b/webapp/src/js/enketo/widgets/draw.js @@ -0,0 +1,714 @@ +// Copied from https://github.com/enketo/enketo/blob/main/packages/enketo-core/src/widget/draw/draw-widget.js +// After upgrading to enekto-core 8.1+, this widget should be removed and we should use the one from enketo-core. +// NOSONAR_BEGIN +/* eslint-disable */ + +const $ = require( 'jquery' ); +const fileManager = require('enketo/file-manager'); +/** + * @external SignaturePad + */ +const SignaturePad = require('signature_pad').default; +const { t } = require('enketo/translator'); +const dialog = require('enketo-core/src/js/fake-dialog').default; +const support = require('enketo-core/src/js/support').default; +const events = require('enketo-core/src/js/event').default; +const Widget = require('enketo-core/src/js/widget').default; +const { dataUriToBlobSync, getFilename } = require('enketo-core/src/js/utils'); +const downloadUtils = require('enketo-core/src/js/download-utils').default; + +const DELAY = 1500; + +/** + * SignaturePad.prototype.fromDataURL is asynchronous and does not return + * a Promise. This is a rewrite returning a promise and the objectUrl. + * In addition it also fixes a bug where a loaded image is stretched to fit + * the canvas. + * + * @function external:SignaturePad#fromObjectURL + * @param {*} objectUrl - ObjectURL + * @param {object} options - options + * @param {number} [options.ratio] - ratio + * @param {number} [options.width] - width + * @param {number} [options.height] - height + * @return {Promise} a promise that resolves with an objectURL + */ +SignaturePad.prototype.fromObjectURL = function (objectUrl, options) { + const image = new Image(); + options = options || {}; + const deviceRatio = options.ratio || window.devicePixelRatio || 1; + const width = options.width || this._canvas.width / deviceRatio; + const height = options.height || this._canvas.height / deviceRatio; + const that = this; + + this._reset(); + + return new Promise((resolve) => { + image.src = objectUrl; + image.onload = () => { + const imgWidth = image.width; + const imgHeight = image.height; + const hRatio = width / imgWidth; + const vRatio = height / imgHeight; + let left; + let top; + + if (hRatio < 1 || vRatio < 1) { + // if image is bigger than canvas then fit within the canvas + const ratio = Math.min(hRatio, vRatio); + left = (width - imgWidth * ratio) / 2; + top = (height - imgHeight * ratio) / 2; + that._ctx.drawImage( + image, + 0, + 0, + imgWidth, + imgHeight, + left, + top, + imgWidth * ratio, + imgHeight * ratio + ); + } else { + // if image is smaller than canvas then show it in the center and don't stretch it + left = (width - imgWidth) / 2; + top = (height - imgHeight) / 2; + that._ctx.drawImage(image, left, top, imgWidth, imgHeight); + } + resolve(objectUrl); + }; + that._isEmpty = false; + }); +}; + +/** + * Similar to SignaturePad.prototype.fromData except that it doesn't clear the canvas. + * This is to facilitate undoing a drawing stroke over a background (bitmap) image. + * + * @function external:SignaturePad#updateData + * @param {*} pointGroups - pointGroups + */ +SignaturePad.prototype.updateData = function (pointGroups) { + const that = this; + this._fromData( + pointGroups, + (curve, widths) => { + that._drawCurve(curve, widths.start, widths.end); + }, + (rawPoint) => { + that._drawDot(rawPoint); + } + ); + + this._data = pointGroups; +}; + +/** + * Widget to obtain user-provided drawings or signature. + * + * @augments Widget + */ +class DrawWidget extends Widget { + /** + * @type {string} + */ + static get selector() { + // note that the selector needs to match both the pre-instantiated form and the post-instantiated form (type attribute changes) + return '.or-appearance-draw input[data-type-xml="binary"][accept^="image"], .or-appearance-signature input[data-type-xml="binary"][accept^="image"], .or-appearance-annotate input[data-type-xml="binary"][accept^="image"]'; + } + + _init() { + let canvas; + const that = this; + const existingFilename = this.element.dataset.loadedFileName; + + this.element.type = 'text'; + this.element.dataset.drawing = true; + + this.element.after(this._getMarkup()); + const { question } = this; + + question.classList.add(`or-${this.props.type}-initialized`); + + this.$widget = $(question.querySelector('.widget')); + + canvas = this.$widget[0].querySelector('.draw-widget__body__canvas'); + this._handleResize(canvas); + this._resizeCanvas(canvas); + + if (this.props.load) { + this._handleFiles(existingFilename); + } + + // This listener serves to capture a drawing when the submit button is clicked within [DELAY] + // milliseconds after the last stroke ended. Note that this could be the entire drawing/signature. + canvas.addEventListener('blur', this._forceUpdate.bind(this)); + + // We built a delay in saving on stroke "end", to avoid excessive updating + // This event does not fire on touchscreens for which we use the .hide-canvas-btn click + // to do the same thing. + + this.initialize = fileManager.init().then(() => { + that.pad = new SignaturePad(canvas, { + onEnd: () => { + // keep replacing this timer so continuous drawing + // doesn't update the value after every stroke. + clearTimeout(that._updateWithDelay); + that._updateWithDelay = setTimeout( + that._updateValue.bind(that), + DELAY + ); + }, + penColor: that.props.colors[0] || 'black', + }); + that.pad.off(); + if (existingFilename) { + that.element.value = existingFilename; + + return that + ._loadFileIntoPad(existingFilename) + .then(that._updateDownloadLink.bind(that)); + } + + return true; + }); + this.disable(); + this.initialize + .then(() => { + that.$widget + .find('.btn-reset') + .on('click', that._reset.bind(that)) + .end() + .find('.draw-widget__colorpicker') + .on('click', '.current', function () { + $(this).parent().toggleClass('reveal'); + }) + .on('click', '[data-color]:not(.current)', function () { + $(this) + .siblings() + .removeClass('current') + .end() + .addClass('current') + .parent() + .removeClass('reveal'); + that.pad.penColor = this.dataset.color; + }) + .end() + .find('.draw-widget__undo') + .on('click', () => { + const data = that.pad.toData(); + that.pad.clear(); + const fileInput = + that.$widget[0].querySelector('input[type=file]'); + // that.element.dataset.loadedFileName will have been removed only after resetting + const fileToLoad = + fileInput && fileInput.files[0] + ? fileInput.files[0] + : that.element.dataset.loadedFileName; + that._loadFileIntoPad(fileToLoad).then(() => { + that.pad.updateData(data.slice(0, -1)); + that._updateValue(); + that.pad.penColor = that.$widget.find( + '.draw-widget__colorpicker .current' + )[0].dataset.color; + }); + }) + .end() + .find('.show-canvas-btn') + .on('click', () => { + that.$widget.addClass('full-screen'); + that._resizeCanvas(canvas); + that.enable(); + + return false; + }) + .end() + .find('.hide-canvas-btn') + .on('click', () => { + that.$widget.removeClass('full-screen'); + that.pad.off(); + that._forceUpdate(); + that._resizeCanvas(canvas); + + return false; + }) + .click(); + + $(canvas).on('canvasreload', () => { + if (that.cache) { + that.pad + .fromObjectURL(that.cache) + .then(that._updateValue.bind(that, false)); + } + }); + that.enable(); + }) + .catch((error) => { + that._showFeedback(error.message); + }); + + $(this.element) + .on('applyfocus', () => { + canvas.focus(); + }) + .closest('[role="page"]') + .on(events.PageFlip().type, () => { + // When an existing value is loaded into the canvas and is not + // the first page, it won't become visible until the canvas is clicked + // or the window is resized: + // https://github.com/kobotoolbox/enketo-express/issues/895 + // This also fixes a similar issue with an empty canvas: + // https://github.com/kobotoolbox/enketo-express/issues/844 + that._resizeCanvas(canvas); + }); + } + + _forceUpdate() { + if (this._updateWithDelay) { + clearTimeout(this._updateWithDelay); + this._updateValue(); + } + } + + // All this is copied from the file-picker widget + /** + * @param {string} loadedFileName - the loaded filename + */ + _handleFiles(loadedFileName) { + // Monitor maxSize changes to update placeholder text in annotate widget. This facilitates asynchronous + // obtaining of max size from server without slowing down form loading. + this._updatePlaceholder(); + this.element + .closest('form.or') + .addEventListener( + events.UpdateMaxSize().type, + this._updatePlaceholder.bind(this) + ); + + const that = this; + + const $input = this.$widget.find('input[type=file]'); + const $fakeInput = this.$widget.find('.fake-file-input'); + + // show loaded file name or placeholder regardless of whether widget is supported + this._showFileName(loadedFileName); + + $input + .on('click', (event) => { + // The purpose of this handler is to block the filepicker window + // when the label is clicked outside of the input. + if (that.props.readonly || event.namespace !== 'propagate') { + that.$fakeInput.focus(); + event.stopImmediatePropagation(); + + return false; + } + }) + .on('change', function () { + // Get the file + const file = this.files[0]; + + if (file) { + // Process the file + if (!fileManager.isTooLarge(file)) { + // Update UI + that.pad.clear(); + that._loadFileIntoPad(this.files[0]).then(() => { + that._updateValue.call(that);// NOSONAR + that._showFileName(file.name); + that.enable(); + }); + } else { + that._showFeedback( + t('filepicker.toolargeerror', { + maxSize: fileManager.getMaxSizeReadable(), + }) + ); + } + } else { + that._showFileName(null); + } + }); + + $fakeInput + .on('click', function (event) { + /* + The purpose of this handler is to selectively propagate clicks on the fake + input to the underlying file input (to show the file picker window). + It blocks propagation if the filepicker has a value to avoid accidentally + clearing files in a loaded record, hereby blocking native browser file input behavior + to clear values. Instead the reset button is the only way to clear a value. + */ + if ( + that.props.readonly || + $input[0].value || + $fakeInput[0].value + ) { + $(this).focus(); + event.stopImmediatePropagation(); + + return false; + } + event.preventDefault(); + $input.trigger('click.propagate'); + }) + .on( + 'change', + () => + // For robustness, avoid any editing of filenames by user. + false + ); + } + + /** + * @param {string} fileName - filename to show + */ + _showFileName(fileName) { + this.$widget + .find('.fake-file-input') + .val(fileName) + .prop('readonly', !!fileName); + } + + /** + * Updates placeholder + */ + _updatePlaceholder() { + this.$widget.find('.fake-file-input').attr( + 'placeholder', + t('filepicker.placeholder', { + maxSize: fileManager.getMaxSizeReadable() || '?MB', + }) + ); + } + + /** + * @return {DocumentFragment} a document fragment with the widget markup + */ + _getMarkup() { + // HTML syntax copied from filepicker widget + const load = this.props.load + ? `
` + : ''; + const fullscreenBtns = this.props.touch + ? '' + + '' + : ''; + const fragment = document.createRange().createContextualFragment( + `
+
+ ${fullscreenBtns} + ${load} + +
+ ${ + this.props.type === 'signature' + ? '' + : '' + } +
+ +
` + ); + fragment + .querySelector('.draw-widget__footer') + .prepend(this.downloadButtonHtml); + fragment + .querySelector('.draw-widget__footer') + .prepend(this.resetButtonHtml); + + const colorpicker = fragment.querySelector('.draw-widget__colorpicker'); + + this.props.colors.forEach((color, index) => { + const current = index === 0 ? ' current' : ''; + const colorDiv = document + .createRange() + .createContextualFragment( + `
` + ); + colorpicker.append(colorDiv); + }); + + return fragment; + } + + /** + * Updates value + * + * @param {boolean} [changed] - whether the value has changed + */ + _updateValue(changed = true) { + const newValue = this.pad.toDataURL(); + if (this.value !== newValue) { + // Note that this.element has become a text input. + // When a default file is loaded this function is called by the canvasreload handler, but the user hasn't changed anything. + // We want to make sure the model remains unchanged in that case. + if (changed) { + // This code is slightly different from the Enekto draw widget since it contains a bug fix for: + // https://github.com/enketo/enketo/issues/1323 + const now = new Date(); + const postfix = `-${now.getHours()}_${now.getMinutes()}_${now.getSeconds()}`; + this.element.dataset.filenamePostfix = postfix; + this.originalInputValue = this.props.filename; + } + // pad.toData() doesn't seem to work when redrawing on a smaller canvas. Doesn't scale. + // pad.toDataURL() is crude and memory-heavy but the advantage is that it will also work for appearance=annotate + this.value = newValue; + this._updateDownloadLink(this.value); + } + } + + /** + * Clears pad, cache, loaded file name, download link and others + */ + _reset() { // NOSONAR + const that = this; + + if (this.element.value) { + // This discombobulated line is to help the i18next parser pick up all 3 keys. + const item = + this.props.type === 'signature' + ? t('drawwidget.signature') + : this.props.type === 'drawing' // NOSONAR + ? t('drawwidget.drawing') + : t('drawwidget.annotation'); + dialog + .confirm(t('filepicker.resetWarning', { item })) + .then((confirmed) => { + if (!confirmed) { + return; + } + that.pad.clear(); + that.cache = null; + // Only upon reset is loadedFileName removed, so that "undo" will work + // for drawings loaded from storage. + delete that.element.dataset.loadedFileName; + delete that.element.dataset.loadedUrl; + that.element.dataset.filenamePostfix = ''; + $(that.element).val('').trigger('change'); + if (that._updateWithDelay) { + // This ensures that an emptied canvas will not be considered a drawing to be captured + // in _forceUpdate, e.g. after the blur event fires on an empty canvas see issue #924 + that._updateWithDelay = null; + } + // Annotate file input + that.$widget + .find('input[type=file]') + .val('') + .trigger('change'); + that._updateDownloadLink(''); + that.disable(); + that.enable(); + }); + } + } + + /** + * @param {string|File} file - Either a filename or a file. + * @return {Promise} promise resolving with a string + */ + _loadFileIntoPad(file) { + const that = this; + if (!file) { + return Promise.resolve(''); + } + if ( + typeof file === 'string' && + file.startsWith('jr://') && + this.element.dataset.loadedUrl + ) { + file = this.element.dataset.loadedUrl; + } + + return fileManager + .getObjectUrl(file) + .then(that.pad.fromObjectURL.bind(that.pad)) + .then((objectUrl) => { + that.cache = objectUrl; + + return objectUrl; + }) + .catch(() => { + that._showFeedback( + 'File could not be loaded (leave unchanged if already submitted and you want to preserve it).', + 'error' + ); + }); + } + + /** + * @param {string} message - the feedback message to show + */ + _showFeedback(message) { + message = message || ''; + + // replace text and replace all existing classes with the new status class + this.$widget.find('.draw-widget__feedback').text(message); + } + + /** + * @param {string} url - the download URL + */ + _updateDownloadLink(url) { + if (url && url.indexOf('data:') === 0) { + url = URL.createObjectURL(dataUriToBlobSync(url)); + } + const fileName = url + ? getFilename( + { name: this.element.value }, + this.element.dataset.filenamePostfix + ) + : ''; + downloadUtils.updateDownloadLink( + this.$widget.find('.btn-download')[0], + url, + fileName + ); + } + + /** + * Forces update and resizes canvas on window resize + * + * @param {Element} canvas - Canvas element + */ + _handleResize(canvas) { + const that = this; + $(window).on('resize', () => { + // that._forceUpdate(); // NOSONAR + that._resizeCanvas(canvas); + }); + } + + /** + * Adjust canvas coordinate space taking into account pixel ratio, + * to make it look crisp on mobile devices. + * This also causes canvas to be cleared. + * + * @param {Element} canvas - Canvas element + */ + _resizeCanvas(canvas) { + // Use a little trick to avoid resizing currently-hidden canvases + // https://github.com/enketo/enketo-core/issues/605 + if (canvas.offsetWidth > 0) { + // When zoomed out to less than 100%, for some very strange reason, + // some browsers report devicePixelRatio as less than 1 + // and only part of the canvas is cleared then. + const ratio = Math.max(window.devicePixelRatio || 1, 1); + canvas.width = canvas.offsetWidth * ratio; + canvas.height = canvas.offsetHeight * ratio; + canvas.getContext('2d').scale(ratio, ratio); + $(canvas).trigger('canvasreload'); + } + } + + /** + * Disables widget + */ + disable() { + const that = this; + const canvas = this.$widget.find('.draw-widget__body__canvas')[0]; + + this.initialize.then(() => { + that.pad.off(); + canvas.classList.add('disabled'); + that.$widget.find('.btn-reset').prop('disabled', true); + }); + } + + /** + * Enables widget + */ + enable() { + const that = this; + const canvas = this.$widget.find('.draw-widget__body__canvas')[0]; + const touchNotFull = + this.props.touch && !this.$widget.is('.full-screen'); + const needFile = this.props.load && !this.element.value; + + this.initialize.then(() => { + if (!that.props.readonly && !needFile && !touchNotFull) { + that.pad.on(); + canvas.classList.remove('disabled'); + that.$widget.find('.btn-reset').prop('disabled', false); + } + // https://github.com/enketo/enketo-core/issues/450 + // When loading a question with a relevant, it is invisible + // until branch.js removes the "pre-init" class. The rendering of the + // canvas may therefore still be ongoing when this widget is instantiated. + // For that reason we call _resizeCanvas when enable is called to make + // sure the canvas is rendered properly. + that._resizeCanvas(canvas); + }); + } + + /** + * Updates value when it is programmatically cleared. + * There is no way to programmatically update a file input other than clearing it, so that's all + * we need to do. + */ + update() { + if (this.originalInputValue === '') { + this._reset(); + } + } + + /** + * @type {object} + */ + get props() { + const props = this._props; + + props.type = props.appearances.includes('draw') + ? 'drawing' + : props.appearances.includes('signature') // NOSONAR + ? 'signature' + : 'annotation'; + props.filename = `${props.type}.png`; + props.load = props.type === 'annotation'; + props.colors = + props.type === 'signature' + ? [] + : [ + 'black', + 'lightblue', + 'blue', + 'red', + 'orange', + 'cyan', + 'yellow', + 'lightgreen', + 'green', + 'pink', + 'purple', + 'lightgray', + 'darkgray', + ]; + props.touch = support.touch; + props.accept = this.element.getAttribute('accept'); + props.capture = this.element.getAttribute('capture'); + + return props; + } + + /** + * @type {string} + */ + get value() { + return this.cache || ''; + } + + set value(dataUrl) { + this.cache = dataUrl; + } +} + +module.exports = DrawWidget; +// NOSONAR_END diff --git a/webapp/src/ts/app.component.ts b/webapp/src/ts/app.component.ts index 3ccfb53cd77..a84bd314e29 100644 --- a/webapp/src/ts/app.component.ts +++ b/webapp/src/ts/app.component.ts @@ -39,7 +39,7 @@ import { TranslationDocsMatcherProvider } from '@mm-providers/translation-docs-m import { TranslateLocaleService } from '@mm-services/translate-locale.service'; import { TelemetryService } from '@mm-services/telemetry.service'; import { TransitionsService } from '@mm-services/transitions.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TranslateService } from '@mm-services/translate.service'; import { AnalyticsModulesService } from '@mm-services/analytics-modules.service'; import { AnalyticsActions } from '@mm-actions/analytics'; @@ -131,7 +131,7 @@ export class AppComponent implements OnInit, AfterViewInit { private performanceService:PerformanceService, private transitionsService:TransitionsService, private ngZone:NgZone, - private chtScriptApiService: CHTScriptApiService, + private chtDatasourceService: CHTDatasourceService, private analyticsModulesService: AnalyticsModulesService, private trainingCardsService: TrainingCardsService, private matIconRegistry: MatIconRegistry, @@ -279,7 +279,7 @@ export class AppComponent implements OnInit, AfterViewInit { // initialisation tasks that can occur after the UI has been rendered this.setupPromise = Promise.resolve() - .then(() => this.chtScriptApiService.isInitialized()) + .then(() => this.chtDatasourceService.isInitialized()) .then(() => this.checkPrivacyPolicy()) .then(() => (this.initialisationComplete = true)) .then(() => this.initRulesEngine()) diff --git a/webapp/src/ts/components/filters/analytics-filter/analytics-filter.component.ts b/webapp/src/ts/components/filters/analytics-filter/analytics-filter.component.ts index 48455811b2e..ee1034178f6 100644 --- a/webapp/src/ts/components/filters/analytics-filter/analytics-filter.component.ts +++ b/webapp/src/ts/components/filters/analytics-filter/analytics-filter.component.ts @@ -1,5 +1,5 @@ import { AfterContentChecked, AfterContentInit, Component, Input, OnDestroy } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { Subscription } from 'rxjs'; @Component({ @@ -12,8 +12,7 @@ export class AnalyticsFilterComponent implements AfterContentInit, AfterContentC subscriptions: Subscription = new Subscription(); constructor( - private router: Router, - private route: ActivatedRoute + private route: ActivatedRoute, ) { } ngAfterContentInit() { diff --git a/webapp/src/ts/components/sender/sender.component.html b/webapp/src/ts/components/sender/sender.component.html index 449777069ba..0be65075014 100644 --- a/webapp/src/ts/components/sender/sender.component.html +++ b/webapp/src/ts/components/sender/sender.component.html @@ -9,7 +9,7 @@ {{ getName() }} - {{ getName() || ( 'messages.unknown.sender' | translate ) }} + {{ getName() || ( 'messages.unknown.sender' | translate ) }} diff --git a/webapp/src/ts/effects/contacts.effects.ts b/webapp/src/ts/effects/contacts.effects.ts index 789e7c5c545..0a62f8ddcea 100644 --- a/webapp/src/ts/effects/contacts.effects.ts +++ b/webapp/src/ts/effects/contacts.effects.ts @@ -132,9 +132,22 @@ export class ContactsEffects { return this.contactIdToLoad !== id ? Promise.reject({code: 'SELECTED_CONTACT_CHANGED'}) : Promise.resolve(); } + private shouldGetDescendants(contactId, userFacilityId: string[] = []) { + if (!userFacilityId?.length) { + return true; + } + + if (userFacilityId.length > 1) { + return true; + } + + return userFacilityId[0] !== contactId; + } + private loadChildren(contactId, userFacilityId, trackName) { const trackPerformance = this.performanceService.track(); - const getChildPlaces = userFacilityId !== contactId; + const getChildPlaces = this.shouldGetDescendants(contactId, userFacilityId); + return this.contactViewModelGeneratorService .loadChildren(this.selectedContact, {getChildPlaces}) .then(children => { @@ -194,6 +207,10 @@ export class ContactsEffects { const selected = this.selectedContact; return this.contactSummaryService .get(selected.doc, selected.reports, selected.lineage, selected.targetDoc) + .catch(error => { + this.contactsActions.updateSelectedContactSummary({ errorStack: error.stack }); + throw error; + }) .then(summary => { return this .verifySelectedContactNotChanged(contactId) diff --git a/webapp/src/ts/effects/reports.effects.ts b/webapp/src/ts/effects/reports.effects.ts index f953a408179..b2899737bb0 100644 --- a/webapp/src/ts/effects/reports.effects.ts +++ b/webapp/src/ts/effects/reports.effects.ts @@ -67,6 +67,7 @@ export class ReportsEffects { name: [ 'report_detail', model?.doc?.form, 'load' ].join(':'), recordApdex: true, }); + this.trackOpenReport = null; } return of(this.reportActions.setRightActionBar()); diff --git a/webapp/src/ts/modals/edit-report/edit-report.component.ts b/webapp/src/ts/modals/edit-report/edit-report.component.ts index 0dc1c377109..a39448e99da 100644 --- a/webapp/src/ts/modals/edit-report/edit-report.component.ts +++ b/webapp/src/ts/modals/edit-report/edit-report.component.ts @@ -42,12 +42,12 @@ export class EditReportComponent implements AfterViewInit { return this.contactTypesService .getPersonTypes() .then(types => { - types = types.map(type => type.id); + const typeIds = types.map(type => type.id); const options = { allowNew: false, initialValue: this.report?.contact?._id || this.report?.from, }; - return this.select2SearchService.init(this.getSelectElement(), types, options); + return this.select2SearchService.init(this.getSelectElement(), typeIds, options); }) .catch(err => console.error('Error initialising select2', err)); } diff --git a/webapp/src/ts/modules/contacts/contacts-content.component.html b/webapp/src/ts/modules/contacts/contacts-content.component.html index f483ac5e022..fdfa9a99170 100644 --- a/webapp/src/ts/modules/contacts/contacts-content.component.html +++ b/webapp/src/ts/modules/contacts/contacts-content.component.html @@ -6,7 +6,7 @@
-
+
{{'No contact selected' | translate}}
@@ -14,7 +14,11 @@
{{'contact.select.error' | translate}}
-
+
+ +
+ +
diff --git a/webapp/src/ts/modules/contacts/contacts-content.component.ts b/webapp/src/ts/modules/contacts/contacts-content.component.ts index bed706499cc..89e4f17f197 100644 --- a/webapp/src/ts/modules/contacts/contacts-content.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-content.component.ts @@ -42,6 +42,7 @@ export class ContactsContentComponent implements OnInit, OnDestroy { selectedContact; contactsLoadingSummary; forms; + summaryErrorStack; loadingSelectedContactReports; reportsTimeWindowMonths; tasksTimeWindowWeeks; @@ -104,14 +105,14 @@ export class ContactsContentComponent implements OnInit, OnDestroy { private getUserFacility() { this.store.select(Selectors.getUserFacilityId) .pipe(first(id => id !== null)) - .subscribe((userFacilityId) => { - const shouldDisplayHomePlace = userFacilityId && + .subscribe((userFacilityIds) => { + const shouldDisplayHomePlace = userFacilityIds && !this.filters?.search && !this.route.snapshot.params.id && !this.responsiveService.isMobile(); if (shouldDisplayHomePlace) { - this.contactsActions.selectContact(userFacilityId); + this.contactsActions.selectContact(userFacilityIds[0]); } }); } @@ -179,7 +180,10 @@ export class ContactsContentComponent implements OnInit, OnDestroy { if (!summary || !this.selectedContact?.doc) { return; } - + if (summary.errorStack){ + this.summaryErrorStack = summary.errorStack; + return; + } this.subscribeToSelectedContactXmlForms(); }); this.subscription.add(contactSummarySubscription); @@ -242,14 +246,10 @@ export class ContactsContentComponent implements OnInit, OnDestroy { this.filteredReports = this.getFilteredReports(allReports, reportStartDate, this.DISPLAY_LIMIT); } - private getFilteredReports(allReports: any[], reportStartDate, displayLimit): any[] { + private getFilteredReports(allReports: any[], startDate, displayLimit): any[] { const filteredReports: any[] = []; for (const report of allReports) { - if (filteredReports.length >= displayLimit) { - break; - } - - if (reportStartDate?.isBefore(report.reported_date)) { + if (filteredReports.length < displayLimit && (!startDate || startDate.isBefore(report.reported_date))) { filteredReports.push(report); } } @@ -272,7 +272,7 @@ export class ContactsContentComponent implements OnInit, OnDestroy { relevantForms: [], // This disables the "New Action" button until forms load sendTo: this.selectedContact?.type?.person ? this.selectedContact?.doc : '', canDelete: this.canDeleteContact, - canEdit: this.isOnlineOnly || this.userSettings?.facility_id !== this.selectedContact?.doc?._id, + canEdit: this.isOnlineOnly || !this.userSettings?.facility_id?.includes(this.selectedContact?.doc?._id), openContactMutedModal: (form) => this.openContactMutedModal(form), openSendMessageModal: (sendTo) => this.openSendMessageModal(sendTo), }); diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 9610c01b29e..ad7823bfcc1 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -167,7 +167,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { throw new Error('Unknown form'); } - const titleKey = contact ? contactType.edit_key : contactType.create_key; + const titleKey = (contact ? contactType.edit_key : contactType.create_key) as string; this.setTitle(titleKey); const formInstance = await this.renderForm(formId, titleKey); this.setEnketoContact(formInstance); diff --git a/webapp/src/ts/modules/contacts/contacts-more-menu.component.html b/webapp/src/ts/modules/contacts/contacts-more-menu.component.html index f6fbb8c6b68..f80c14ddc26 100644 --- a/webapp/src/ts/modules/contacts/contacts-more-menu.component.html +++ b/webapp/src/ts/modules/contacts/contacts-more-menu.component.html @@ -12,7 +12,7 @@ {{ 'Edit' | translate }} - diff --git a/webapp/src/ts/modules/contacts/contacts-more-menu.component.ts b/webapp/src/ts/modules/contacts/contacts-more-menu.component.ts index 22ecce3b833..ace3a2369dd 100644 --- a/webapp/src/ts/modules/contacts/contacts-more-menu.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-more-menu.component.ts @@ -30,6 +30,7 @@ export class ContactsMoreMenuComponent implements OnInit, OnDestroy { selectedContactDoc; hasNestedContacts = false; + isUserFacility = false; contactsList; useOldActionBar = false; @@ -70,6 +71,7 @@ export class ContactsMoreMenuComponent implements OnInit, OnDestroy { this.selectedContactDoc = selectedContactDoc; this.loadingContent = loadingContent; this.snapshotData = snapshotData; + this.checkUserFacility(); }); this.subscription.add(storeSubscription); @@ -98,6 +100,13 @@ export class ContactsMoreMenuComponent implements OnInit, OnDestroy { .catch(error => console.error('Error fetching user settings', error)); } + private checkUserFacility() { + if (this.sessionService.isAdmin()) { + return false; + } + this.isUserFacility = this.userSettings?.facility_id.includes(this.selectedContactDoc?._id); + } + deleteContact() { this.globalActions.deleteDocConfirm(this.selectedContactDoc); } @@ -107,7 +116,7 @@ export class ContactsMoreMenuComponent implements OnInit, OnDestroy { && !this.loadingContent && this.snapshotData?.name === 'contacts.detail' && this.hasEditPermission - && (this.isOnlineOnly || this.userSettings?.facility_id !== this.selectedContactDoc?._id); + && (this.isOnlineOnly || !this.isUserFacility); } displayDeleteOption() { diff --git a/webapp/src/ts/modules/contacts/contacts.component.html b/webapp/src/ts/modules/contacts/contacts.component.html index ed7131ed101..485fe094453 100644 --- a/webapp/src/ts/modules/contacts/contacts.component.html +++ b/webapp/src/ts/modules/contacts/contacts.component.html @@ -11,7 +11,7 @@ 1) { + this.isAllowedToSort = false; + } this.lastVisitedDateExtras = viewLastVisitedDate; this.contactTypes = contactTypes; @@ -136,8 +140,8 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { this.contactsActions.removeContactFromList({ _id: change.id }); this.hasContacts = !!this.contactsList.length; } - if (this.usersHomePlace && change.id === this.usersHomePlace._id) { - this.usersHomePlace = await this.getUserHomePlaceSummary(change.id); + if (this.usersHomePlace?.find(homePlace => homePlace._id === change.id)) { + this.usersHomePlace = await this.getUserHomePlaceSummary(); } const withIds = this.isSortedByLastVisited() && @@ -218,31 +222,41 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { ); } - private getUserHomePlaceSummary(homePlaceId?: string) { + private getUserFacilityId(userSettings) { + return Array.isArray(userSettings.facility_id) ? userSettings.facility_id : [userSettings.facility_id]; + } + + private getUserHomePlaceSummary() { return this.userSettingsService .get() - .then((userSettings:any) => { - const facilityId: string = homePlaceId ?? userSettings.facility_id; - if (!facilityId) { + .then((userSettings: any) => { + const facilityId = this.getUserFacilityId(userSettings); + + if (!facilityId.length || facilityId.some(id => id === undefined)) { return; } this.globalActions.setUserFacilityId(facilityId); return this.getDataRecordsService - .get([ facilityId ]) - .then(places => places?.length ? places[0] : undefined); + .get(facilityId) + .then(places => { + const validPlaces = places?.filter(place => place !== undefined); + return validPlaces.length ? validPlaces : undefined; + }); }) - .then((summary) => { - if (summary) { - summary.home = true; + .then((homeplaces) => { + if (homeplaces) { + homeplaces.forEach(homeplace => { + homeplace.home = true; + }); } - return summary; + return homeplaces; }); } private canViewLastVisitedDate() { if (this.sessionService.isAdmin()) { - // disable UHC for DB admins + // disable UHC for DB admins return Promise.resolve(false); } return this.authService.has('can_view_last_visited_date'); @@ -265,13 +279,17 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { return this.contactTypesService.getTypeById(this.contactTypes, typeId); } + private isPrimaryContact(contact) { + return this.usersHomePlace && this.usersHomePlace.length === 1 && contact.home; + } + private populateContactDetails(contact, type) { contact.route = 'contacts'; contact.icon = type?.icon; contact.heading = contact.name || ''; contact.valid = true; contact.summary = null; - contact.primary = contact.home; + contact.primary = this.isPrimaryContact(contact); contact.dod = contact.date_of_death; } @@ -333,7 +351,7 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { if (this.usersHomePlace) { // backwards compatibility with pre-flexible hierarchy users - const homeType = this.contactTypesService.getTypeId(this.usersHomePlace); + const homeType = this.contactTypesService.getTypeId(this.usersHomePlace[0]); return this.contactTypesService .getChildren(homeType) .then(filterChildPlaces); @@ -362,6 +380,13 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { return this.sortDirection === 'last_visited_date'; } + private updateAdditionalListItem(homeIndex) { + this.additionalListItem = + !this.filters.search && + (this.additionalListItem || !this.appending) && + homeIndex === -1; + } + private query(opts?) { const trackPerformance = this.performanceService.track(); const options = Object.assign({ limit: this.PAGE_SIZE }, opts); @@ -415,23 +440,27 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { .then(updatedContacts => { // If you have a home place make sure its at the top if (this.usersHomePlace) { - const homeIndex = _findIndex(updatedContacts, (contact:any) => { - return contact._id === this.usersHomePlace._id; - }); - this.additionalListItem = - !this.filters.search && - (this.additionalListItem || !this.appending) && - homeIndex === -1; - - if (!this.appending) { - if (homeIndex !== -1) { - // move it to the top - updatedContacts.splice(homeIndex, 1); - updatedContacts.unshift(this.usersHomePlace); - } else if (!this.filters.search) { - updatedContacts.unshift(this.usersHomePlace); + this.usersHomePlace.forEach(homePlace => { + const homeIndex = _findIndex(updatedContacts, (contact: any) => contact._id === homePlace._id); + + this.updateAdditionalListItem(homeIndex); + + if (!this.appending) { + if (homeIndex !== -1) { + // move it to the top + const [homeContact] = updatedContacts.splice(homeIndex, 1); + updatedContacts.unshift(homeContact); + } else if (!this.filters.search) { + updatedContacts.unshift(homePlace); + } } - } + }); + } + + // only show homeplaces facilities for multi-facility users + if (this.usersHomePlace?.length > 1 && !this.filters.search) { + const homePlaceIds = this.usersHomePlace.map(place => place._id); + updatedContacts = updatedContacts.filter(place => homePlaceIds.includes(place._id)); } updatedContacts = this.formatContacts(updatedContacts); @@ -474,6 +503,10 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { return contact._id + contact._rev; } + private getUserHomePlaceId() { + return this.usersHomePlace?.[0]?._id; + } + private setLeftActionBar() { if (this.destroyed) { // don't update the actionbar if the component has already been destroyed @@ -484,7 +517,7 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { this.globalActions.setLeftActionBar({ exportFn: () => this.exportContacts(), hasResults: this.hasContacts, - userFacilityId: this.usersHomePlace?._id, + userFacilityId: this.getUserHomePlaceId(), childPlaces: this.allowedChildPlaces, }); } @@ -497,7 +530,7 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { } this.fastActionList = await this.fastActionButtonService.getContactLeftSideActions({ - parentFacilityId: this.usersHomePlace?._id, + parentFacilityId: this.getUserHomePlaceId(), childContactTypes: this.allowedChildPlaces, }); } diff --git a/webapp/src/ts/modules/messages/messages.component.ts b/webapp/src/ts/modules/messages/messages.component.ts index c4d009e4d80..ea4a807fee7 100644 --- a/webapp/src/ts/modules/messages/messages.component.ts +++ b/webapp/src/ts/modules/messages/messages.component.ts @@ -13,10 +13,9 @@ import { ExportService } from '@mm-services/export.service'; import { ModalService } from '@mm-services/modal.service'; import { SendMessageComponent } from '@mm-modals/send-message/send-message.component'; import { ResponsiveService } from '@mm-services/responsive.service'; -import { UserContactService } from '@mm-services/user-contact.service'; -import { AuthService } from '@mm-services/auth.service'; import { FastAction, FastActionButtonService } from '@mm-services/fast-action-button.service'; import { PerformanceService } from '@mm-services/performance.service'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; @Component({ templateUrl: './messages.component.html' @@ -33,7 +32,7 @@ export class MessagesComponent implements OnInit, OnDestroy { conversations: Record[] = []; error = false; trackPerformance; - currentLevel; + userLineageLevel; constructor( private router: Router, @@ -44,9 +43,8 @@ export class MessagesComponent implements OnInit, OnDestroy { private exportService: ExportService, private modalService: ModalService, private responsiveService: ResponsiveService, - private userContactService: UserContactService, - private authService: AuthService, - private performanceService: PerformanceService + private performanceService: PerformanceService, + private extractLineageService: ExtractLineageService, ) { this.globalActions = new GlobalActions(store); this.messagesActions = new MessagesActions(store); @@ -54,10 +52,8 @@ export class MessagesComponent implements OnInit, OnDestroy { ngOnInit() { this.trackPerformance = this.performanceService.track(); + this.userLineageLevel = this.extractLineageService.getUserLineageToRemove(); this.subscribeToStore(); - - this.currentLevel = this.authService.online(true) ? Promise.resolve() : this.getCurrentLineageLevel(); - this.updateConversations().then(() => this.displayFirstConversation(this.conversations)); this.watchForChanges(); } @@ -185,20 +181,14 @@ export class MessagesComponent implements OnInit, OnDestroy { updateConversations({ merge = false } = {}) { return Promise - .all([ this.messageContactService.getList(), this.currentLevel ]) - .then(([ conversations, currentLevel ]) => { - // Remove the lineage level that belongs to the offline logged-in user. - if (currentLevel) { - conversations?.forEach(conversation => { - if (!conversation.lineage?.length) { - return; - } - conversation.lineage = conversation.lineage.filter(level => level); - if (conversation.lineage[conversation.lineage.length -1] === currentLevel){ - conversation.lineage.pop(); - } - }); - } + .all([ this.messageContactService.getList(), this.userLineageLevel ]) + .then(([ conversations, userLineageLevel ]) => { + conversations?.forEach(conversation => { + const lineage = this.extractLineageService.removeUserFacility(conversation.lineage, userLineageLevel); + if (lineage) { + conversation.lineage = lineage; + } + }); this.setConversations(conversations, { merge }); this.loading = false; }); @@ -238,10 +228,6 @@ export class MessagesComponent implements OnInit, OnDestroy { return message.key + identifier; } - private getCurrentLineageLevel() { - return this.userContactService.get().then(user => user?.parent?.name); - } - exportMessages() { this.exportService.export('messages', {}, { humanReadable: true }); } diff --git a/webapp/src/ts/modules/reports/reports-add.component.ts b/webapp/src/ts/modules/reports/reports-add.component.ts index 0805407966f..e9d02552be1 100644 --- a/webapp/src/ts/modules/reports/reports-add.component.ts +++ b/webapp/src/ts/modules/reports/reports-add.component.ts @@ -167,30 +167,64 @@ export class ReportsAddComponent implements OnInit, OnDestroy, AfterViewInit { }); } + private getAttachment(docId, attachmentName) { + return this.dbService + .get() + .getAttachment(docId, attachmentName) + .catch(e => { + if (e.status === 404) { + console.error(`Could not find attachment [${attachmentName}] on doc [${docId}].`); + } else { + throw e; + } + }); + } + + private async getAttachmentForElement(docId, $element) { + const fileName = $element.data('loaded-file-name'); + if (fileName) { + const attachmentName = `user-file-${fileName}`; + const attachment = await this.getAttachment(docId, attachmentName); + if (attachment) { + return attachment; + } + } + + const legacyAttachmentName = `user-file${$element.attr('name')}`; + return this.getAttachment(docId, legacyAttachmentName); + } + private renderAttachmentPreviews(model) { return Promise .resolve() .then(() => Promise - .all($('#report-form input[type=file]') - .map((idx, element) => { + .all($('#report-form input[type="file"]:not(.draw-widget__load)') + .map(async (idx, element) => { const $element = $(element); - const attachmentName = 'user-file' + $element.attr('name'); - - return this.dbService - .get() - .getAttachment(model.doc._id, attachmentName) - .then(blob => this.fileReaderService.base64(blob)) - .then((base64) => { - const $picker = $element - .closest('.question') - .find('.widget.file-picker'); - - $picker.find('.file-feedback').empty(); - - const $preview = $picker.find('.file-preview'); - $preview.empty(); - $preview.append(''); - }); + const $picker = $element + .closest('.question') + .find('.widget.file-picker'); + + $picker + .find('.file-feedback') + .empty(); + + // Currently only support rendering image previews when editing reports + // https://github.com/medic/cht-core/issues/9165 + if ($element.attr('accept') !== 'image/*') { + return; + } + + const attachmentBlob = await this.getAttachmentForElement(model.doc._id, $element); + if (!attachmentBlob) { + return; + } + + const base64 = await this.fileReaderService.base64(attachmentBlob); + + const $preview = $picker.find('.file-preview'); + $preview.empty(); + $preview.append(''); }))); } diff --git a/webapp/src/ts/modules/reports/reports.component.ts b/webapp/src/ts/modules/reports/reports.component.ts index fe231001690..19e98f866b7 100644 --- a/webapp/src/ts/modules/reports/reports.component.ts +++ b/webapp/src/ts/modules/reports/reports.component.ts @@ -24,6 +24,7 @@ import { ModalService } from '@mm-services/modal.service'; import { FastAction, FastActionButtonService } from '@mm-services/fast-action-button.service'; import { XmlFormsService } from '@mm-services/xml-forms.service'; import { PerformanceService } from '@mm-services/performance.service'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; const PAGE_SIZE = 50; const CAN_DEFAULT_FACILITY_FILTER = 'can_default_facility_filter'; @@ -63,6 +64,7 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy { isExporting = false; userParentPlace; fastActionList?: FastAction[]; + userLineageLevel; LIMIT_SELECT_ALL_REPORTS = 500; @@ -85,6 +87,7 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy { private fastActionButtonService:FastActionButtonService, private xmlFormsService:XmlFormsService, private performanceService: PerformanceService, + private extractLineageService: ExtractLineageService, ) { this.globalActions = new GlobalActions(store); this.reportsActions = new ReportsActions(store); @@ -105,7 +108,7 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy { } async ngAfterViewInit() { - this.userParentPlace = await this.getUserParentPlace(); + this.userLineageLevel = this.extractLineageService.getUserLineageToRemove(); await this.checkPermissions(); this.subscribeSidebarFilter(); this.doInitialSearch(); @@ -241,7 +244,8 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy { return this.translateService.instant('report.subject.unknown'); } - private prepareReports(reports, isContent=false) { + private async prepareReports(reports, isContent=false) { + const userLineageLevel = await this.userLineageLevel; return reports.map(report => { const form = _find(this.forms, { code: report.form }); const subTitle = form ? form.title : report.form; @@ -251,14 +255,8 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy { report.heading = this.getReportHeading(form, report); report.lineage = report.subject && report.subject.lineage || report.lineage; report.unread = !report.read; - - // Remove the lineage level that belongs to the offline logged-in user - if (!this.isOnlineOnly && this.userParentPlace?.name && report?.lineage?.length) { - report.lineage = report.lineage.filter(level => level); - const item = report.lineage[report.lineage.length -1]; - if (item === this.userParentPlace.name) { - report.lineage.pop(); - } + if (Array.isArray(report.lineage)) { + report.lineage = this.extractLineageService.removeUserFacility(report.lineage, userLineageLevel); } return report; @@ -351,8 +349,12 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy { } } - private doInitialSearch() { - if (this.canDefaultFilter && this.userParentPlace?._id) { + private async doInitialSearch() { + if (this.canDefaultFilter) { + this.userParentPlace = await this.getUserParentPlace(); + } + + if (this.userParentPlace?._id) { // The facility filter will trigger the search. this.reportsSidebarFilter?.setDefaultFacilityFilter({ facility: this.userParentPlace }); return; @@ -470,7 +472,6 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy { this.filters, { limit: this.LIMIT_SELECT_ALL_REPORTS, hydrateContactNames: true } ); - const preparedReports = await this.prepareReports(reports, true); this.reportsActions.setSelectedReports(preparedReports); this.globalActions.unsetComponents(); diff --git a/webapp/src/ts/modules/tasks/tasks.component.ts b/webapp/src/ts/modules/tasks/tasks.component.ts index 0de6d2f7b70..a68c342b8dd 100644 --- a/webapp/src/ts/modules/tasks/tasks.component.ts +++ b/webapp/src/ts/modules/tasks/tasks.component.ts @@ -2,6 +2,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; import { combineLatest, Subscription } from 'rxjs'; import { debounce as _debounce } from 'lodash-es'; +import * as moment from 'moment'; import { ChangesService } from '@mm-services/changes.service'; import { ContactTypesService } from '@mm-services/contact-types.service'; @@ -10,9 +11,8 @@ import { TasksActions } from '@mm-actions/tasks'; import { Selectors } from '@mm-selectors/index'; import { GlobalActions } from '@mm-actions/global'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { UserContactService } from '@mm-services/user-contact.service'; -import { SessionService } from '@mm-services/session.service'; import { PerformanceService } from '@mm-services/performance.service'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; @Component({ templateUrl: './tasks.component.html', @@ -25,8 +25,7 @@ export class TasksComponent implements OnInit, OnDestroy { private rulesEngineService: RulesEngineService, private performanceService: PerformanceService, private lineageModelGeneratorService: LineageModelGeneratorService, - private userContactService: UserContactService, - private sessionService: SessionService, + private extractLineageService: ExtractLineageService, ) { this.tasksActions = new TasksActions(store); this.globalActions = new GlobalActions(store); @@ -35,7 +34,8 @@ export class TasksComponent implements OnInit, OnDestroy { subscription = new Subscription(); private tasksActions; private globalActions; - private trackPerformance; + private trackLoadPerformance; + private trackRefreshPerformance; tasksList; selectedTask; @@ -43,7 +43,7 @@ export class TasksComponent implements OnInit, OnDestroy { hasTasks; loading; tasksDisabled; - currentLevel; + userLineageLevel; private tasksLoaded; private debouncedReload; @@ -94,7 +94,7 @@ export class TasksComponent implements OnInit, OnDestroy { } ngOnInit() { - this.trackPerformance = this.performanceService.track(); + this.trackLoadPerformance = this.performanceService.track(); this.tasksActions.setSelectedTask(null); this.subscribeToStore(); this.subscribeToChanges(); @@ -102,14 +102,12 @@ export class TasksComponent implements OnInit, OnDestroy { this.hasTasks = false; this.loading = true; this.debouncedReload = _debounce(this.refreshTasks.bind(this), 1000, { maxWait: 10 * 1000 }); - - this.currentLevel = this.sessionService.isOnlineOnly() ? Promise.resolve() : this.getCurrentLineageLevel(); + this.userLineageLevel = this.extractLineageService.getUserLineageToRemove(); this.refreshTasks(); } ngOnDestroy() { this.subscription.unsubscribe(); - this.tasksActions.setTasksList([]); this.tasksActions.setTasksLoaded(false); this.tasksActions.setSelectedTask(null); @@ -121,14 +119,38 @@ export class TasksComponent implements OnInit, OnDestroy { window.location.reload(); } + private hydrateEmissions(taskDocs) { + return taskDocs.map(taskDoc => { + const emission = { ...taskDoc.emission }; + const dueDate = moment(emission.dueDate, 'YYYY-MM-DD'); + emission.date = new Date(dueDate.valueOf()); + emission.overdue = dueDate.isBefore(moment()); + emission.owner = taskDoc.owner; + + return emission; + }); + } + private async refreshTasks() { try { - this.tasksDisabled = !(await this.rulesEngineService.isEnabled()); - const taskDocs = this.tasksDisabled ? [] : await this.rulesEngineService.fetchTaskDocsForAllContacts() || []; + if (this.tasksLoaded) { + this.trackRefreshPerformance = this.performanceService.track(); + } + const isEnabled = await this.rulesEngineService.isEnabled(); + this.tasksDisabled = !isEnabled; + const taskDocs = isEnabled ? await this.rulesEngineService.fetchTaskDocsForAllContacts() : []; this.hasTasks = taskDocs.length > 0; - const userLineageLevel = await this.currentLevel; - const taskEmissions = await this.getTasksWithLineage(taskDocs, userLineageLevel); - this.tasksActions.setTasksList(taskEmissions); + + const hydratedTasks = await this.hydrateEmissions(taskDocs) || []; + const subjects = await this.getLineagesFromTaskDocs(hydratedTasks); + if (subjects?.size) { + const userLineageLevel = await this.userLineageLevel; + hydratedTasks.forEach(task => { + task.lineage = this.getTaskLineage(subjects, task, userLineageLevel); + }); + } + + this.tasksActions.setTasksList(hydratedTasks); } catch (exception) { console.error('Error getting tasks for all contacts', exception); @@ -137,57 +159,43 @@ export class TasksComponent implements OnInit, OnDestroy { this.tasksActions.setTasksList([]); } finally { this.loading = false; - const performanceName = this.tasksLoaded ? 'tasks:refresh' : 'tasks:load'; - this.trackPerformance?.stop({ - name: performanceName, - recordApdex: true, - }); + this.recordPerformance(); if (!this.tasksLoaded) { this.tasksActions.setTasksLoaded(true); } } } - listTrackBy(index, task) { - return task?._id; - } - - private getCurrentLineageLevel() { - return this.userContactService.get().then(user => user?.parent?.name); - } - - private async getTasksWithLineage(taskDocs, userLineageLevel) { - const ownerIds = [ ...new Set(taskDocs.map(task => task.owner)) ]; - const subjects = await this.lineageModelGeneratorService.reportSubjects(ownerIds); - const subjectLineageMap = new Map(); - subjects.forEach(subject => { - const taskLineage = subject.lineage - ?.filter(level => level?.name) - .map(level => level.name); - - if (taskLineage?.length) { - subjectLineageMap.set(subject._id, taskLineage); - } - }); + private recordPerformance() { + if (this.tasksLoaded) { + this.trackRefreshPerformance?.stop({ + name: ['tasks', 'refresh'].join(':'), + recordApdex: true, + }); + return; + } - return taskDocs.map(task => { - return { - ...task.emission, - lineage: this.removeCurrentLineage(subjectLineageMap.get(task.owner), userLineageLevel), - }; + this.trackLoadPerformance?.stop({ + name: ['tasks', 'load'].join(':'), + recordApdex: true, }); } - private removeCurrentLineage(taskLineage, userLineageLevel) { - if (!taskLineage?.length) { - return; - } + listTrackBy(index, task) { + return task?._id; + } - const lastLevel = taskLineage[taskLineage.length - 1]; - if (lastLevel === userLineageLevel) { - taskLineage.pop(); - } + private getLineagesFromTaskDocs(taskDocs) { + const ids = [ ...new Set(taskDocs.map(task => task.owner)) ]; + return this.lineageModelGeneratorService + .reportSubjects(ids) + .then(subjects => new Map(subjects.map(subject => [subject._id, subject.lineage]))); + } - return taskLineage; + private getTaskLineage(subjects, task, userLineageLevel) { + const lineage = subjects + .get(task.owner) + ?.map(lineage => lineage?.name); + return this.extractLineageService.removeUserFacility(lineage, userLineageLevel); } } diff --git a/webapp/src/ts/services/analytics-modules.service.ts b/webapp/src/ts/services/analytics-modules.service.ts index 38d7e41a446..37fcaa9b4c4 100644 --- a/webapp/src/ts/services/analytics-modules.service.ts +++ b/webapp/src/ts/services/analytics-modules.service.ts @@ -1,14 +1,15 @@ import { Injectable } from '@angular/core'; + import { SettingsService } from '@mm-services/settings.service'; -import { AuthService } from '@mm-services/auth.service'; +import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; @Injectable({ providedIn: 'root' }) export class AnalyticsModulesService { constructor( - private authService:AuthService, - private settingsService:SettingsService, + private settingsService: SettingsService, + private targetAggregatesService: TargetAggregatesService, ) { } private getTargetsModule(settings) { @@ -20,19 +21,19 @@ export class AnalyticsModulesService { }; } - private getTargetAggregatesModule (settings, canAggregateTargets) { + private getTargetAggregatesModule (settings, isAggregateEnabled) { return { id: 'target-aggregates', label: 'analytics.target.aggregates', route: ['/', 'analytics', 'target-aggregates'], - available: () => !!(settings.tasks && settings.tasks.targets && canAggregateTargets) + available: () => !!(settings?.tasks?.targets && isAggregateEnabled) }; } - private getModules(settings, canAggregateTargets) { + private getModules(settings, isAggregateEnabled) { return [ this.getTargetsModule(settings), - this.getTargetAggregatesModule(settings, canAggregateTargets), + this.getTargetAggregatesModule(settings, isAggregateEnabled), ].filter(module => module.available()); } @@ -40,10 +41,10 @@ export class AnalyticsModulesService { return Promise .all([ this.settingsService.get(), - this.authService.has('can_aggregate_targets') + this.targetAggregatesService.isEnabled(), ]) - .then(([settings, canAggregateTargets]) => { - const modules = this.getModules(settings, canAggregateTargets); + .then(([settings, isAggregateEnabled]) => { + const modules = this.getModules(settings, isAggregateEnabled); console.debug('AnalyticsModules. Enabled modules: ', modules.map(module => module.label)); return modules; }); diff --git a/webapp/src/ts/services/auth.service.ts b/webapp/src/ts/services/auth.service.ts index df181ee8b80..e746691a82c 100644 --- a/webapp/src/ts/services/auth.service.ts +++ b/webapp/src/ts/services/auth.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { SessionService } from '@mm-services/session.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; @Injectable({ providedIn: 'root' @@ -10,7 +10,7 @@ export class AuthService { constructor( private session: SessionService, - private chtScriptApiService: CHTScriptApiService + private chtDatasourceService: CHTDatasourceService ) { } /** @@ -21,8 +21,8 @@ export class AuthService { * @param permissions {string | string[]} */ has(permissions?: string | string[]): Promise { - return this.chtScriptApiService - .getApi() + return this.chtDatasourceService + .get() .then(chtApi => { const userCtx = this.session.userCtx(); @@ -53,8 +53,8 @@ export class AuthService { return this.has(permissionsGroupList); } - return this.chtScriptApiService - .getApi() + return this.chtDatasourceService + .get() .then(chtApi => { const userCtx = this.session.userCtx(); diff --git a/webapp/src/ts/services/cht-script-api.service.ts b/webapp/src/ts/services/cht-datasource.service.ts similarity index 67% rename from webapp/src/ts/services/cht-script-api.service.ts rename to webapp/src/ts/services/cht-datasource.service.ts index 25c5c7275fe..e3f41c41e1f 100644 --- a/webapp/src/ts/services/cht-script-api.service.ts +++ b/webapp/src/ts/services/cht-datasource.service.ts @@ -1,18 +1,20 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import * as chtScriptApiFactory from '@medic/cht-script-api'; +import { DataContext, getDatasource, getLocalDataContext, getRemoteDataContext } from '@medic/cht-datasource'; import { SettingsService } from '@mm-services/settings.service'; import { ChangesService } from '@mm-services/changes.service'; import { SessionService } from '@mm-services/session.service'; +import { DbService } from '@mm-services/db.service'; import { lastValueFrom } from 'rxjs'; @Injectable({ providedIn: 'root' }) -export class CHTScriptApiService { +export class CHTDatasourceService { private userCtx; + private dataContext!: DataContext; private settings; private initialized; private extensionLibs = {}; @@ -21,7 +23,8 @@ export class CHTScriptApiService { private http: HttpClient, private sessionService: SessionService, private settingsService: SettingsService, - private changesService: ChangesService + private changesService: ChangesService, + private dbService: DbService ) { } isInitialized() { @@ -35,14 +38,25 @@ export class CHTScriptApiService { private async init() { this.watchChanges(); this.userCtx = this.sessionService.userCtx(); - await Promise.all([ this.getSettings(), this.loadScripts() ]); + await Promise.all([this.getSettings(), this.loadScripts()]); + this.dataContext = await this.createDataContext(); + } + + private async createDataContext() { + if (this.sessionService.isOnlineOnly(this.userCtx)) { + return getRemoteDataContext(); + } + + const settingsService = { getAll: () => this.settings }; + const sourceDatabases = { medic: await this.dbService.get() }; + return getLocalDataContext(settingsService, sourceDatabases); } private async loadScripts() { try { - const request = this.http.get('/extension-libs', { responseType: 'json' }); + const request = this.http.get('/extension-libs', { responseType: 'json' }); const extensionLibs = await lastValueFrom(request); - if (extensionLibs && extensionLibs.length) { + if (extensionLibs?.length) { return Promise.all(extensionLibs.map(name => this.loadScript(name))); } } catch (e) { @@ -83,19 +97,27 @@ export class CHTScriptApiService { return user?.roles || this.userCtx?.roles; } - async getApi() { + async bind (fn: (ctx: DataContext) => T): Promise { + await this.isInitialized(); + return this.dataContext.bind(fn); + } + + async get() { await this.isInitialized(); + const dataSource = getDatasource(this.dataContext); return { + ...dataSource, v1: { + ...dataSource.v1, hasPermissions: (permissions, user?, chtSettings?) => { const userRoles = this.getRolesFromUser(user); const chtPermissionsSettings = this.getChtPermissionsFromSettings(chtSettings); - return chtScriptApiFactory.v1.hasPermissions(permissions, userRoles, chtPermissionsSettings); + return dataSource.v1.hasPermissions(permissions, userRoles, chtPermissionsSettings); }, hasAnyPermission: (permissionsGroupList, user?, chtSettings?) => { const userRoles = this.getRolesFromUser(user); const chtPermissionsSettings = this.getChtPermissionsFromSettings(chtSettings); - return chtScriptApiFactory.v1.hasAnyPermission(permissionsGroupList, userRoles, chtPermissionsSettings); + return dataSource.v1.hasAnyPermission(permissionsGroupList, userRoles, chtPermissionsSettings); }, getExtensionLib: (id) => { return this.extensionLibs[id]; diff --git a/webapp/src/ts/services/contact-summary.service.ts b/webapp/src/ts/services/contact-summary.service.ts index be8ca5d8b78..cd9612f2cad 100644 --- a/webapp/src/ts/services/contact-summary.service.ts +++ b/webapp/src/ts/services/contact-summary.service.ts @@ -4,7 +4,7 @@ import { SettingsService } from '@mm-services/settings.service'; import { PipesService } from '@mm-services/pipes.service'; import { UHCSettingsService } from '@mm-services/uhc-settings.service'; import { UHCStatsService } from '@mm-services/uhc-stats.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; /** * Service for generating summary information based on a given @@ -26,7 +26,7 @@ export class ContactSummaryService { private ngZone:NgZone, private uhcSettingsService:UHCSettingsService, private uhcStatsService:UHCStatsService, - private chtScriptApiService:CHTScriptApiService + private chtDatasourceService:CHTDatasourceService ) { } private getGeneratorFunction() { @@ -97,7 +97,7 @@ export class ContactSummaryService { uhcInterval: this.uhcStatsService.getUHCInterval(this.visitCountSettings) }; - const chtScriptApi = await this.chtScriptApiService.getApi(); + const chtScriptApi = await this.chtDatasourceService.get(); try { const summary = generatorFunction(contact, reports || [], lineage || [], uhcStats, chtScriptApi, targetDoc); diff --git a/webapp/src/ts/services/contacts.service.ts b/webapp/src/ts/services/contacts.service.ts index f1a2f065614..959ea197032 100644 --- a/webapp/src/ts/services/contacts.service.ts +++ b/webapp/src/ts/services/contacts.service.ts @@ -25,7 +25,7 @@ export class ContactsService { .then(types => { const cacheByType = {}; types.forEach(type => { - cacheByType[type.id] = this.cacheService.register({ + cacheByType[type.id as string] = this.cacheService.register({ get: (callback) => { return this.dbService .get() diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts index b33d777d7f5..007bc80bb27 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -1,7 +1,9 @@ import { Injectable, NgZone } from '@angular/core'; import { v4 as uuid } from 'uuid'; import * as pojo2xml from 'pojo2xml'; +import { Nullable, Person } from '@medic/cht-datasource'; import type JQuery from 'jquery'; +import * as FileManager from '../../js/enketo/file-manager.js'; import { Xpath } from '@mm-providers/xpath-element-path.provider'; import { AttachmentService } from '@mm-services/attachment.service'; @@ -158,7 +160,7 @@ export class EnketoService { instanceStr: instanceStr, }; if (contactSummaryXML) { - options.external = [ contactSummaryXML ]; + options.external = [contactSummaryXML]; } const form = wrapper.find('form')[0]; return new window.EnketoForm(form, options, { language: userSettings.language }); @@ -222,7 +224,7 @@ export class EnketoService { } // else the title is hardcoded in the form definition - leave it alone } - private setNavigation(form, $wrapper, useWindowHistory=true) { + private setNavigation(form, $wrapper, useWindowHistory = true) { if (useWindowHistory) { // Handle page turning using browser history window.history.replaceState({ enketo_page_number: 0 }, ''); @@ -460,32 +462,27 @@ export class EnketoService { } doc.hidden_fields = this.enketoTranslationService.getHiddenFieldList(record, dbDocTags); - const attach = (elem, file, type, alreadyEncoded, xpath?) => { - xpath = xpath || Xpath.getElementXPath(elem); + FileManager + .getCurrentFiles() + .forEach(file => this.attachmentService.add(doc, `user-file-${file.name}`, file, file.type, false)); + + const attachLegacyFile = (elem, file, type, alreadyEncoded) => { + const xpath = Xpath.getElementXPath(elem); // replace instance root element node name with form internal ID const filename = 'user-file' + (xpath.startsWith('/' + doc.form) ? xpath : xpath.replace(/^\/[^/]+/, '/' + doc.form)); this.attachmentService.add(doc, filename, file, type, alreadyEncoded); }; - $record - .find('[type=file]') - .each((idx, element) => { - const xpath = Xpath.getElementXPath(element); - const $input: any = $('input[type=file][name="' + xpath + '"]'); - const file = $input[0].files[0]; - if (file) { - attach(element, file, file.type, false, xpath); - } - }); - $record .find('[type=binary]') .each((idx, element) => { const file = $(element).text(); if (file) { - $(element).text(''); - attach(element, file, 'image/png', true); + // Attach binary file with legacy-style filename because the actual filename is not stored as the question + // value in the form model (and so there is currently no way to map the answer in a saved report to the + // associated file attachment). + attachLegacyFile(element, file, 'image/png', true); } }); @@ -513,7 +510,7 @@ export class EnketoService { } private create(formInternalId, contact) { - return { + return { form: formInternalId, type: 'data_record', content_type: 'xml', @@ -617,7 +614,7 @@ interface XmlFormContext { hasContactSummary: boolean; }; wrapper: JQuery; - instanceData: null|string|Record; // String for report forms, Record<> for contact forms. + instanceData: null | string | Record; // String for report forms, Record<> for contact forms. titleKey?: string; isFormInModal?: boolean; contactSummary?: Record; @@ -628,15 +625,15 @@ export class EnketoFormContext { formDoc: Record; type: string; // 'contact'|'report'|'task'|'training-card' editing?: boolean; - instanceData: null|string|Record; + instanceData: null | string | Record; editedListener?: () => void; valuechangeListener?: () => void; titleKey?: string; isFormInModal?: boolean; - userContact?: Record; - contactSummary? :Record; + userContact?: Nullable; + contactSummary?: Record; - constructor(selector:string, type:string, formDoc:Record, instanceData?) { + constructor(selector: string, type: string, formDoc: Record, instanceData?) { this.selector = selector; this.type = type; this.formDoc = formDoc; diff --git a/webapp/src/ts/services/extract-lineage.service.ts b/webapp/src/ts/services/extract-lineage.service.ts index ddd45feb87e..3bcaeb88de8 100644 --- a/webapp/src/ts/services/extract-lineage.service.ts +++ b/webapp/src/ts/services/extract-lineage.service.ts @@ -1,11 +1,19 @@ import { Injectable } from '@angular/core'; +import { UserSettingsService } from '@mm-services/user-settings.service'; +import { UserContactService } from '@mm-services/user-contact.service'; +import { AuthService } from '@mm-services/auth.service'; + @Injectable({ providedIn: 'root' }) export class ExtractLineageService { - constructor() { } + constructor( + private userSettingsService: UserSettingsService, + private userContactService: UserContactService, + private authService: AuthService, + ) { } extract(contact) { if (!contact) { @@ -23,4 +31,35 @@ export class ExtractLineageService { return result; } + + async getUserLineageToRemove(): Promise { + if (this.authService.online(true)) { + return null; + } + + const { facility_id }:any = await this.userSettingsService.get(); + if (!facility_id || (Array.isArray(facility_id) && facility_id.length > 1)) { + return null; + } + + const user = await this.userContactService.get(); + return user?.parent?.name as string; + } + + removeUserFacility(lineage: string[], userLineageLevel: string): string[] | undefined { + if (!lineage?.length) { + return; + } + + lineage = lineage.filter(level => level); + if (!userLineageLevel) { + return lineage; + } + + if (lineage[lineage.length - 1] === userLineageLevel) { + lineage.pop(); + } + + return lineage; + } } diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index a10d09c3e68..658580b3df6 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -18,7 +18,7 @@ import { ContactSummaryService } from '@mm-services/contact-summary.service'; import { TranslateService } from '@mm-services/translate.service'; import { TransitionsService } from '@mm-services/transitions.service'; import { GlobalActions } from '@mm-actions/global'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TrainingCardsService } from '@mm-services/training-cards.service'; import { EnketoFormContext, EnketoService } from '@mm-services/enketo.service'; import { UserSettingsService } from '@mm-services/user-settings.service'; @@ -54,7 +54,7 @@ export class FormService { private transitionsService: TransitionsService, private translateService: TranslateService, private ngZone: NgZone, - private chtScriptApiService: CHTScriptApiService, + private chtDatasourceService: CHTDatasourceService, private enketoService: EnketoService ) { this.inited = this.init(); @@ -73,7 +73,7 @@ export class FormService { } return Promise.all([ this.zScoreService.getScoreUtil(), - this.chtScriptApiService.getApi() + this.chtDatasourceService.get() ]) .then(([zscoreUtil, api]) => { medicXpathExtensions.init(zscoreUtil, toBik_text, moment, api); diff --git a/webapp/src/ts/services/format-data-record.service.ts b/webapp/src/ts/services/format-data-record.service.ts index 49bd0099286..84641f6e660 100644 --- a/webapp/src/ts/services/format-data-record.service.ts +++ b/webapp/src/ts/services/format-data-record.service.ts @@ -456,6 +456,23 @@ export class FormatDataRecordService { return result; } + private getImagePath(doc, label, value) { + if (!doc?._attachments) { + return undefined; + } + const isImagePath = filePath => doc._attachments[filePath]?.content_type?.startsWith('image/'); + const filePath = 'user-file-' + value; + if (isImagePath(filePath)) { + return filePath; + } + // Fall back to the old style of naming image attachments + const legacyFilePath = 'user-file/' + label.split('.').slice(1).join('/'); + if (isImagePath(legacyFilePath)) { + return legacyFilePath; + } + return undefined; + } + private getFields(doc, results, values, labelPrefix, depth) { if (depth > 3) { depth = 3; @@ -469,23 +486,13 @@ export class FormatDataRecordService { results.push({ label, depth }); this.getFields(doc, results, value, label, depth + 1); } else { - const result:any = { + results.push({ label, value, depth, target: this.getClickTarget(key, doc), - }; - - const filePath = 'user-file/' + label.split('.').slice(1).join('/'); - if (doc && - doc._attachments && - doc._attachments[filePath] && - doc._attachments[filePath].content_type && - doc._attachments[filePath].content_type.startsWith('image/')) { - result.imagePath = filePath; - } - - results.push(result); + imagePath: this.getImagePath(doc, label, value), + }); } }); return results; diff --git a/webapp/src/ts/services/place-hierarchy.service.ts b/webapp/src/ts/services/place-hierarchy.service.ts index 94625cb45ff..03084d3c587 100644 --- a/webapp/src/ts/services/place-hierarchy.service.ts +++ b/webapp/src/ts/services/place-hierarchy.service.ts @@ -81,7 +81,7 @@ export class PlaceHierarchyService { const ids: any[] = []; types.forEach(type => { if (type.parents) { - ids.push(...type.parents); + ids.push(...(type.parents as unknown[])); } }); return ids; diff --git a/webapp/src/ts/services/rules-engine.service.ts b/webapp/src/ts/services/rules-engine.service.ts index 5b8cb802edc..e0f4ca4cb16 100644 --- a/webapp/src/ts/services/rules-engine.service.ts +++ b/webapp/src/ts/services/rules-engine.service.ts @@ -18,7 +18,7 @@ import { ContactTypesService } from '@mm-services/contact-types.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; import { DbService } from '@mm-services/db.service'; import { CalendarIntervalService } from '@mm-services/calendar-interval.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TranslateService } from '@mm-services/translate.service'; import { PerformanceService } from '@mm-services/performance.service'; @@ -73,7 +73,7 @@ export class RulesEngineService implements OnDestroy { private rulesEngineCoreFactoryService:RulesEngineCoreFactoryService, private calendarIntervalService:CalendarIntervalService, private ngZone:NgZone, - private chtScriptApiService:CHTScriptApiService + private chtDatasourceService:CHTDatasourceService ) { this.initialized = this.initialize(); this.rulesEngineCore = this.rulesEngineCoreFactoryService.get(); @@ -104,7 +104,7 @@ export class RulesEngineService implements OnDestroy { this.settingsService.get(), this.userContactService.get(), this.userSettingsService.get(), - this.chtScriptApiService.getApi() + this.chtDatasourceService.get() ]) .then(([settingsDoc, userContactDoc, userSettingsDoc, chtScriptApi]) => { const rulesEngineContext = this.getRulesEngineContext( diff --git a/webapp/src/ts/services/target-aggregates.service.ts b/webapp/src/ts/services/target-aggregates.service.ts index dc2fca565e6..b450f622f39 100644 --- a/webapp/src/ts/services/target-aggregates.service.ts +++ b/webapp/src/ts/services/target-aggregates.service.ts @@ -228,13 +228,20 @@ export class TargetAggregatesService { }); } - private async getHomePlace() { + private async getUserFacilityIds(): Promise { const { facility_id }:any = await this.userSettingsService.get(); - if (!facility_id) { + if (!facility_id?.length) { return; } + return Array.isArray(facility_id) ? facility_id : [ facility_id ]; + } - const places = await this.getDataRecordsService.get([ facility_id ]); + private async getHomePlace() { + const facilityIds = await this.getUserFacilityIds(); + if (!facilityIds?.length) { + return; + } + const places = await this.getDataRecordsService.get(facilityIds); return places?.length ? places[0] : undefined; } @@ -269,8 +276,17 @@ export class TargetAggregatesService { }); } - isEnabled() { - return this.authService.has('can_aggregate_targets'); + async isEnabled() { + const canSeeAggregates = await this.authService.has('can_aggregate_targets'); + if (!canSeeAggregates) { + return false; + } + + const facilityIds = await this.getUserFacilityIds(); + + // When no facilities assigned, this returns true to display an error indicating to assign facilities to the user + // When more than one facility, this returns false to display an error about the module being disabled. + return !facilityIds || facilityIds.length <= 1; } getAggregates() { diff --git a/webapp/src/ts/services/transitions/create-user-for-contacts.transition.ts b/webapp/src/ts/services/transitions/create-user-for-contacts.transition.ts index 27ccbef42c1..50a0f1f5989 100644 --- a/webapp/src/ts/services/transitions/create-user-for-contacts.transition.ts +++ b/webapp/src/ts/services/transitions/create-user-for-contacts.transition.ts @@ -1,17 +1,18 @@ import { Injectable } from '@angular/core'; +import { Person, Place, Qualifier } from '@medic/cht-datasource'; -import { DbService } from '@mm-services/db.service'; -import { Transition, Doc } from '@mm-services/transitions/transition'; +import { Doc, Transition } from '@mm-services/transitions/transition'; import { CreateUserForContactsService } from '@mm-services/create-user-for-contacts.service'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { UserContactService } from '@mm-services/user-contact.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; @Injectable({ providedIn: 'root' }) export class CreateUserForContactsTransition extends Transition { constructor( - private dbService: DbService, + private chtDatasourceService: CHTDatasourceService, private createUserForContactsService: CreateUserForContactsService, private extractLineageService: ExtractLineageService, private userContactService: UserContactService, @@ -112,52 +113,42 @@ export class CreateUserForContactsTransition extends Transition { throw new Error('Only the contact associated with the currently logged in user can be replaced.'); } const newContact = await this.getNewContact(docs, replacementContactId); - if (!newContact) { - throw new Error(`The new contact could not be found [${replacementContactId}].`); - } this.createUserForContactsService.setReplaced(originalContact, newContact); docs.push(originalContact); await this.setPrimaryContactForParent(newContact, originalContactId, docs); } - private async setPrimaryContactForParent(newContact: ContactDoc, originalContactId: string, docs: Doc[]) { + private async setPrimaryContactForParent(newContact: Person.v1.Person, originalContactId: string, docs: Doc[]) { const parentPlace = await this.getParentDoc(newContact); if (parentPlace?.contact?._id === originalContactId) { - parentPlace.contact = this.extractLineageService.extract(newContact); - docs.push(parentPlace); + docs.push({ + ...parentPlace, + contact: this.extractLineageService.extract(newContact) + }); } } - private async getParentDoc(doc: ContactDoc) { + private async getParentDoc(doc: Person.v1.Person) { if (!doc.parent?._id) { return; } - return this.dbService - .get() - .get(doc.parent._id) - .catch(err => { - if (err.status === 404) { - return; - } - throw err; - }) as ContactDoc; + const getPlace = await this.chtDatasourceService.bind(Place.v1.get); + return getPlace(Qualifier.byUuid(doc.parent._id)); } - private async getNewContact(docs: Doc[], newContactId: string): Promise { + private async getNewContact(docs: Doc[], newContactId: string) { const newContact = docs.find(doc => doc._id === newContactId); if (newContact) { - return newContact as ContactDoc; + return newContact as Person.v1.Person; } - return this.dbService - .get() - .get(newContactId) - .catch(err => { - if (err.status === 404) { - return; - } - throw err; - }); + + const getPerson = await this.chtDatasourceService.bind(Person.v1.get); + const person = await getPerson(Qualifier.byUuid(newContactId)); + if (!person) { + throw new Error(`The new contact could not be found [${newContactId}].`); + } + return person; } private getReportDocsForContact(docs: Doc[], originalContactId: string) { @@ -191,9 +182,3 @@ interface ReportDoc extends Doc { _id: string; }; } - -interface ContactDoc extends ReportDoc { - parent: { - _id: string; - }; -} diff --git a/webapp/src/ts/services/user-contact.service.ts b/webapp/src/ts/services/user-contact.service.ts index 01c6c3b100b..913d9845e02 100644 --- a/webapp/src/ts/services/user-contact.service.ts +++ b/webapp/src/ts/services/user-contact.service.ts @@ -1,47 +1,36 @@ import { Injectable } from '@angular/core'; +import { Person, Qualifier } from '@medic/cht-datasource'; import { UserSettingsService } from '@mm-services/user-settings.service'; -import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { DbService } from '@mm-services/db.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; @Injectable({ providedIn: 'root' }) export class UserContactService { constructor( - private dbService: DbService, private userSettingsService: UserSettingsService, - private lineageModelGeneratorService: LineageModelGeneratorService, + private chtDatasourceService: CHTDatasourceService, ) { } async get({ hydrateLineage = true } = {}) { - try { - const user: any = await this.userSettingsService.get(); - if (!user.contact_id) { - return; - } - if (hydrateLineage) { - return await this.getContactWithLineage(user.contact_id); - } + const user: any = await this.getUserSettings(); + if (!user?.contact_id) { + return null; + } + const getPerson = await this.chtDatasourceService.bind(hydrateLineage ? Person.v1.getWithLineage : Person.v1.get); + return await getPerson(Qualifier.byUuid(user.contact_id)); + } - return await this.getContact(user.contact_id); + private getUserSettings = async () => { + try { + return await this.userSettingsService.get(); } catch (err) { if (err.code === 404) { - return; + return null; } throw err; } - } - - private async getContact(contactId) { - return this.dbService - .get() - .get(contactId); - } - - private async getContactWithLineage(contactId) { - const contact = await this.lineageModelGeneratorService.contact(contactId, { merge: true }); - return contact && contact.doc; - } + }; } diff --git a/webapp/tests/karma/js/enketo/file-manager.spec.ts b/webapp/tests/karma/js/enketo/file-manager.spec.ts new file mode 100644 index 00000000000..4755f072e78 --- /dev/null +++ b/webapp/tests/karma/js/enketo/file-manager.spec.ts @@ -0,0 +1,153 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import enketoConstants from '../../../../src/js/enketo/constants'; + +const fileManager = require('../../../../src/js/enketo/file-manager.js'); +const windowLib = require('../../../../src/js/enketo/lib/window.js'); + +describe('file-manager', () => { + afterEach(() => sinon.restore()); + + describe('isTooLarge', () => { + it('evaluates a file that is too large', () => { + const file = { size: enketoConstants.maxAttachmentSize + 1 }; + expect(fileManager.isTooLarge(file)).to.be.true; + }); + + it('evaluates a file that is not too large', () => { + const file = { size: enketoConstants.maxAttachmentSize }; + expect(fileManager.isTooLarge(file)).to.be.false; + }); + + it('evaluates a null file', () => { + expect(fileManager.isTooLarge(null)).to.be.null; + }); + }); + + it('getMaxSizeReadable', () => { + expect(fileManager.getMaxSizeReadable()).to.equal(enketoConstants.maxAttachmentSizeReadable); + }); + + describe('getObjectUrl', () => { + const fakeUrl = 'fake-url'; + let originalCHTCore; + let createObjectURL; + let getCurrentHref; + let dbGet; + let fakeDbService; + + before(() => originalCHTCore = window.CHTCore); + + beforeEach(() => { + createObjectURL = sinon.stub(URL, 'createObjectURL'); + getCurrentHref = sinon + .stub(windowLib, 'getCurrentHref') + .returns(''); + fakeDbService = { getAttachment: sinon.stub() }; + dbGet = sinon + .stub() + .returns(fakeDbService); + window.CHTCore = { DB: { get: dbGet } }; + }); + + after(() => window.CHTCore = originalCHTCore); + + it('resolves an object URL for a given file object', async () => { + const file = { hello: 'world' }; + createObjectURL.returns(fakeUrl); + + const objectURL = await fileManager.getObjectUrl(file); + + expect(objectURL).to.equal(fakeUrl); + expect(createObjectURL.calledOnceWithExactly(file)).to.be.true; + expect(getCurrentHref.notCalled).to.be.true; + expect(dbGet.notCalled).to.be.true; + }); + + it('resolves an object URL for a given URL string', async () => { + const url = 'https://example.com'; + createObjectURL.returns(fakeUrl); + + const objectURL = await fileManager.getObjectUrl(url); + + expect(objectURL).to.equal(fakeUrl); + expect(createObjectURL.calledOnceWithExactly(url)).to.be.true; + expect(getCurrentHref.notCalled).to.be.true; + expect(dbGet.notCalled).to.be.true; + }); + + [ + ['report', 'https://example.com/edit/report-id', 'report-id'], + ['contact', 'https://example.com/contact-id/edit', 'contact-id'] + ].forEach(([type, href, id]) => { + it(`resolves an object URL for an attachment name when editing a ${type}`, async () => { + const attachmentName = 'attachment-name.png'; + createObjectURL.returns(fakeUrl); + getCurrentHref.returns(href); + const blob = { hello: 'world' }; + fakeDbService.getAttachment.resolves(blob); + + const objectURL = await fileManager.getObjectUrl(attachmentName); + + expect(objectURL).to.equal(fakeUrl); + expect(createObjectURL.calledOnceWithExactly(blob)).to.be.true; + expect(getCurrentHref.calledOnceWithExactly()).to.be.true; + expect(dbGet.calledOnceWithExactly()).to.be.true; + expect(fakeDbService.getAttachment.calledOnceWithExactly(id, `user-file-${attachmentName}`)).to.be.true; + }); + }); + + it('resolves null when a null subject is provided', async () => { + getCurrentHref.returns('https://example.com/edit/report-id'); + + const objectURL = await fileManager.getObjectUrl(null); + + expect(objectURL).to.be.null; + expect(createObjectURL.notCalled).to.be.true; + expect(getCurrentHref.calledOnceWithExactly()).to.be.true; + expect(dbGet.notCalled).to.be.true; + }); + + it('resolves null for an attachment name when not editing a document', async () => { + const attachmentName = 'attachment-name.png'; + getCurrentHref.returns('https://example.com/add/pnc'); + + const objectURL = await fileManager.getObjectUrl(attachmentName); + + expect(objectURL).to.be.null; + expect(createObjectURL.notCalled).to.be.true; + expect(getCurrentHref.calledOnceWithExactly()).to.be.true; + expect(dbGet.notCalled).to.be.true; + }); + + it(`resolves null for an attachment name when no attachment found in the DB`, async () => { + const attachmentName = 'attachment-name.png'; + const reportId = 'report-id'; + getCurrentHref.returns(`https://example.com/edit/${reportId}`); + fakeDbService.getAttachment.rejects({ status: 404 }); + + const objectURL = await fileManager.getObjectUrl(attachmentName); + + expect(objectURL).to.be.null; + expect(createObjectURL.notCalled).to.be.true; + expect(getCurrentHref.calledOnceWithExactly()).to.be.true; + expect(dbGet.calledOnceWithExactly()).to.be.true; + expect(fakeDbService.getAttachment.calledOnceWithExactly(reportId, `user-file-${attachmentName}`)).to.be.true; + }); + + it(`rejects when an error is thrown getting the attachment from the DB`, async () => { + const attachmentName = 'attachment-name.png'; + const reportId = 'report-id'; + getCurrentHref.returns(`https://example.com/edit/${reportId}`); + const expectedError = new Error('DB error'); + fakeDbService.getAttachment.rejects(expectedError); + + await expect(fileManager.getObjectUrl(attachmentName)).to.be.rejectedWith(expectedError); + + expect(createObjectURL.notCalled).to.be.true; + expect(getCurrentHref.calledOnceWithExactly()).to.be.true; + expect(dbGet.calledOnceWithExactly()).to.be.true; + expect(fakeDbService.getAttachment.calledOnceWithExactly(reportId, `user-file-${attachmentName}`)).to.be.true; + }); + }); +}); diff --git a/webapp/tests/karma/ts/app.component.spec.ts b/webapp/tests/karma/ts/app.component.spec.ts index 9c3c471af8f..781dd688d3e 100644 --- a/webapp/tests/karma/ts/app.component.spec.ts +++ b/webapp/tests/karma/ts/app.component.spec.ts @@ -40,7 +40,7 @@ import { TranslateLocaleService } from '@mm-services/translate-locale.service'; import { BrowserDetectorService } from '@mm-services/browser-detector.service'; import { TelemetryService } from '@mm-services/telemetry.service'; import { TransitionsService } from '@mm-services/transitions.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { AnalyticsActions } from '@mm-actions/analytics'; import { AnalyticsModulesService } from '@mm-services/analytics-modules.service'; import { Selectors } from '@mm-selectors/index'; @@ -80,7 +80,7 @@ describe('AppComponent', () => { let translateLocaleService; let telemetryService; let transitionsService; - let chtScriptApiService; + let chtDatasourceService; let analyticsModulesService; let trainingCardsService; // End Services @@ -119,7 +119,7 @@ describe('AppComponent', () => { translateService = { instant: sinon.stub().returnsArg(0) }; modalService = { show: sinon.stub().resolves() }; browserDetectorService = { isUsingOutdatedBrowser: sinon.stub().returns(false) }; - chtScriptApiService = { isInitialized: sinon.stub() }; + chtDatasourceService = { isInitialized: sinon.stub() }; analyticsModulesService = { get: sinon.stub() }; databaseConnectionMonitorService = { listenForDatabaseClosed: sinon.stub().returns(of()) @@ -213,7 +213,7 @@ describe('AppComponent', () => { { provide: TranslateLocaleService, useValue: translateLocaleService }, { provide: TelemetryService, useValue: telemetryService }, { provide: TransitionsService, useValue: transitionsService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService }, + { provide: CHTDatasourceService, useValue: chtDatasourceService }, { provide: AnalyticsModulesService, useValue: analyticsModulesService }, { provide: TrainingCardsService, useValue: trainingCardsService }, { provide: Router, useValue: router }, @@ -250,7 +250,7 @@ describe('AppComponent', () => { // init rules engine expect(rulesEngineService.isEnabled.callCount).to.equal(1); // init CHTScriptApiService - expect(chtScriptApiService.isInitialized.callCount).to.equal(1); + expect(chtDatasourceService.isInitialized.callCount).to.equal(1); // init unread count expect(unreadRecordsService.init.callCount).to.equal(1); expect(unreadRecordsService.init.args[0][0]).to.be.a('Function'); @@ -643,7 +643,7 @@ describe('AppComponent', () => { })); it('should redirect to the error page when there is an exception', fakeAsync(async () => { - chtScriptApiService.isInitialized.throws({ error: 'some error'}); + chtDatasourceService.isInitialized.throws({ error: 'some error'}); await getComponent(); tick(); diff --git a/webapp/tests/karma/ts/effects/contacts.effects.spec.ts b/webapp/tests/karma/ts/effects/contacts.effects.spec.ts index 780f39ad2f9..baef4d52837 100644 --- a/webapp/tests/karma/ts/effects/contacts.effects.spec.ts +++ b/webapp/tests/karma/ts/effects/contacts.effects.spec.ts @@ -287,8 +287,8 @@ describe('Contacts effects', () => { ]]); }); - it('should not load child places for user facility', async () => { - store.overrideSelector(Selectors.getUserFacilityId, 'facility'); + it('should not load child places for user with one facility', async () => { + store.overrideSelector(Selectors.getUserFacilityId, ['facility']); store.refreshState(); contactViewModelGeneratorService.getContact.resolves({ _id: 'facility', doc: { _id: 'facility' } }); @@ -311,6 +311,30 @@ describe('Contacts effects', () => { ]]); }); + it('should load child places for multi-facility user', async () => { + store.overrideSelector(Selectors.getUserFacilityId, ['facility-1', 'facility-2' ]); + store.refreshState(); + + contactViewModelGeneratorService.getContact.resolves({ _id: 'facility-1', doc: { _id: 'facility-1' } }); + contactViewModelGeneratorService.loadChildren.resolves([ + { type: { id: 'patient' }, contacts: [{ _id: 'person1' }] }, + ]); + + actions$ = of(ContactActionList.selectContact({ id: 'facility-1' })); + await effects.selectContact.toPromise(); + + expect(contactViewModelGeneratorService.loadChildren.callCount).to.equal(1); + expect(contactViewModelGeneratorService.loadChildren.args[0]).to.deep.equal([ + { _id: 'facility-1', doc: { _id: 'facility-1' } }, + { getChildPlaces: true }, + ]); + const receiveSelectedContactChildren: any = ContactsActions.prototype.receiveSelectedContactChildren; + expect(receiveSelectedContactChildren.callCount).to.equal(1); + expect(receiveSelectedContactChildren.args[0]).to.deep.equal([[ + { type: { id: 'patient' }, contacts: [{ _id: 'person1' }] }, + ]]); + }); + it('should not receive children if the selected contact changes', async () => { contactViewModelGeneratorService.getContact.onFirstCall() .resolves({_id: 'contact1', doc: {_id: 'contact1'}}); diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts index 71c66fd36b6..8f3e72233f5 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts @@ -191,6 +191,7 @@ describe('Contacts content component', () => { flush(); expect(selectContact.callCount).to.equal(0); + expect(!!component.summaryErrorStack).to.be.false; })); }); @@ -205,6 +206,7 @@ describe('Contacts content component', () => { expect(selectContact.calledOnce).to.be.true; expect(selectContact.args[0][0]).to.equal('contact-1234'); + expect(!!component.summaryErrorStack).to.be.false; })); it(`should not load the user's home place when a search term exists`, fakeAsync(() => { @@ -215,11 +217,12 @@ describe('Contacts content component', () => { flush(); expect(selectContact.notCalled).to.be.true; + expect(!!component.summaryErrorStack).to.be.false; })); it(`should load the user's home place when a param id not set and no search term exists`, fakeAsync(() => { const selectContact = sinon.stub(ContactsActions.prototype, 'selectContact'); - store.overrideSelector(Selectors.getUserFacilityId, 'homeplace'); + store.overrideSelector(Selectors.getUserFacilityId, ['homeplace']); store.overrideSelector(Selectors.getFilters, undefined); activatedRoute.params = of({}); activatedRoute.snapshot.params = {}; @@ -228,6 +231,7 @@ describe('Contacts content component', () => { expect(selectContact.callCount).to.equal(1); expect(selectContact.args[0][0]).to.equal('homeplace'); + expect(!!component.summaryErrorStack).to.be.false; })); it('should unset selected contact when a param id not set and no search term exists', fakeAsync(() => { @@ -240,6 +244,7 @@ describe('Contacts content component', () => { expect(globalActions.unsetSelected.calledOnce).to.be.true; expect(clearSelectionStub.calledOnce).to.be.true; + expect(!!component.summaryErrorStack).to.be.false; })); describe('Change feed process', () => { @@ -271,6 +276,7 @@ describe('Contacts content component', () => { expect(contactChangeFilterService.matchContact.callCount).to.equal(2); expect(selectContact.callCount).to.equal(1); expect(selectContact.args[0][0]).to.equal('load contact'); + expect(!!component.summaryErrorStack).to.be.false; }); it('should redirect to parent when selected contact is deleted', () => { @@ -289,6 +295,7 @@ describe('Contacts content component', () => { expect(contactChangeFilterService.matchContact.callCount).to.equal(2); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'parent_id']]); + expect(!!component.summaryErrorStack).to.be.false; }); it('shoul clear when selected contact is deleted and has no parent', () => { @@ -301,6 +308,7 @@ describe('Contacts content component', () => { changesCallback(change); expect(contactChangeFilterService.matchContact.callCount).to.equal(2); expect(router.navigate.callCount).to.equal(1); + expect(!!component.summaryErrorStack).to.be.false; }); it('should update information when relevant contact change is received', () => { @@ -316,6 +324,7 @@ describe('Contacts content component', () => { expect(contactChangeFilterService.matchContact.callCount).to.equal(2); expect(contactChangeFilterService.isRelevantContact.callCount).to.equal(1); expect(selectContact.callCount).to.equal(1); + expect(!!component.summaryErrorStack).to.be.false; }); it('should update information when relevant report change is received', () => { @@ -333,6 +342,7 @@ describe('Contacts content component', () => { expect(contactChangeFilterService.isRelevantContact.callCount).to.equal(1); expect(contactChangeFilterService.isRelevantReport.callCount).to.equal(1); expect(selectContact.callCount).to.equal(1); + expect(!!component.summaryErrorStack).to.be.false; }); it('does not update information when irrelevant change is received', () => { @@ -348,6 +358,7 @@ describe('Contacts content component', () => { expect(contactChangeFilterService.isRelevantContact.callCount).to.equal(1); expect(contactChangeFilterService.isRelevantReport.callCount).to.equal(1); expect(selectContact.callCount).to.equal(0); + expect(!!component.summaryErrorStack).to.be.false; }); }); @@ -455,6 +466,7 @@ describe('Contacts content component', () => { expect(globalActions.setRightActionBar.callCount).to.equal(1); expect(globalActions.setRightActionBar.args[0][0].canDelete).to.equal(false); expect(globalActions.setRightActionBar.args[0][0].canEdit).to.equal(false); + expect(!!component.summaryErrorStack).to.be.false; })); it('should enable edit when user is not online only and facility is not home place ', fakeAsync(() => { @@ -479,6 +491,7 @@ describe('Contacts content component', () => { expect(globalActions.setRightActionBar.callCount).to.equal(1); expect(globalActions.setRightActionBar.args[0][0].canDelete).to.equal(false); expect(globalActions.setRightActionBar.args[0][0].canEdit).to.equal(true); + expect(!!component.summaryErrorStack).to.be.false; })); it('should enable delete when selected contact has no children', fakeAsync(() => { @@ -497,6 +510,7 @@ describe('Contacts content component', () => { expect(globalActions.setRightActionBar.callCount).to.equal(1); expect(globalActions.setRightActionBar.args[0][0].canDelete).to.equal(true); expect(globalActions.setRightActionBar.args[0][0].canEdit).to.equal(true); + expect(!!component.summaryErrorStack).to.be.false; })); it('should filter contact types to allowed ones from all contact forms', fakeAsync(() => { @@ -532,6 +546,7 @@ describe('Contacts content component', () => { component.ngOnInit(); flush(); + expect(!!component.summaryErrorStack).to.be.false; expect(xmlFormsService.subscribe.callCount).to.equal(2); expect(xmlFormsService.subscribe.args[0][0]).to.equal('SelectedContactChildrenForms'); expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ contactForms: true }); @@ -597,6 +612,7 @@ describe('Contacts content component', () => { component.ngOnInit(); flush(); + expect(!!component.summaryErrorStack).to.be.false; expect(xmlFormsService.subscribe.callCount).to.equal(2); expect(xmlFormsService.subscribe.args[1][0]).to.equal('SelectedContactReportForms'); expect(xmlFormsService.subscribe.args[1][1]).to.deep.equal({ @@ -686,6 +702,7 @@ describe('Contacts content component', () => { component.ngOnInit(); flush(); + expect(!!component.summaryErrorStack).to.be.false; expect(xmlFormsService.subscribe.callCount).to.equal(2); expect(xmlFormsService.subscribe.args[1][0]).to.equal('SelectedContactReportForms'); expect(xmlFormsService.subscribe.args[1][1]).to.deep.equal({ @@ -764,6 +781,7 @@ describe('Contacts content component', () => { xmlFormsService.subscribe.args[0][2](null, forms); + expect(!!component.summaryErrorStack).to.be.false; expect(globalActions.updateRightActionBar.callCount).to.equal(1); expect(globalActions.updateRightActionBar.args[0][0]).to.deep.equal({ relevantForms: [ @@ -823,6 +841,7 @@ describe('Contacts content component', () => { component.ngOnInit(); flush(); + expect(!!component.summaryErrorStack).to.be.false; expect(xmlFormsService.subscribe.callCount).to.equal(1); expect(xmlFormsService.subscribe.args[0][0]).to.equal('SelectedContactChildrenForms'); })); diff --git a/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts index 786d53b4771..dc773ae7e15 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts @@ -1270,4 +1270,53 @@ describe('Contacts component', () => { }); }); }); + + describe('Facility ID', () => { + it('supports user with multi-facility homeplaces', fakeAsync(() => { + sinon.resetHistory(); + const multi_facility = [{ + _id: 'district-id-1', + name: 'My District 1', + type: 'district_hospital' + }, + { + _id: 'district-id-2', + name: 'My District 2', + type: 'district_hospital' + }]; + + userSettingsService.get.resolves({ facility_id: [multi_facility[0]._id, multi_facility[1]._id] }); + getDataRecordsService.get.resolves(multi_facility); + + sinon.stub(ContactsActions.prototype, 'updateContactsList'); + component.ngOnInit(); + flush(); + const contacts = component.contactsActions.updateContactsList.args[0][0]; + + expect(contacts.length).to.equal(2); + expect(contacts[0]._id).to.equal('district-id-2'); + expect(contacts[1]._id).to.equal('district-id-1'); + expect(contacts[0].home).to.equal(true); + expect(contacts[1].home).to.equal(true); + expect(stopPerformanceTrackStub.args[1][0]).to.deep.equal({ + name: 'contact_list:load', + recordApdex: true, + }); + })); + + it('supports user with one facility homeplace', fakeAsync(() => { + sinon.resetHistory(); + sinon.stub(ContactsActions.prototype, 'updateContactsList'); + component.ngOnInit(); + flush(); + const contacts = component.contactsActions.updateContactsList.args[0][0]; + expect(contacts.length).to.equal(1); + expect(contacts[0]._id).to.equal('district-id'); + expect(contacts[0].home).to.equal(true); + expect(stopPerformanceTrackStub.args[1][0]).to.deep.equal({ + name: 'contact_list:load', + recordApdex: true, + }); + })); + }); }); diff --git a/webapp/tests/karma/ts/modules/messages/messages.component.spec.ts b/webapp/tests/karma/ts/modules/messages/messages.component.spec.ts index ce6f55dea15..d4471e83c2b 100644 --- a/webapp/tests/karma/ts/modules/messages/messages.component.spec.ts +++ b/webapp/tests/karma/ts/modules/messages/messages.component.spec.ts @@ -15,39 +15,29 @@ import { SettingsService } from '@mm-services/settings.service'; import { ModalService } from '@mm-services/modal.service'; import { NavigationComponent } from '@mm-components/navigation/navigation.component'; import { NavigationService } from '@mm-services/navigation.service'; -import { UserContactService } from '@mm-services/user-contact.service'; -import { AuthService } from '@mm-services/auth.service'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { FastActionButtonService } from '@mm-services/fast-action-button.service'; import { SendMessageComponent } from '@mm-modals/send-message/send-message.component'; import { MessagesMoreMenuComponent } from '@mm-modules/messages/messages-more-menu.component'; import { SessionService } from '@mm-services/session.service'; import { FastActionButtonComponent } from '@mm-components/fast-action-button/fast-action-button.component'; import { PerformanceService } from '@mm-services/performance.service'; +import { ExportService } from '@mm-services/export.service'; +import { AuthService } from '@mm-services/auth.service'; describe('Messages Component', () => { let component: MessagesComponent; let fixture: ComponentFixture; let messageContactService; let changesService; - let exportService; let modalService; - let userContactService; let fastActionButtonService; let authService; let sessionService; let performanceService; + let extractLineageService; let stopPerformanceTrackStub; - const userContactGrandparent = { _id: 'grandparent' }; - const userContactDoc = { - _id: 'user', - parent: { - _id: 'parent', - name: 'parent', - parent: userContactGrandparent, - }, - }; - beforeEach(waitForAsync(() => { stopPerformanceTrackStub = sinon.stub(); performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) }; @@ -57,9 +47,6 @@ describe('Messages Component', () => { isRelevantChange: sinon.stub() }; changesService = { subscribe: sinon.stub().returns({ unsubscribe: sinon.stub() }) }; - userContactService = { - get: sinon.stub().resolves(userContactDoc), - }; fastActionButtonService = { getMessageActions: sinon.stub(), getButtonTypeForContentList: sinon.stub(), @@ -70,6 +57,10 @@ describe('Messages Component', () => { has: sinon.stub() }; sessionService = { isAdmin: sinon.stub() }; + extractLineageService = { + getUserLineageToRemove: sinon.stub(), + removeUserFacility: ExtractLineageService.prototype.removeUserFacility, + }; const mockedSelectors = [ { selector: 'getSelectedConversation', value: {} }, { selector: 'getLoadingContent', value: false }, @@ -94,14 +85,14 @@ describe('Messages Component', () => { { provide: ChangesService, useValue: changesService }, { provide: MessageContactService, useValue: messageContactService }, { provide: SettingsService, useValue: {} }, // Needed because of ngx-translate provider's constructor. - { provide: exportService, useValue: {} }, + { provide: ExportService, useValue: {} }, { provide: SessionService, useValue: sessionService }, { provide: ModalService, useValue: modalService }, { provide: NavigationService, useValue: {} }, - { provide: UserContactService, useValue: userContactService }, { provide: AuthService, useValue: authService }, { provide: FastActionButtonService, useValue: fastActionButtonService }, { provide: PerformanceService, useValue: performanceService }, + { provide: ExtractLineageService, useValue: extractLineageService }, { provide: MatBottomSheet, useValue: { open: sinon.stub() } }, { provide: MatDialog, useValue: { open: sinon.stub() } }, ] @@ -244,15 +235,7 @@ describe('Messages Component', () => { }); describe('Messages breadcrumbs', () => { - const bettyOfflineUserContactDoc = { - _id: 'user', - parent: { - _id: 'parent', - name: 'CHW Bettys Area', - parent: userContactGrandparent, - }, - }; - const conversations = [ + const conversations = [ { key: 'a', message: { inAllMessages: true }, lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village', 'CHW Bettys Area', null] @@ -274,33 +257,20 @@ describe('Messages Component', () => { }, ]; - it('it should retrieve the hierarchy level of the connected user', async () => { - expect(await component.currentLevel).to.equal('parent'); - }); - - it('should not alter conversations when user is offline and parent place is not relevant to the conversation', - fakeAsync(() => { - sinon.resetHistory(); + it('it should retrieve the hierarchy level of the connected user', fakeAsync(async () => { + extractLineageService.getUserLineageToRemove.resolves('CHW Bettys Area'); - messageContactService.getList.resolves(conversations); - userContactService.get.resolves(userContactDoc); - authService.online.returns(false); - - component.ngOnInit(); - tick(); - component.updateConversations({ merge: true }); - tick(); + component.ngOnInit(); + tick(); - expect(component.conversations).to.deep.equal(conversations); - })); + expect(await component.userLineageLevel).to.equal('CHW Bettys Area'); + })); - it('should not change the conversations lineage if the connected user is online only', fakeAsync(() => { + it('should not remove the lineage when user lineage level is undefined', fakeAsync(() => { sinon.resetHistory(); messageContactService.getList.resolves(conversations); - userContactService.get.resolves(bettyOfflineUserContactDoc); - authService.online.returns(true); - + extractLineageService.getUserLineageToRemove.resolves(undefined); component.ngOnInit(); tick(); component.updateConversations({ merge: true }); @@ -309,42 +279,39 @@ describe('Messages Component', () => { expect(component.conversations).to.deep.equal(conversations); })); - it('should remove current level from lineage when user is offline and parent place relevant to the conversation', - fakeAsync(() => { - sinon.resetHistory(); - const updatedConversations = [ - { key: 'a', - message: { inAllMessages: true }, - lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village'] - }, - { key: 'b', - message: { inAllMessages: true }, - lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village'] - }, - { key: 'c', - message: { inAllMessages: true }, - lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village', 'Ramdom Place'] - }, - { key: 'd', - message: { inAllMessages: true }, - lineage: [] - }, - { key: 'e', - message: { inAllMessages: true }, - }, - ]; - - messageContactService.getList.resolves(conversations); - userContactService.get.resolves(bettyOfflineUserContactDoc); - authService.online.returns(false); - - component.ngOnInit(); - tick(); - component.updateConversations({ merge: true }); - tick(); - - expect(component.conversations).to.deep.equal(updatedConversations); - })); + it('should remove lineage when user lineage level is defined', fakeAsync(() => { + sinon.resetHistory(); + const updatedConversations = [ + { key: 'a', + message: { inAllMessages: true }, + lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village'] + }, + { key: 'b', + message: { inAllMessages: true }, + lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village'] + }, + { key: 'c', + message: { inAllMessages: true }, + lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village', 'Ramdom Place'] + }, + { key: 'd', + message: { inAllMessages: true }, + lineage: [] + }, + { key: 'e', + message: { inAllMessages: true }, + }, + ]; + + extractLineageService.getUserLineageToRemove.resolves('CHW Bettys Area'); + messageContactService.getList.resolves(conversations); + component.ngOnInit(); + tick(); + component.updateConversations({ merge: true }); + tick(); + + expect(component.conversations).to.deep.equal(updatedConversations); + })); }); }); diff --git a/webapp/tests/karma/ts/modules/reports/reports-add.component.spec.ts b/webapp/tests/karma/ts/modules/reports/reports-add.component.spec.ts index b0d540c258e..b0a61a439c8 100644 --- a/webapp/tests/karma/ts/modules/reports/reports-add.component.spec.ts +++ b/webapp/tests/karma/ts/modules/reports/reports-add.component.spec.ts @@ -37,6 +37,8 @@ describe('Reports Add Component', () => { let stopPerformanceTrackStub; let performanceService; let route; + let debug; + let error; beforeEach(waitForAsync(() => { dbService = { getAttachment: sinon.stub() }; @@ -58,6 +60,8 @@ describe('Reports Add Component', () => { router = { navigate: sinon.stub() }; stopPerformanceTrackStub = sinon.stub(); performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) }; + debug = sinon.spy(console, 'debug'); + error = sinon.spy(console, 'error'); const mockedSelectors = [ { selector: Selectors.getLoadingContent, value: false }, @@ -249,7 +253,6 @@ describe('Reports Add Component', () => { })); it('should catch form reading errors', fakeAsync(() => { - const consoleErrorMock = sinon.stub(console, 'error'); sinon.resetHistory(); xmlFormsService.get.rejects({ error: 'boom' }); @@ -258,12 +261,11 @@ describe('Reports Add Component', () => { expect(xmlFormsService.get.callCount).to.equal(1); expect(formService.render.callCount).to.equal(0); - expect(consoleErrorMock.callCount).to.equal(1); - expect(consoleErrorMock.args[0][0]).to.equal('Error setting selected doc'); + expect(error.callCount).to.equal(1); + expect(error.args[0][0]).to.equal('Error setting selected doc'); })); it('should catch enketo errors', fakeAsync(() => { - const consoleErrorMock = sinon.stub(console, 'error'); sinon.resetHistory(); getReportContentService.getReportContent.resolves(); xmlFormsService.get.resolves({ _id: 'my_form', some: 'content' }); @@ -275,13 +277,272 @@ describe('Reports Add Component', () => { expect(xmlFormsService.get.callCount).to.equal(1); expect(formService.render.callCount).to.equal(1); expect(component.form).to.equal(undefined); - expect(consoleErrorMock.callCount).to.equal(1); - expect(consoleErrorMock.args[0][0]).to.equal('Error loading form.'); + expect(error.callCount).to.equal(1); + expect(error.args[0][0]).to.equal('Error loading form.'); })); }); describe('for existent reports', () => { - // todo add tests here when migrating the final code about editing reports + const imageElementName = '/enketo_widgets/media_widgets/image'; + const imageAttachmentName = 'Screenshot-12_3_42.png'; + const getFileInputHTML = (accept, loadedFileName?) => $.parseHTML(``); + const fileInputSelector = '#report-form input[type="file"]:not(.draw-widget__load)'; + + let jqStub; + let jqMap; + let jqFeedbackElement; + let jqPreviewElement; + + let openReportContent; + let setLoadingContent; + let setEnketoEditedStatus; + let setEnketoError; + + beforeEach(() => { + sinon.resetHistory(); + + const jqFind = $.fn.find; + jqStub = sinon + .stub($.fn, 'find') + .callsFake(jqFind); + jqMap = sinon + .stub() + .returns([]); + jqStub + .withArgs(fileInputSelector) + .returns({ map: jqMap }); + jqFeedbackElement = { empty: sinon.stub() }; + jqStub + .withArgs('.file-feedback') + .returns(jqFeedbackElement); + jqPreviewElement = { + empty: sinon.stub(), + append: sinon.stub(), + }; + jqStub + .withArgs('.file-preview') + .returns(jqPreviewElement); + + openReportContent = sinon.stub(ReportsActions.prototype, 'openReportContent'); + setLoadingContent = sinon.stub(GlobalActions.prototype, 'setLoadingContent'); + setEnketoEditedStatus = sinon.stub(GlobalActions.prototype, 'setEnketoEditedStatus'); + setEnketoError = sinon.stub(GlobalActions.prototype, 'setEnketoError'); + }); + + it('loads form', fakeAsync(() => { + const reportId = 'my_report'; + route.snapshot.params = { reportId }; + const doc = { _id: 'report-id', form: 'my-form' }; + lineageModelGeneratorService.report.resolves({ doc }); + const model = { doc, formInternalId: doc.form }; + const reportContent = { hello: 'world' }; + getReportContentService.getReportContent.resolves(reportContent); + const xmlForm = { _id: 'my_form', some: 'content' }; + xmlFormsService.get.resolves(xmlForm); + const renderedForm = { rendered: 'form', model: {}, instance: {} }; + formService.render.resolves(renderedForm); + + component.ngAfterViewInit(); + tick(); + + expect(geoHandle.cancel.calledOnceWithExactly()).to.be.true; + expect(geolocationService.init.calledOnceWithExactly()).to.be.true; + expect(lineageModelGeneratorService.report.calledOnceWithExactly(reportId)).to.be.true; + expect(debug.args).to.deep.equal([['setting selected', model]]); + expect(openReportContent.args).to.deep.equal([[model]]); + expect(setLoadingContent.args).to.deep.equal([[true], [false]]); + expect(getReportContentService.getReportContent.calledOnceWithExactly(doc)).to.be.true; + expect(xmlFormsService.get.calledOnceWithExactly(model.formInternalId)).to.be.true; + expect(setEnketoEditedStatus.calledOnceWithExactly(false)).to.be.true; + expect(formService.render.calledOnce).to.be.true; + expect(formService.render.args[0][0]).to.deep.include({ + formDoc: xmlForm, + editing: true, + selector: '#report-form', + type: 'report', + instanceData: reportContent + }); + expect(component.form).to.equal(renderedForm); + expect(dbService.getAttachment.notCalled).to.be.true; + expect(fileReaderService.base64.notCalled).to.be.true; + expect(stopPerformanceTrackStub.args).to.deep.equal([[{ + name: `enketo:reports:${model.formInternalId}:edit:render`, + recordApdex: true, + }]]); + expect(performanceService.track.calledOnceWithExactly()).to.be.true; + + const markFormEdited = formService.render.args[0][0].editedListener; + const resetFormError = formService.render.args[0][0].valuechangeListener; + + markFormEdited(); + expect(setEnketoEditedStatus.calledTwice).to.be.true; + expect(setEnketoEditedStatus.args[1]).to.deep.equal([true]); + + resetFormError(); + expect(setEnketoError.notCalled).to.be.true; // no error so no call + component.enketoError = 'some error'; + resetFormError(); + expect(setEnketoError.calledOnce).to.be.true; + expect(setEnketoError.args[0]).to.deep.equal([null]); + })); + + [ + [imageAttachmentName, `user-file-${imageAttachmentName}`], + [null, `user-file${imageElementName}`] + ].forEach(([loadedFileName, attachmentId]) => { + it('loads form with image having loaded file name or a legacy attachment name', fakeAsync(async () => { + route.snapshot.params = { reportId: 'my_report' }; + const doc = { _id: 'report-id', form: 'my-form' }; + lineageModelGeneratorService.report.resolves({ doc }); + const attachmentBlob = { attachment: 'blob' }; + dbService.getAttachment.resolves(attachmentBlob); + const base64 = 'base64'; + fileReaderService.base64.resolves(base64); + + component.ngAfterViewInit(); + tick(); + + expect(jqStub.calledOnceWith(fileInputSelector)).to.be.true; + expect(jqMap.calledOnce).to.be.true; + + const renderAttachmentPreview = jqMap.args[0][0]; + const inputElement = getFileInputHTML('image/*', loadedFileName); + await renderAttachmentPreview(0, inputElement); + + expect(jqStub.calledWith('.widget.file-picker')).to.be.true; + expect(jqStub.calledWith('.file-feedback')).to.be.true; + expect(jqFeedbackElement.empty.calledOnceWithExactly()).to.be.true; + expect(dbService.getAttachment.calledOnceWithExactly(doc._id, attachmentId)).to.be.true; + expect(fileReaderService.base64.calledOnceWithExactly(attachmentBlob)).to.be.true; + expect(jqStub.calledWith('.file-preview')).to.be.true; + expect(jqPreviewElement.empty.calledOnce).to.be.true; + expect(jqPreviewElement.append.calledOnceWithExactly(``)).to.be.true; + })); + }); + + it('loads form with image and loaded file name but attachment has legacy name', fakeAsync(async () => { + route.snapshot.params = { reportId: 'my_report' }; + const doc = { _id: 'report-id', form: 'my-form' }; + lineageModelGeneratorService.report.resolves({ doc }); + const attachmentBlob = { attachment: 'blob' }; + dbService.getAttachment.onFirstCall().rejects({ status: 404 }); + dbService.getAttachment.onSecondCall().resolves(attachmentBlob); + const base64 = 'base64'; + fileReaderService.base64.resolves(base64); + + component.ngAfterViewInit(); + tick(); + + expect(jqStub.calledOnceWith(fileInputSelector)).to.be.true; + expect(jqMap.calledOnce).to.be.true; + + const renderAttachmentPreview = jqMap.args[0][0]; + const attachmentName = 'Screenshot-12_3_42.png'; + const inputElement = getFileInputHTML('image/*', attachmentName); + await renderAttachmentPreview(0, inputElement); + + expect(jqStub.calledWith('.widget.file-picker')).to.be.true; + expect(jqStub.calledWith('.file-feedback')).to.be.true; + expect(jqFeedbackElement.empty.calledOnceWithExactly()).to.be.true; + expect(dbService.getAttachment.calledTwice).to.be.true; + expect(dbService.getAttachment.args[0]).to.deep.equal([doc._id, `user-file-${attachmentName}`]); + expect(error.calledOnceWithExactly( + `Could not find attachment [user-file-${attachmentName}] on doc [${doc._id}].` + )).to.be.true; + expect(dbService.getAttachment.args[1]).to.deep.equal([doc._id, `user-file${imageElementName}`]); + expect(fileReaderService.base64.calledOnceWithExactly(attachmentBlob)).to.be.true; + expect(jqStub.calledWith('.file-preview')).to.be.true; + expect(jqPreviewElement.empty.calledOnce).to.be.true; + expect(jqPreviewElement.append.calledOnceWithExactly(``)).to.be.true; + })); + + it('loads form with non-image attachment', fakeAsync(async () => { + route.snapshot.params = { reportId: 'my_report' }; + const doc = { _id: 'report-id', form: 'my-form' }; + lineageModelGeneratorService.report.resolves({ doc }); + + component.ngAfterViewInit(); + tick(); + + expect(jqStub.calledOnceWith(fileInputSelector)).to.be.true; + expect(jqMap.calledOnce).to.be.true; + + const renderAttachmentPreview = jqMap.args[0][0]; + const attachmentName = 'Screenshot-12_3_42.png'; + const inputElement = getFileInputHTML('video/*', attachmentName); + await renderAttachmentPreview(0, inputElement); + + expect(jqStub.calledWith('.widget.file-picker')).to.be.true; + expect(jqStub.calledWith('.file-feedback')).to.be.true; + expect(jqFeedbackElement.empty.calledOnceWithExactly()).to.be.true; + expect(dbService.getAttachment.notCalled).to.be.true; + expect(fileReaderService.base64.notCalled).to.be.true; + expect(jqPreviewElement.empty.notCalled).to.be.true; + expect(jqPreviewElement.append.notCalled).to.be.true; + })); + + it('loads form with image when there is an error retrieving the attachment', fakeAsync(async () => { + route.snapshot.params = { reportId: 'my_report' }; + const doc = { _id: 'report-id', form: 'my-form' }; + lineageModelGeneratorService.report.resolves({ doc }); + const expectedError = new Error('some error'); + dbService.getAttachment.onFirstCall().rejects(expectedError); + + component.ngAfterViewInit(); + tick(); + + expect(jqStub.calledOnceWith(fileInputSelector)).to.be.true; + expect(jqMap.calledOnce).to.be.true; + + const renderAttachmentPreview = jqMap.args[0][0]; + const attachmentName = 'Screenshot-12_3_42.png'; + const inputElement = getFileInputHTML('image/*', attachmentName); + await expect(renderAttachmentPreview(0, inputElement)).to.be.rejectedWith(expectedError); + + expect(jqStub.calledWith('.widget.file-picker')).to.be.true; + expect(jqStub.calledWith('.file-feedback')).to.be.true; + expect(jqFeedbackElement.empty.calledOnceWithExactly()).to.be.true; + expect(dbService.getAttachment.calledOnceWithExactly(doc._id, `user-file-${attachmentName}`)).to.be.true; + expect(fileReaderService.base64.notCalled).to.be.true; + expect(jqPreviewElement.empty.notCalled).to.be.true; + expect(jqPreviewElement.append.notCalled).to.be.true; + })); + + + it('loads form with image attachment and loaded file name', fakeAsync(async () => { + route.snapshot.params = { reportId: 'my_report' }; + const doc = { _id: 'report-id', form: 'my-form' }; + lineageModelGeneratorService.report.resolves({ doc }); + const attachmentBlob = { attachment: 'blob' }; + dbService.getAttachment.resolves(attachmentBlob); + const base64 = 'base64'; + fileReaderService.base64.resolves(base64); + + component.ngAfterViewInit(); + tick(); + + expect(jqStub.calledOnceWith(fileInputSelector)).to.be.true; + expect(jqMap.calledOnce).to.be.true; + + const renderAttachmentPreview = jqMap.args[0][0]; + const attachmentName = 'Screenshot-12_3_42.png'; + const inputElement = getFileInputHTML('image/*', attachmentName); + await renderAttachmentPreview(0, inputElement); + + expect(jqStub.calledWith('.widget.file-picker')).to.be.true; + expect(jqStub.calledWith('.file-feedback')).to.be.true; + expect(jqFeedbackElement.empty.calledOnceWithExactly()).to.be.true; + expect(dbService.getAttachment.calledOnceWithExactly(doc._id, `user-file-${attachmentName}`)).to.be.true; + expect(fileReaderService.base64.calledOnceWithExactly(attachmentBlob)).to.be.true; + expect(jqStub.calledWith('.file-preview')).to.be.true; + expect(jqPreviewElement.empty.calledOnce).to.be.true; + expect(jqPreviewElement.append.calledOnceWithExactly(``)).to.be.true; + })); }); }); @@ -343,7 +604,6 @@ describe('Reports Add Component', () => { })); it('should catch enketo saving error', fakeAsync(() => { - const consoleErrorMock = sinon.stub(console, 'error'); component.form = { the: 'the form' }; component.selectedReport = { formInternalId: 'delivery' }; @@ -373,8 +633,8 @@ describe('Reports Add Component', () => { expect(setEnketoEditedStatus.callCount).to.equal(0); expect(router.navigate.callCount).to.equal(0); expect(setEnketoError.callCount).to.equal(1); - expect(consoleErrorMock.callCount).to.equal(1); - expect(consoleErrorMock.args[0][0]).to.equal('Error submitting form data: '); + expect(error.callCount).to.equal(1); + expect(error.args[0][0]).to.equal('Error submitting form data: '); })); // todo add tests for editing existent reports when focusing on migrating that diff --git a/webapp/tests/karma/ts/modules/reports/reports.component.spec.ts b/webapp/tests/karma/ts/modules/reports/reports.component.spec.ts index 1fc707af6f0..221c7ebc82d 100644 --- a/webapp/tests/karma/ts/modules/reports/reports.component.spec.ts +++ b/webapp/tests/karma/ts/modules/reports/reports.component.spec.ts @@ -38,6 +38,7 @@ import { FastActionButtonService } from '@mm-services/fast-action-button.service import { FeedbackService } from '@mm-services/feedback.service'; import { XmlFormsService } from '@mm-services/xml-forms.service'; import { ReportsMoreMenuComponent } from '@mm-modules/reports/reports-more-menu.component'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; describe('Reports Component', () => { let component: ReportsComponent; @@ -56,6 +57,7 @@ describe('Reports Component', () => { let xmlFormsService; let feedbackService; let performanceService; + let extractLineageService; let stopPerformanceTrackStub; let store; let route; @@ -114,6 +116,10 @@ describe('Reports Component', () => { }; xmlFormsService = { subscribe: sinon.stub() }; feedbackService = { submit: sinon.stub() }; + extractLineageService = { + getUserLineageToRemove: sinon.stub(), + removeUserFacility: ExtractLineageService.prototype.removeUserFacility, + }; route = { snapshot: { queryParams: { query: '' } } }; router = { navigate: sinon.stub(), @@ -165,6 +171,7 @@ describe('Reports Component', () => { { provide: FastActionButtonService, useValue: fastActionButtonService }, { provide: FeedbackService, useValue: feedbackService }, { provide: XmlFormsService, useValue: xmlFormsService }, + { provide: ExtractLineageService, useValue: extractLineageService }, ] }) .compileComponents() @@ -209,13 +216,17 @@ describe('Reports Component', () => { expect(spySubscriptionsAdd.callCount).to.equal(4); }); - it('should submit a feedback doc when an error was thrown by UserContactService', async () => { + it('should handle error when UserContactService throws', async () => { + const consoleErrorStub = sinon.stub(console, 'error'); + authService.has.withArgs('can_default_facility_filter').resolves(true); userContactService.get.resetHistory(); userContactService.get.rejects(new Error('some error')); await component.ngAfterViewInit(); expect(userContactService.get.calledOnce).to.be.true; + expect(consoleErrorStub.calledOnce).to.be.true; + expect(consoleErrorStub.args[0][0]).to.equal('some error'); }); it('listTrackBy() should return unique identifier', () => { @@ -279,7 +290,7 @@ describe('Reports Component', () => { }); describe('doInitialSearch', () => { - it('should set default facility report', async () => { + it('should set default facility report', fakeAsync(async () => { searchService.search.resetHistory(); authService.has.resetHistory(); authService.has.withArgs('can_default_facility_filter').resolves(true); @@ -288,6 +299,7 @@ describe('Reports Component', () => { component.ngOnInit(); await component.ngAfterViewInit(); + flush(); expect(setDefaultFacilityFilter.calledOnce).to.be.true; expect(setDefaultFacilityFilter.args[0][0]).to.deep.equal({ @@ -298,9 +310,10 @@ describe('Reports Component', () => { expect(authService.has.args[1][0]).to.equal('can_view_old_filter_and_search'); expect(authService.has.args[2][0]).to.equal('can_default_facility_filter'); expect(searchService.search.notCalled).to.be.true; - }); + })); - it('should not set default facility report when it is offline user', async () => { + it('should not set default facility report when it is offline user', fakeAsync(async () => { + stopPerformanceTrackStub.resetHistory(); searchService.search.resetHistory(); authService.has.resetHistory(); authService.has.withArgs('can_default_facility_filter').resolves(true); @@ -309,6 +322,7 @@ describe('Reports Component', () => { component.ngOnInit(); await component.ngAfterViewInit(); + flush(); expect(setDefaultFacilityFilter.notCalled).to.be.true; expect(authService.has.calledTwice).to.be.true; @@ -318,9 +332,10 @@ describe('Reports Component', () => { expect(stopPerformanceTrackStub.callCount).to.equal(2); expect(stopPerformanceTrackStub.args[0][0]).to.deep.equal({ name: 'report_list:query', recordApdex: true }); expect(stopPerformanceTrackStub.args[1][0]).to.deep.equal({ name: 'report_list:load', recordApdex: true }); - }); + })); - it('should not set default facility report when it is admin user', async () => { + it('should not set default facility report when it is admin user', fakeAsync(async () => { + stopPerformanceTrackStub.resetHistory(); searchService.search.resetHistory(); authService.has.resetHistory(); sessionService.isAdmin.returns(true); @@ -330,6 +345,7 @@ describe('Reports Component', () => { component.ngOnInit(); await component.ngAfterViewInit(); + flush(); expect(setDefaultFacilityFilter.notCalled).to.be.true; expect(authService.has.calledOnce).to.be.true; @@ -338,9 +354,10 @@ describe('Reports Component', () => { expect(stopPerformanceTrackStub.callCount).to.equal(2); expect(stopPerformanceTrackStub.args[0][0]).to.deep.equal({ name: 'report_list:query', recordApdex: true }); expect(stopPerformanceTrackStub.args[1][0]).to.deep.equal({ name: 'report_list:load', recordApdex: true }); - }); + })); - it('should not set default facility report when user does not have parent place', async () => { + it('should not set default facility report when user does not have parent place', fakeAsync(async () => { + stopPerformanceTrackStub.resetHistory(); userContactService.get.resolves({ _id: 'user-123' }); searchService.search.resetHistory(); authService.has.resetHistory(); @@ -350,6 +367,7 @@ describe('Reports Component', () => { component.ngOnInit(); await component.ngAfterViewInit(); + flush(); expect(setDefaultFacilityFilter.notCalled).to.be.true; expect(authService.has.calledThrice).to.be.true; @@ -360,7 +378,7 @@ describe('Reports Component', () => { expect(stopPerformanceTrackStub.callCount).to.equal(2); expect(stopPerformanceTrackStub.args[0][0]).to.deep.equal({ name: 'report_list:query', recordApdex: true }); expect(stopPerformanceTrackStub.args[1][0]).to.deep.equal({ name: 'report_list:load', recordApdex: true }); - }); + })); }); describe('selectAllReports', () => { @@ -402,7 +420,7 @@ describe('Reports Component', () => { unread: true, summary: { _id: 'one', form: 'the_form', lineage: [], contact: { _id: 'contact', name: 'person' } }, expanded: false, - lineage: [], + lineage: undefined, contact: { _id: 'contact', name: 'person' } }, { @@ -746,6 +764,7 @@ describe('Reports Component', () => { }); describe('Reports breadcrumbs', () => { + let updateReportsListStub; const reports = [ { _id: '88b0dfff-4a82-4202-abea-d0cabe5aa9bd', @@ -767,26 +786,15 @@ describe('Reports Component', () => { _id: 'ee21ea15-1ebb-4d6d-95ea-7073ba965525', }, ]; - const offlineUserContactDoc = { - _id: 'user', - parent: { - _id: 'parent', - name: 'CHW Bettys Area', - parent: userContactGrandparent, - }, - }; - - let updateReportsListStub; beforeEach(() => { updateReportsListStub = sinon.stub(ReportsActions.prototype, 'updateReportsList'); searchService.search.resolves(reports); }); - it('should not change the reports lineage if UserContactService throws error', fakeAsync(async () => { + it('should not remove the lineage when user lineage level is undefined', fakeAsync(() => { sinon.resetHistory(); - authService.online.returns(true); - userContactService.get.rejects(new Error('some error')); + extractLineageService.getUserLineageToRemove.resolves(undefined); const expectedReports = [ { _id: '88b0dfff-4a82-4202-abea-d0cabe5aa9bd', @@ -799,7 +807,7 @@ describe('Reports Component', () => { }, { _id: 'a86f238a-ad81-4780-9552-c7248864d1b2', - lineage: [ 'Chattanooga Village', 'CHW Bettys Area', null, null ], + lineage: [ 'Chattanooga Village', 'CHW Bettys Area'], heading: 'report.subject.unknown', icon: undefined, summary: undefined, @@ -826,15 +834,6 @@ describe('Reports Component', () => { }, { _id: 'ee21ea15-1ebb-4d6d-95ea-7073ba357229', - lineage: [], - heading: 'report.subject.unknown', - icon: undefined, - summary: undefined, - expanded: false, - unread: true, - }, - { - _id: 'ee21ea15-1ebb-4d6d-95ea-7073ba965525', lineage: undefined, heading: 'report.subject.unknown', icon: undefined, @@ -842,66 +841,6 @@ describe('Reports Component', () => { expanded: false, unread: true, }, - ]; - - component.ngOnInit(); - await component.ngAfterViewInit(); - flush(); - - expect(updateReportsListStub.calledOnce).to.be.true; - expect(updateReportsListStub.args[0]).to.deep.equal([ expectedReports ]); - expect(userContactService.get.calledOnce).to.be.true; - expect(authService.online.calledOnce).to.be.true; - })); - - it('should not change the reports lineage if user is online only', fakeAsync(() => { - authService.online.returns(true); - const expectedReports = [ - { - _id: '88b0dfff-4a82-4202-abea-d0cabe5aa9bd', - lineage: [ 'St Elmos Concession', 'Chattanooga Village', 'CHW Bettys Area' ], - heading: 'report.subject.unknown', - icon: undefined, - summary: undefined, - expanded: false, - unread: true, - }, - { - _id: 'a86f238a-ad81-4780-9552-c7248864d1b2', - lineage: [ 'Chattanooga Village', 'CHW Bettys Area', null, null ], - heading: 'report.subject.unknown', - icon: undefined, - summary: undefined, - expanded: false, - unread: true, - }, - { - _id: 'd2da792d-e7f1-48b3-8e53-61d331d7e899', - lineage: [ 'Chattanooga Village' ], - heading: 'report.subject.unknown', - icon: undefined, - summary: undefined, - expanded: false, - unread: true, - }, - { - _id: 'ee21ea15-1ebb-4d6d-95ea-7073ba357229', - lineage: [ 'CHW Bettys Area' ], - heading: 'report.subject.unknown', - icon: undefined, - summary: undefined, - expanded: false, - unread: true, - }, - { - _id: 'ee21ea15-1ebb-4d6d-95ea-7073ba357229', - lineage: [], - heading: 'report.subject.unknown', - icon: undefined, - summary: undefined, - expanded: false, - unread: true, - }, { _id: 'ee21ea15-1ebb-4d6d-95ea-7073ba965525', lineage: undefined, @@ -917,13 +856,12 @@ describe('Reports Component', () => { component.ngAfterViewInit(); flush(); - expect(updateReportsListStub.callCount).to.equal(1); + expect(updateReportsListStub.calledOnce).to.be.true; expect(updateReportsListStub.args[0]).to.deep.equal([ expectedReports ]); })); - it('should remove current level from reports lineage when user is offline', fakeAsync(() => { - userContactService.get.resolves(offlineUserContactDoc); - authService.online.returns(false); + it('should remove lineage when user lineage level is defined', fakeAsync(() => { + extractLineageService.getUserLineageToRemove.resolves('CHW Bettys Area'); const expectedReports = [ { _id: '88b0dfff-4a82-4202-abea-d0cabe5aa9bd', @@ -965,7 +903,7 @@ describe('Reports Component', () => { _id: 'ee21ea15-1ebb-4d6d-95ea-7073ba357229', heading: 'report.subject.unknown', icon: undefined, - lineage: [], + lineage: undefined, summary: undefined, expanded: false, unread: true, @@ -985,7 +923,7 @@ describe('Reports Component', () => { component.ngAfterViewInit(); flush(); - expect(updateReportsListStub.callCount).to.equal(1); + expect(updateReportsListStub.calledOnce).to.be.true; expect(updateReportsListStub.args[0]).to.deep.equal([ expectedReports ]); })); @@ -1003,9 +941,9 @@ describe('Reports Component', () => { flush(); expect(setSelectMode.callCount).to.equal(1); - expect(setSelectMode.args[0]).to.deep.equal([true]); - expect(unsetComponents.callCount).to.equal(1); - expect(router.navigate.callCount).to.equal(1); + expect(setSelectMode.args).to.deep.equal([[true]]); + expect(unsetComponents.calledOnce).to.be.true; + expect(router.navigate.calledOnce).to.be.true; expect(router.navigate.args[0]).to.deep.equal([['/reports']]); })); diff --git a/webapp/tests/karma/ts/modules/tasks/tasks.component.spec.ts b/webapp/tests/karma/ts/modules/tasks/tasks.component.spec.ts index a6094dbf23b..fa07fc02bb5 100644 --- a/webapp/tests/karma/ts/modules/tasks/tasks.component.spec.ts +++ b/webapp/tests/karma/ts/modules/tasks/tasks.component.spec.ts @@ -3,6 +3,7 @@ import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { expect } from 'chai'; +import * as moment from 'moment'; import sinon from 'sinon'; import { ChangesService } from '@mm-services/changes.service'; @@ -14,9 +15,8 @@ import { TasksComponent } from '@mm-modules/tasks/tasks.component'; import { NavigationComponent } from '@mm-components/navigation/navigation.component'; import { Selectors } from '@mm-selectors/index'; import { NavigationService } from '@mm-services/navigation.service'; -import { UserContactService } from '@mm-services/user-contact.service'; -import { SessionService } from '@mm-services/session.service'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; describe('TasksComponent', () => { let getComponent; @@ -25,22 +25,14 @@ describe('TasksComponent', () => { let performanceService; let stopPerformanceTrackStub; let contactTypesService; + let extractLineageService; + let clock; let store; - let sessionService; - let userContactService; let lineageModelGeneratorService; let component: TasksComponent; let fixture: ComponentFixture; - const userContactDoc = { - _id: 'user', - parent: { - _id: 'parent', - name: 'parent', - }, - }; - beforeEach(async () => { changesService = { subscribe: sinon.stub().returns({ unsubscribe: sinon.stub() }) }; rulesEngineService = { @@ -53,14 +45,11 @@ describe('TasksComponent', () => { contactTypesService = { includes: sinon.stub(), }; - sessionService = { - isOnlineOnly: sinon.stub().returns(false), - userCtx: sinon.stub() - }; - userContactService = { - get: sinon.stub().resolves(), - }; lineageModelGeneratorService = { reportSubjects: sinon.stub().resolves([]) }; + extractLineageService = { + getUserLineageToRemove: sinon.stub(), + removeUserFacility: ExtractLineageService.prototype.removeUserFacility, + }; TestBed.configureTestingModule({ imports: [ @@ -74,8 +63,7 @@ describe('TasksComponent', () => { { provide: PerformanceService, useValue: performanceService }, { provide: ContactTypesService, useValue: contactTypesService }, { provide: NavigationService, useValue: {} }, - { provide: SessionService, useValue: sessionService }, - { provide: UserContactService, useValue: userContactService }, + { provide: ExtractLineageService, useValue: extractLineageService }, { provide: LineageModelGeneratorService, useValue: lineageModelGeneratorService }, ], declarations: [ @@ -97,6 +85,7 @@ describe('TasksComponent', () => { afterEach(() => { store.resetSelectors(); sinon.restore(); + clock?.restore(); }); it('should ngOnDestroy should unsubscribe and clear state', async () => { @@ -160,54 +149,32 @@ describe('TasksComponent', () => { }); it('tasks render', async () => { + const now = moment('2020-10-20'); + const futureDate = now.clone().add(3, 'days'); + const pastDate = now.clone().subtract(3, 'days'); + clock = sinon.useFakeTimers(now.valueOf()); const taskDocs = [ - { - _id: '1', - owner: 'a', - emission: { - _id: 'e1', - dueDate: '2030-10-24', - date: new Date('2023-10-24T17:00:00.000Z'), - overdue: false, - owner: 'a', - }, - }, - { - _id: '2', - owner: 'b', - emission: { - _id: 'e2', - dueDate: '2023-10-24', - date: new Date('2023-10-24T17:00:00.000Z'), - overdue: true, - owner: 'b', - }, - }, + { _id: '1', emission: { _id: 'e1', dueDate: futureDate.format('YYYY-MM-DD') }, owner: 'a' }, + { _id: '2', emission: { _id: 'e2', dueDate: pastDate.format('YYYY-MM-DD') }, owner: 'b' }, ]; const expectedTasks = [ { _id: 'e1', - dueDate: '2030-10-24', - date: new Date('2023-10-24T17:00:00.000Z'), + dueDate: futureDate.format('YYYY-MM-DD'), overdue: false, + date: new Date(futureDate.valueOf()), owner: 'a', - lineage: [ 'lineage-a' ] }, { _id: 'e2', - dueDate: '2023-10-24', - date: new Date('2023-10-24T17:00:00.000Z'), + dueDate: pastDate.format('YYYY-MM-DD'), overdue: true, + date: new Date(pastDate.valueOf()), owner: 'b', - lineage: [ 'lineage-b' ] }, ]; - rulesEngineService.fetchTaskDocsForAllContacts.resolves(taskDocs); - lineageModelGeneratorService.reportSubjects.resolves([ - { _id: 'a', lineage: [ { name: 'lineage-a' } ] }, - { _id: 'b', lineage: [ { name: 'lineage-b' } ] }, - ]); + rulesEngineService.fetchTaskDocsForAllContacts.resolves(taskDocs); await new Promise(resolve => { sinon.stub(TasksActions.prototype, 'setTasksList').callsFake(resolve); getComponent(); @@ -305,7 +272,7 @@ describe('TasksComponent', () => { flush(); expect(rulesEngineService.fetchTaskDocsForAllContacts.callCount).to.eq(2); - expect(performanceService.track.calledOnce).to.be.true; + expect(performanceService.track.calledTwice).to.be.true; expect(stopPerformanceTrackStub.calledTwice).to.be.true; expect(stopPerformanceTrackStub.args[0][0]).to.deep.equal({ name: 'tasks:load', recordApdex: true }); expect(stopPerformanceTrackStub.args[1][0]).to.deep.equal({ name: 'tasks:refresh', recordApdex: true }); @@ -322,39 +289,6 @@ describe('TasksComponent', () => { }); describe('lineage and breadcrumbs', () => { - const bettysContactDoc = { - _id: 'user', - parent: { - _id: 'parent', - name: 'CHW Bettys Area', - }, - }; - const taskDocs = [ - { - _id: '1', - forId: 'a', - owner: 'a', - emission: { - _id: 'e1', - dueDate: '2020-10-20', - date: new Date('2020-10-20T17:00:00.000Z'), - overdue: true, - owner: 'a', - }, - }, - { - _id: '2', - forId: 'b', - owner: 'b', - emission: { - _id: 'e2', - dueDate: '2020-10-20', - date: new Date('2020-10-20T17:00:00.000Z'), - overdue: true, - owner: 'b', - }, - }, - ]; const taskLineages = [ { _id: 'a', @@ -377,45 +311,16 @@ describe('TasksComponent', () => { ], }, ]; + const taskDocs = [ + { _id: '1', emission: { _id: 'e1', dueDate: '2020-10-20' }, forId: 'a', owner: 'a' }, + { _id: '2', emission: { _id: 'e2', dueDate: '2020-10-20' }, forId: 'b', owner: 'b' }, + ]; - it('should not alter tasks lineage if user is online only', async () => { - const expectedTasks = [ - { - _id: 'e1', - date: new Date('2020-10-20T17:00:00.000Z'), - dueDate: '2020-10-20', - lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village', 'CHW Bettys Area' ], - overdue: true, - owner: 'a', - }, - { - _id: 'e2', - dueDate: '2020-10-20', - date: new Date('2020-10-20T17:00:00.000Z'), - lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village' ], - overdue: true, - owner: 'b', - }, - ]; - userContactService.get.resolves(bettysContactDoc); - sessionService.isOnlineOnly.returns(true); - rulesEngineService.fetchTaskDocsForAllContacts.resolves(taskDocs); - lineageModelGeneratorService.reportSubjects.resolves(taskLineages); - - await new Promise(resolve => { - sinon.stub(TasksActions.prototype, 'setTasksList').callsFake(resolve); - getComponent(); - }); - - expect(await component.currentLevel).to.be.undefined; - expect((TasksActions.prototype.setTasksList).args).to.deep.equal([[expectedTasks]]); - }); - - it('should not change the tasks lineage if user is offline with unrelated lineage', async () => { + it('should not remove the lineage when user lineage level is undefined', async () => { const expectedTasks = [ { _id: 'e1', - date: new Date('2020-10-20T17:00:00.000Z'), + date: moment('2020-10-20').toDate(), dueDate: '2020-10-20', lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village', 'CHW Bettys Area' ], overdue: true, @@ -423,15 +328,14 @@ describe('TasksComponent', () => { }, { _id: 'e2', + date: moment('2020-10-20').toDate(), dueDate: '2020-10-20', - date: new Date('2020-10-20T17:00:00.000Z'), lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village' ], overdue: true, owner: 'b', }, ]; - userContactService.get.resolves(userContactDoc); - sessionService.isOnlineOnly.returns(false); + extractLineageService.getUserLineageToRemove.resolves(undefined); rulesEngineService.fetchTaskDocsForAllContacts.resolves(taskDocs); lineageModelGeneratorService.reportSubjects.resolves(taskLineages); @@ -440,15 +344,15 @@ describe('TasksComponent', () => { getComponent(); }); - expect(await component.currentLevel).to.equal('parent'); + expect(await component.userLineageLevel).to.be.undefined; expect((TasksActions.prototype.setTasksList).args).to.deep.equal([[expectedTasks]]); }); - it('should update the tasks lineage if user is offline with related place to lineage', async () => { + it('should remove lineage when user lineage level is defined', async () => { const expectedTasks = [ { _id: 'e1', - date: new Date('2020-10-20T17:00:00.000Z'), + date: moment('2020-10-20').toDate(), dueDate: '2020-10-20', lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village' ], overdue: true, @@ -456,15 +360,14 @@ describe('TasksComponent', () => { }, { _id: 'e2', + date: moment('2020-10-20').toDate(), dueDate: '2020-10-20', - date: new Date('2020-10-20T17:00:00.000Z'), lineage: [ 'Amy Johnsons Household', 'St Elmos Concession', 'Chattanooga Village' ], overdue: true, owner: 'b', }, ]; - userContactService.get.resolves(bettysContactDoc); - sessionService.isOnlineOnly.returns(false); + extractLineageService.getUserLineageToRemove.resolves('CHW Bettys Area'); rulesEngineService.fetchTaskDocsForAllContacts.resolves(taskDocs); lineageModelGeneratorService.reportSubjects.resolves(taskLineages); @@ -473,7 +376,7 @@ describe('TasksComponent', () => { getComponent(); }); - expect(await component.currentLevel).to.equal('CHW Bettys Area'); + expect(await component.userLineageLevel).to.equal('CHW Bettys Area'); expect((TasksActions.prototype.setTasksList).args).to.deep.equal([[expectedTasks]]); }); }); diff --git a/webapp/tests/karma/ts/services/analytics-modules.service.spec.ts b/webapp/tests/karma/ts/services/analytics-modules.service.spec.ts index d3fa5f30d20..c4579b08f4c 100644 --- a/webapp/tests/karma/ts/services/analytics-modules.service.spec.ts +++ b/webapp/tests/karma/ts/services/analytics-modules.service.spec.ts @@ -3,23 +3,23 @@ import sinon from 'sinon'; import { expect, assert } from 'chai'; import { AnalyticsModulesService } from '@mm-services/analytics-modules.service'; -import { AuthService } from '@mm-services/auth.service'; import { SettingsService } from '@mm-services/settings.service'; +import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; describe('AnalyticsModulesService', () => { let service: AnalyticsModulesService; - let authService; + let targetAggregatesService; let settingsService; beforeEach(() => { - authService = { has: sinon.stub() }; + targetAggregatesService = { isEnabled: sinon.stub() }; settingsService = { get: sinon.stub() }; TestBed.configureTestingModule({ providers: [ - { provide: AuthService, useValue: authService }, + { provide: TargetAggregatesService, useValue: targetAggregatesService }, { provide: SettingsService, useValue: settingsService }, - ] + ], }); service = TestBed.inject(AnalyticsModulesService); @@ -31,7 +31,7 @@ describe('AnalyticsModulesService', () => { it('should throw an error when settings fails', () => { settingsService.get.rejects({ some: 'err' }); - authService.has.resolves(true); + targetAggregatesService.isEnabled.resolves(true); return service .get() @@ -41,7 +41,7 @@ describe('AnalyticsModulesService', () => { it('should enable targets when configured', () => { settingsService.get.resolves({ tasks: { targets: { enabled: true } } }); - authService.has.resolves(false); + targetAggregatesService.isEnabled.resolves(false); return service .get() @@ -57,7 +57,7 @@ describe('AnalyticsModulesService', () => { it('should enable target aggregates when configured', () => { settingsService.get.resolves({ tasks: { targets: { enabled: true } } }); - authService.has.resolves(true); + targetAggregatesService.isEnabled.resolves(true); return service .get() diff --git a/webapp/tests/karma/ts/services/auth.service.spec.ts b/webapp/tests/karma/ts/services/auth.service.spec.ts index 37800cc00b8..289394398ed 100644 --- a/webapp/tests/karma/ts/services/auth.service.spec.ts +++ b/webapp/tests/karma/ts/services/auth.service.spec.ts @@ -8,13 +8,14 @@ import { SessionService } from '@mm-services/session.service'; import { SettingsService } from '@mm-services/settings.service'; import { AuthService } from '@mm-services/auth.service'; import { ChangesService } from '@mm-services/changes.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; +import { DbService } from '@mm-services/db.service'; describe('Auth Service', () => { let service:AuthService; let sessionService; let settingsService; - let chtScriptApiService; + let chtDatasourceService; let changesService; let http; @@ -29,12 +30,13 @@ describe('Auth Service', () => { { provide: SessionService, useValue: sessionService }, { provide: SettingsService, useValue: settingsService }, { provide: ChangesService, useValue: changesService }, + { provide: DbService, useValue: { get: sinon.stub().resolves({}) } }, { provide: HttpClient, useValue: http }, ] }); service = TestBed.inject(AuthService); - chtScriptApiService = TestBed.inject(CHTScriptApiService); + chtDatasourceService = TestBed.inject(CHTDatasourceService); }); afterEach(() => { @@ -45,7 +47,7 @@ describe('Auth Service', () => { it('should return false when no settings', async () => { sessionService.userCtx.returns({ roles: ['chw'] }); settingsService.get.resolves(null); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has('can_edit'); @@ -55,7 +57,7 @@ describe('Auth Service', () => { it('should return false when no permissions configured', async () => { sessionService.userCtx.returns({ roles: ['chw'] }); settingsService.get.resolves({}); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has('can_edit'); @@ -65,7 +67,7 @@ describe('Auth Service', () => { it('should return false when no session', async () => { sessionService.userCtx.returns(null); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(); @@ -75,7 +77,7 @@ describe('Auth Service', () => { it('should return false when user has no role', async () => { sessionService.userCtx.returns({}); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(); @@ -85,7 +87,7 @@ describe('Auth Service', () => { it('should return true when user is db admin', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: { can_edit: ['chw'] } }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['can_backup_facilities']); @@ -95,7 +97,7 @@ describe('Auth Service', () => { it('should return false when settings errors', async () => { sessionService.userCtx.returns({ roles: ['district_admin'] }); settingsService.get.rejects('boom'); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['can_backup_facilities']); @@ -114,7 +116,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['']); @@ -123,7 +125,7 @@ describe('Auth Service', () => { it('should throw error when server is offline', async () => { settingsService.get.rejects({ status: 503 }); - chtScriptApiService.init(); + chtDatasourceService.init(); try { await service.has(['']); expect.fail(); @@ -149,7 +151,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['xyz']); @@ -168,7 +170,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['!xyz']); @@ -189,7 +191,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has('can_backup_facilities'); @@ -208,7 +210,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['can_backup_facilities', 'can_export_messages']); @@ -227,7 +229,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['can_backup_facilities', 'can_export_messages']); @@ -237,7 +239,7 @@ describe('Auth Service', () => { it('should return false when admin and !permission', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['!can_backup_facilities']); @@ -256,7 +258,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['!can_backup_facilities', '!can_export_messages']); @@ -275,7 +277,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['!can_backup_facilities', 'can_export_messages']); @@ -287,7 +289,7 @@ describe('Auth Service', () => { it('should return false when no settings', async () => { sessionService.userCtx.returns({ roles: ['chw'] }); settingsService.get.resolves(null); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['can_edit'], ['can_configure']]); @@ -297,7 +299,7 @@ describe('Auth Service', () => { it('should return false when no settings and no permissions configured', async () => { sessionService.userCtx.returns({ roles: ['chw'] }); settingsService.get.resolves({}); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['can_edit'], ['can_configure']]); @@ -307,7 +309,7 @@ describe('Auth Service', () => { it('should return false when no session', async () => { sessionService.userCtx.returns(null); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any(); @@ -317,7 +319,7 @@ describe('Auth Service', () => { it('should return false when user has no role', async () => { sessionService.userCtx.returns({}); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any(); @@ -327,7 +329,7 @@ describe('Auth Service', () => { it('should return true when admin and no disallowed permissions', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: { can_edit: [ 'chw' ] } }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['can_backup_facilities'], ['can_export_messages'], ['somepermission']]); @@ -337,7 +339,7 @@ describe('Auth Service', () => { it('should return true when admin and some disallowed permissions', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: { can_edit: [ 'chw' ] } }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['!can_backup_facilities'], ['!can_export_messages'], ['somepermission']]); @@ -347,7 +349,7 @@ describe('Auth Service', () => { it('should return false when admin and all disallowed permissions', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['!can_backup_facilities'], ['!can_export_messages'], ['!somepermission']]); @@ -369,7 +371,7 @@ describe('Auth Service', () => { can_roll_over: ['national_admin', 'district_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const permissions = [ ['can_backup_facilities'], ['can_export_messages', 'can_roll_over'], @@ -389,7 +391,7 @@ describe('Auth Service', () => { can_backup_people: ['national_admin', 'district_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const permissions = [ ['can_backup_facilities', 'can_backup_people'], ['can_export_messages', 'can_roll_over'], @@ -409,7 +411,7 @@ describe('Auth Service', () => { can_backup_people: ['national_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const permissions = [ ['can_backup_facilities', 'can_backup_people'], ['can_export_messages', 'can_roll_over'], @@ -437,7 +439,7 @@ describe('Auth Service', () => { random3: ['national_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([ ['can_backup_facilities', '!random1'], @@ -460,14 +462,14 @@ describe('Auth Service', () => { random3: ['national_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([ ['can_backup_facilities', '!can_add_people'], ['can_export_messages', '!random2'], ['can_backup_people', '!can_add_places'] ]); - chtScriptApiService.init(); + chtDatasourceService.init(); expect(result).to.be.true; }); @@ -484,7 +486,7 @@ describe('Auth Service', () => { random3: ['national_admin', 'district_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([ ['can_backup_facilities', '!random1'], diff --git a/webapp/tests/karma/ts/services/cht-script-api.service.spec.ts b/webapp/tests/karma/ts/services/cht-datasource.service.spec.ts similarity index 70% rename from webapp/tests/karma/ts/services/cht-script-api.service.spec.ts rename to webapp/tests/karma/ts/services/cht-datasource.service.spec.ts index 70be34e778b..c84e72ac009 100644 --- a/webapp/tests/karma/ts/services/cht-script-api.service.spec.ts +++ b/webapp/tests/karma/ts/services/cht-datasource.service.spec.ts @@ -4,22 +4,25 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { SettingsService } from '@mm-services/settings.service'; import { ChangesService } from '@mm-services/changes.service'; import { SessionService } from '@mm-services/session.service'; +import { DbService } from '@mm-services/db.service'; describe('CHTScriptApiService service', () => { - let service: CHTScriptApiService; + let service: CHTDatasourceService; let sessionService; let settingsService; let changesService; + let dbService; let http; beforeEach(() => { - sessionService = { userCtx: sinon.stub() }; + sessionService = { userCtx: sinon.stub(), isOnlineOnly: sinon.stub() }; settingsService = { get: sinon.stub() }; changesService = { subscribe: sinon.stub().returns({ unsubscribe: sinon.stub() }) }; + dbService = { get: sinon.stub().resolves({}) }; http = { get: sinon.stub().returns(of([])) }; TestBed.configureTestingModule({ @@ -27,11 +30,12 @@ describe('CHTScriptApiService service', () => { { provide: SessionService, useValue: sessionService }, { provide: SettingsService, useValue: settingsService }, { provide: ChangesService, useValue: changesService }, + { provide: DbService, useValue: dbService }, { provide: HttpClient, useValue: http }, ] }); - service = TestBed.inject(CHTScriptApiService); + service = TestBed.inject(CHTDatasourceService); }); afterEach(() => { @@ -40,9 +44,11 @@ describe('CHTScriptApiService service', () => { describe('init', () => { - it('should initialise service', async () => { + it('should initialise service for offline user', async () => { settingsService.get.resolves(); - sessionService.userCtx.returns(); + const userCtx = { hello: 'world' }; + sessionService.userCtx.returns(userCtx); + sessionService.isOnlineOnly.returns(false); await service.isInitialized(); @@ -51,19 +57,39 @@ describe('CHTScriptApiService service', () => { expect(changesService.subscribe.args[0][0].filter).to.be.a('function'); expect(changesService.subscribe.args[0][0].callback).to.be.a('function'); expect(settingsService.get.callCount).to.equal(1); + expect(sessionService.isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + expect(dbService.get.calledOnceWithExactly()).to.be.true; + }); + + it('should initialise service for online user', async () => { + settingsService.get.resolves(); + const userCtx = { hello: 'world' }; + sessionService.userCtx.returns(userCtx); + sessionService.isOnlineOnly.returns(true); + + await service.isInitialized(); + + expect(changesService.subscribe.callCount).to.equal(1); + expect(changesService.subscribe.args[0][0].key).to.equal('cht-script-api-settings-changes'); + expect(changesService.subscribe.args[0][0].filter).to.be.a('function'); + expect(changesService.subscribe.args[0][0].callback).to.be.a('function'); + expect(settingsService.get.callCount).to.equal(1); + expect(sessionService.isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + expect(dbService.get.notCalled).to.be.true; }); it('should return versioned api', async () => { settingsService.get.resolves(); await service.isInitialized(); - const result = await service.getApi(); + const result = await service.get(); - expect(result).to.have.all.keys([ 'v1' ]); - expect(result.v1).to.have.all.keys([ 'hasPermissions', 'hasAnyPermission', 'getExtensionLib' ]); + expect(result).to.contain.keys([ 'v1' ]); + expect(result.v1).to.contain.keys([ 'hasPermissions', 'hasAnyPermission', 'getExtensionLib', 'person' ]); expect(result.v1.hasPermissions).to.be.a('function'); expect(result.v1.hasAnyPermission).to.be.a('function'); expect(result.v1.getExtensionLib).to.be.a('function'); + expect(result.v1.person).to.be.a('object'); }); it('should initialize extension libs', async () => { @@ -78,7 +104,7 @@ describe('CHTScriptApiService service', () => { expect(http.get.args[1][0]).to.equal('/extension-libs/bar.js'); expect(http.get.args[2][0]).to.equal('/extension-libs/foo.js'); - const result = await service.getApi(); + const result = await service.get(); const foo = result.v1.getExtensionLib('foo.js'); expect(foo).to.be.a('function'); @@ -94,6 +120,42 @@ describe('CHTScriptApiService service', () => { }); + describe('bind()', () => { + [true, false].forEach((isOnlineOnly) => { + it(`binds to a data context when isOnlineOnly is ${isOnlineOnly}`, async () => { + const settings = { hello: 'settings' } as const; + settingsService.get.resolves(settings); + const userCtx = { hello: 'world' }; + sessionService.userCtx.returns(userCtx); + sessionService.isOnlineOnly.returns(isOnlineOnly); + const expectedDb = { hello: 'medic' }; + dbService.get.resolves(expectedDb); + const innerFn = sinon.stub(); + const outerFn = sinon + .stub() + .returns(innerFn); + + const result = await service.bind(outerFn); + + expect(outerFn.calledOnce).to.be.true; + const [dataContext, ...other] = outerFn.args[0]; + expect(other).to.be.empty; + expect(dataContext.bind).to.be.a('function'); + expect(result).to.equal(innerFn); + expect(innerFn.notCalled).to.be.true; + expect(changesService.subscribe.calledOnce).to.be.true; + expect(changesService.subscribe.args[0][0].key).to.equal('cht-script-api-settings-changes'); + expect(changesService.subscribe.args[0][0].filter).to.be.a('function'); + expect(changesService.subscribe.args[0][0].callback).to.be.a('function'); + expect(sessionService.userCtx.calledOnceWithExactly()).to.be.true; + expect(settingsService.get.calledOnceWithExactly()).to.be.true; + expect(http.get.calledOnceWithExactly('/extension-libs', { responseType: 'json' })).to.be.true; + expect(sessionService.isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + expect(dbService.get.callCount).to.equal(isOnlineOnly ? 0 : 1); + }); + }); + }); + describe('v1.hasPermissions()', () => { it('should return true when user has the permission', async () => { @@ -105,7 +167,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'chw_supervisor', 'gateway' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasPermissions('can_edit'); @@ -121,7 +183,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'chw_supervisor', 'gateway' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasPermissions('can_create_people'); @@ -138,7 +200,7 @@ describe('CHTScriptApiService service', () => { sessionService.userCtx.returns({ roles: [ 'nurse' ] }); await service.isInitialized(); const changesCallback = changesService.subscribe.args[0][0].callback; - const api = await service.getApi(); + const api = await service.get(); const permissionNotFound = api.v1.hasPermissions('can_create_people'); @@ -170,7 +232,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ '_admin' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasPermissions('can_create_people'); @@ -186,7 +248,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'chw_supervisor' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasPermissions('can_configure'); @@ -207,7 +269,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'district_admin' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasAnyPermission([ [ 'can_backup_facilities' ], @@ -227,7 +289,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'district_admin' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasAnyPermission([ [ 'can_backup_facilities', 'can_backup_people' ], @@ -248,7 +310,7 @@ describe('CHTScriptApiService service', () => { sessionService.userCtx.returns({ roles: [ 'nurse' ] }); await service.isInitialized(); const changesCallback = changesService.subscribe.args[0][0].callback; - const api = await service.getApi(); + const api = await service.get(); const permissionNotFound = api.v1.hasAnyPermission([[ 'can_create_people' ], [ '!can_edit' ]]); @@ -280,7 +342,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ '_admin' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasAnyPermission([[ 'can_create_people' ], [ 'can_edit', 'can_configure' ]]); @@ -298,7 +360,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'chw_supervisor' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasAnyPermission([[ 'can_configure', 'can_create_people' ], [ 'can_backup_facilities' ] ]); diff --git a/webapp/tests/karma/ts/services/contact-summary.service.spec.ts b/webapp/tests/karma/ts/services/contact-summary.service.spec.ts index a0794b99230..80e712afad4 100644 --- a/webapp/tests/karma/ts/services/contact-summary.service.spec.ts +++ b/webapp/tests/karma/ts/services/contact-summary.service.spec.ts @@ -8,14 +8,14 @@ import { PipesService } from '@mm-services/pipes.service'; import { SettingsService } from '@mm-services/settings.service'; import { FeedbackService } from '@mm-services/feedback.service'; import { UHCStatsService } from '@mm-services/uhc-stats.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; describe('ContactSummary service', () => { let service; let Settings; let feedbackService; let uhcStatsService; - let chtScriptApiService; + let chtDatasourceService; let chtScriptApi; beforeEach(() => { @@ -31,8 +31,8 @@ describe('ContactSummary service', () => { hasAnyPermission: sinon.stub() } }; - chtScriptApiService = { - getApi: sinon.stub().returns(chtScriptApi) + chtDatasourceService = { + get: sinon.stub().returns(chtScriptApi) }; const pipesTransform = (name, value) => { @@ -48,7 +48,7 @@ describe('ContactSummary service', () => { { provide: PipesService, useValue: { transform: pipesTransform } }, { provide: FeedbackService, useValue: feedbackService }, { provide: UHCStatsService, useValue: uhcStatsService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService } + { provide: CHTDatasourceService, useValue: chtDatasourceService } ] }); service = TestBed.inject(ContactSummaryService); @@ -149,6 +149,7 @@ describe('ContactSummary service', () => { }) .catch((err) => { expect(err.message).to.equal('Configuration error'); + expect(err.stack).to.exist; expect(consoleErrorMock.callCount).to.equal(1); expect(consoleErrorMock.args[0][0].startsWith('Configuration error in contact-summary')).to.be.true; }); diff --git a/webapp/tests/karma/ts/services/contact-types.service.spec.ts b/webapp/tests/karma/ts/services/contact-types.service.spec.ts index da170878545..62d2d9b74c8 100644 --- a/webapp/tests/karma/ts/services/contact-types.service.spec.ts +++ b/webapp/tests/karma/ts/services/contact-types.service.spec.ts @@ -55,7 +55,7 @@ describe('ContactTypes service', () => { ]; Settings.resolves({ contact_types: types }); return service.get('something').then(type => { - expect(type.id).to.equal('something'); + expect(type?.id).to.equal('something'); }); }); }); diff --git a/webapp/tests/karma/ts/services/delete-docs.service.spec.ts b/webapp/tests/karma/ts/services/delete-docs.service.spec.ts index 8f8df4a3617..4225f074194 100644 --- a/webapp/tests/karma/ts/services/delete-docs.service.spec.ts +++ b/webapp/tests/karma/ts/services/delete-docs.service.spec.ts @@ -6,6 +6,7 @@ import { DbService } from '@mm-services/db.service'; import { SessionService } from '@mm-services/session.service'; import { ChangesService } from '@mm-services/changes.service'; import { DeleteDocsService } from '@mm-services/delete-docs.service'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; describe('DeleteDocs service', () => { @@ -14,6 +15,7 @@ describe('DeleteDocs service', () => { let bulkDocs; let isOnlineOnly; let server; + let extractLineageService; beforeEach(() => { get = sinon.stub(); @@ -21,12 +23,14 @@ describe('DeleteDocs service', () => { isOnlineOnly = sinon.stub().returns(false); const Changes = () => undefined; Changes.killWatchers = () => undefined; + extractLineageService = { extract: sinon.stub() }; TestBed.configureTestingModule({ providers: [ { provide: DbService, useValue: { get: () => ({ bulkDocs, get }) } }, { provide: SessionService, useValue: { isOnlineOnly } }, { provide: ChangesService, useValue: Changes }, + { provide: ExtractLineageService, useValue: extractLineageService }, ] }); service = TestBed.inject(DeleteDocsService); diff --git a/webapp/tests/karma/ts/services/enketo.service.spec.ts b/webapp/tests/karma/ts/services/enketo.service.spec.ts index a0f7eebb201..296657afb5a 100644 --- a/webapp/tests/karma/ts/services/enketo.service.spec.ts +++ b/webapp/tests/karma/ts/services/enketo.service.spec.ts @@ -10,6 +10,8 @@ import { EnketoPrepopulationDataService } from '@mm-services/enketo-prepopulatio import { AttachmentService } from '@mm-services/attachment.service'; import { TranslateService } from '@mm-services/translate.service'; import { EnketoService, EnketoFormContext } from '@mm-services/enketo.service'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; +import * as FileManager from '../../../../src/js/enketo/file-manager.js'; describe('Enketo service', () => { // return a mock form ready for putting in #dbContent @@ -39,6 +41,7 @@ describe('Enketo service', () => { let EnketoForm; let EnketoPrepopulationData; let translateService; + let extractLineageService; beforeEach(() => { enketoInit = sinon.stub(); @@ -73,6 +76,7 @@ describe('Enketo service', () => { instant: sinon.stub().returnsArg(0), get: sinon.stub(), }; + extractLineageService = { extract: ExtractLineageService.prototype.extract }; TestBed.configureTestingModule({ providers: [ @@ -87,6 +91,7 @@ describe('Enketo service', () => { { provide: EnketoPrepopulationDataService, useValue: { get: EnketoPrepopulationData } }, { provide: AttachmentService, useValue: { add: AddAttachment, remove: removeAttachment } }, { provide: TranslateService, useValue: translateService }, + { provide: ExtractLineageService, useValue: extractLineageService }, ], }); @@ -1012,95 +1017,64 @@ describe('Enketo service', () => { }); describe('Saving attachments', () => { + let getCurrentFiles; + beforeEach(() => { service = TestBed.inject(EnketoService); + getCurrentFiles = sinon + .stub(FileManager, 'getCurrentFiles') + .returns([]); }); - it('should save attachments', () => { - const jqFind = $.fn.find; - sinon.stub($.fn, 'find'); - //@ts-ignore - $.fn.find.callsFake(jqFind); - - $.fn.find - //@ts-ignore - .withArgs('input[type=file][name="/my-form/my_file"]') - .returns([{ files: [{ type: 'image', foo: 'bar' }] }]); - + it('should save attachments', async () => { form.validate.resolves(true); const content = loadXML('file-field'); - form.getDataStr.returns(content); dbGetAttachment.resolves('
'); - - return service - .completeNewReport('my-form', form, { doc: { } }, { _id: 'my-user', phone: '8989' }) - .then(() => { - expect(AddAttachment.calledOnce); - - expect(AddAttachment.args[0][1]).to.equal('user-file/my-form/my_file'); - expect(AddAttachment.args[0][2]).to.deep.equal({ type: 'image', foo: 'bar' }); - expect(AddAttachment.args[0][3]).to.equal('image'); - }); + const file0 = { name: 'my_image', type: 'image' }; + const file1 = { name: 'my_file', type: 'file' }; + getCurrentFiles.returns([file0, file1]); + + await service.completeNewReport( + 'my-form', + form, + { doc: { } }, + { _id: 'my-user', phone: '8989' } + ); + + expect(AddAttachment.calledTwice).to.be.true; + expect(AddAttachment.args[0][1]).to.equal(`user-file-${file0.name}`); + expect(AddAttachment.args[0][2]).to.deep.equal(file0); + expect(AddAttachment.args[0][3]).to.equal(file0.type); + expect(AddAttachment.args[1][1]).to.equal(`user-file-${file1.name}`); + expect(AddAttachment.args[1][2]).to.deep.equal(file1); + expect(AddAttachment.args[1][3]).to.equal(file1.type); }); - it('should remove binary data from content', () => { + it('should remove binary data from content', async () => { form.validate.resolves(true); const content = loadXML('binary-field'); form.getDataStr.returns(content); dbGetAttachment.resolves(''); - return service - .completeNewReport('my-form', form, { doc: { } }, { _id: 'my-user', phone: '8989' }) - .then(([actual]) => { - expect(actual.fields).to.deep.equal({ - name: 'Mary', - age: '10', - gender: 'f', - my_file: '', - }); - expect(AddAttachment.callCount).to.equal(1); - - expect(AddAttachment.args[0][1]).to.equal('user-file/my-form/my_file'); - expect(AddAttachment.args[0][2]).to.deep.equal('some image data'); - expect(AddAttachment.args[0][3]).to.equal('image/png'); - }); - }); - - it('should assign attachment names relative to the form name not the root node name', () => { - const jqFind = $.fn.find; - sinon.stub($.fn, 'find'); - //@ts-ignore - $.fn.find.callsFake(jqFind); - $.fn.find - //@ts-ignore - .withArgs('input[type=file][name="/my-root-element/my_file"]') - .returns([{ files: [{ type: 'image', foo: 'bar' }] }]); - $.fn.find - //@ts-ignore - .withArgs('input[type=file][name="/my-root-element/sub_element/sub_sub_element/other_file"]') - .returns([{ files: [{ type: 'mytype', foo: 'baz' }] }]); - form.validate.resolves(true); - const content = loadXML('deep-file-fields'); - - form.getDataStr.returns(content); - dbGetAttachment.resolves(''); + const [actual] = await service.completeNewReport( + 'my-form', + form, + { doc: { } }, + { _id: 'my-user', phone: '8989' } + ); + expect(actual.fields).to.deep.equal({ + name: 'Mary', + age: '10', + gender: 'f', + my_file: '', + }); + expect(AddAttachment.callCount).to.equal(1); - return service - .completeNewReport('my-form-internal-id', form, { doc: { } }, { _id: 'my-user', phone: '8989' }) - .then(() => { - expect(AddAttachment.callCount).to.equal(2); - - expect(AddAttachment.args[0][1]).to.equal('user-file/my-form-internal-id/my_file'); - expect(AddAttachment.args[0][2]).to.deep.equal({ type: 'image', foo: 'bar' }); - expect(AddAttachment.args[0][3]).to.equal('image'); - - expect(AddAttachment.args[1][1]) - .to.equal('user-file/my-form-internal-id/sub_element/sub_sub_element/other_file'); - expect(AddAttachment.args[1][2]).to.deep.equal({ type: 'mytype', foo: 'baz' }); - expect(AddAttachment.args[1][3]).to.equal('mytype'); - }); + expect(AddAttachment.args[0][1]).to.equal('user-file/my-form/my_file'); + expect(AddAttachment.args[0][2]).to.deep.equal('some image data'); + expect(AddAttachment.args[0][3]).to.equal('image/png'); }); }); diff --git a/webapp/tests/karma/ts/services/extract-lineage.service.spec.ts b/webapp/tests/karma/ts/services/extract-lineage.service.spec.ts index f8fd0e3bdd9..8bfbf1b8324 100644 --- a/webapp/tests/karma/ts/services/extract-lineage.service.spec.ts +++ b/webapp/tests/karma/ts/services/extract-lineage.service.spec.ts @@ -1,62 +1,156 @@ import { TestBed } from '@angular/core/testing'; import { expect } from 'chai'; +import sinon from 'sinon'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; +import { UserSettingsService } from '@mm-services/user-settings.service'; +import { UserContactService } from '@mm-services/user-contact.service'; +import { AuthService } from '@mm-services/auth.service'; describe('ExtractLineageService', () => { let service: ExtractLineageService; + let userSettingsService; + let userContactService; + let authService; beforeEach(() => { - TestBed.configureTestingModule({}); + userSettingsService = { get: sinon.stub() }; + userContactService = { get: sinon.stub() }; + authService = { online: sinon.stub() }; + + TestBed.configureTestingModule({ + providers: [ + { provide: UserContactService, useValue: userContactService }, + { provide: UserSettingsService, useValue: userSettingsService }, + { provide: AuthService, useValue: authService }, + ] + }); service = TestBed.inject(ExtractLineageService); }); + afterEach(() => sinon.restore()); + it('should be created', () => { expect(service).to.exist; }); - it('returns nothing when given nothing', () => { - expect(service.extract(null)).to.equal(null); - }); + describe('extract()', () => { + it('returns nothing when given nothing', () => { + expect(service.extract(null)).to.equal(null); + }); - it('extracts orphan', () => { - const contact = { _id: 'a', name: 'jim' }; - const expected = { _id: 'a' }; + it('extracts orphan', () => { + const contact = { _id: 'a', name: 'jim' }; + const expected = { _id: 'a' }; - expect(service.extract(contact)).to.deep.equal(expected); - }); + expect(service.extract(contact)).to.deep.equal(expected); + }); - it('extracts lineage', () => { - const contact = { - _id: 'a', - name: 'jim', - parent: { - _id: 'b', - age: 55, + it('extracts lineage', () => { + const contact = { + _id: 'a', + name: 'jim', parent: { - _id: 'c', - sex: true, + _id: 'b', + age: 55, parent: { - _id: 'd' + _id: 'c', + sex: true, + parent: { + _id: 'd' + } } } - } - }; - const expected = { - _id: 'a', - parent: { - _id: 'b', + }; + const expected = { + _id: 'a', parent: { - _id: 'c', + _id: 'b', parent: { - _id: 'd' + _id: 'c', + parent: { + _id: 'd' + } } } - } - }; + }; + + expect(service.extract(contact)).to.deep.equal(expected); + // ensure the original contact is unchanged + expect(contact.parent.age).to.equal(55); + }); + }); + + describe('getUserLineageToRemove()', () => { + it('should return null when user is type online', async () => { + authService.online.returns(true); + + const result = await service.getUserLineageToRemove(); + + expect(result).to.be.null; + }); + + it('should return null when user has more than one assigned facility', async () => { + authService.online.returns(false); + userSettingsService.get.resolves({ facility_id: [ 'id-1', 'id-2' ] }); + + const result = await service.getUserLineageToRemove(); + + expect(result).to.be.null; + }); + + it('should return null when parent is not defined', async () => { + authService.online.returns(false); + userSettingsService.get.resolves({ facility_id: [ 'id-1', 'id-2' ] }); + userContactService.get.resolves({ parent: undefined }); + + const result = await service.getUserLineageToRemove(); + + expect(result).to.be.null; + }); + + it('should return facility name when user is type offline and has only one assigned facility', async () => { + authService.online.returns(false); + userSettingsService.get.resolves({ facility_id: 'id-1' }); + userContactService.get.resolves({ parent: { name: 'Kisumu Area' } }); + + const resultWithString = await service.getUserLineageToRemove(); + + expect(resultWithString).to.equal('Kisumu Area'); + + userSettingsService.get.resolves({ facility_id: [ 'id-5' ] }); + userContactService.get.resolves({ parent: { name: 'Kakamega Area' } }); + + const resultWithArray = await service.getUserLineageToRemove(); + + expect(resultWithArray).to.equal('Kakamega Area'); + }); + }); + + describe('removeUserFacility()', () => { + it('should return undefined when lineage is empty or undefined', () => { + const resultWithUndefined = service.removeUserFacility(undefined as any, 'Kakamega Area'); + + expect(resultWithUndefined).to.be.undefined; + + const resultWithEmpty = service.removeUserFacility([], 'Kakamega Area'); + + expect(resultWithEmpty).to.be.undefined; + }); + + it('should filter empty strings when no need to remove facility associated to user', () => { + const resultWithEmpty = service.removeUserFacility([ '', 'place-1', '', 'place-2', '' ], 'Kakamega Area'); + + expect(resultWithEmpty).to.have.members([ 'place-1', 'place-2' ]); + }); + + it('should filter empty strings and remove facility associated to user', () => { + const resultWithEmpty = service.removeUserFacility( + [ '', 'place-1', '', 'place-2', '', 'Kakamega Area' ], + 'Kakamega Area' + ); - expect(service.extract(contact)).to.deep.equal(expected); - // ensure the original contact is unchanged - expect(contact.parent.age).to.equal(55); + expect(resultWithEmpty).to.have.members([ 'place-1', 'place-2' ]); + }); }); }); diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index 10817bae33c..e8a4b451918 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -27,12 +27,13 @@ import { TranslateService } from '@mm-services/translate.service'; import { GlobalActions } from '@mm-actions/global'; import { FeedbackService } from '@mm-services/feedback.service'; import * as medicXpathExtensions from '../../../../src/js/enketo/medic-xpath-extensions'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TrainingCardsService } from '@mm-services/training-cards.service'; import { EnketoService, EnketoFormContext } from '@mm-services/enketo.service'; import { cloneDeep } from 'lodash-es'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; +import * as FileManager from '../../../../src/js/enketo/file-manager.js'; describe('Form service', () => { // return a mock form ready for putting in #dbContent @@ -81,13 +82,14 @@ describe('Form service', () => { let xmlFormGetWithAttachment; let zScoreService; let zScoreUtil; - let chtScriptApiService; + let chtDatasourceService; let chtScriptApi; let globalActions; let trainingCardsService; let consoleErrorMock; let consoleWarnMock; let feedbackService; + let extractLineageService; beforeEach(() => { enketoInit = sinon.stub(); @@ -143,7 +145,7 @@ describe('Form service', () => { zScoreUtil = sinon.stub(); zScoreService = { getScoreUtil: sinon.stub().resolves(zScoreUtil) }; chtScriptApi = sinon.stub(); - chtScriptApiService = { getApi: sinon.stub().resolves(chtScriptApi) }; + chtDatasourceService = { get: sinon.stub().resolves(chtScriptApi) }; globalActions = { setSnackbarContent: sinon.stub(GlobalActions.prototype, 'setSnackbarContent') }; setLastChangedDoc = sinon.stub(ServicesActions.prototype, 'setLastChangedDoc'); trainingCardsService = { @@ -153,6 +155,7 @@ describe('Form service', () => { consoleErrorMock = sinon.stub(console, 'error'); consoleWarnMock = sinon.stub(console, 'warn'); feedbackService = { submit: sinon.stub() }; + extractLineageService = { extract: ExtractLineageService.prototype.extract }; TestBed.configureTestingModule({ providers: [ @@ -176,11 +179,12 @@ describe('Form service', () => { { provide: AttachmentService, useValue: { add: AddAttachment, remove: removeAttachment } }, { provide: XmlFormsService, useValue: xmlFormsService }, { provide: ZScoreService, useValue: zScoreService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService }, + { provide: CHTDatasourceService, useValue: chtDatasourceService }, { provide: TransitionsService, useValue: transitionsService }, { provide: TranslateService, useValue: translateService }, { provide: TrainingCardsService, useValue: trainingCardsService }, { provide: FeedbackService, useValue: feedbackService }, + { provide: ExtractLineageService, useValue: extractLineageService }, ], }); @@ -202,7 +206,7 @@ describe('Form service', () => { await service.init(); expect(zScoreService.getScoreUtil.callCount).to.equal(1); - expect(chtScriptApiService.getApi.callCount).to.equal(1); + expect(chtDatasourceService.get.callCount).to.equal(1); expect(medicXpathExtensions.init.callCount).to.equal(1); expect(medicXpathExtensions.init.args[0]).to.deep.equal([zScoreUtil, toBik_text, moment, chtScriptApi]); }); @@ -1048,16 +1052,11 @@ describe('Form service', () => { }); describe('Saving attachments', () => { - it('should save attachments', () => { - const jqFind = $.fn.find; - sinon.stub($.fn, 'find'); - //@ts-ignore - $.fn.find.callsFake(jqFind); - - $.fn.find - //@ts-ignore - .withArgs('input[type=file][name="/my-form/my_file"]') - .returns([{ files: [{ type: 'image', foo: 'bar' }] }]); + it('should save attachments', async () => { + const file = { name: 'my_file', type: 'image', foo: 'bar' }; + sinon + .stub(FileManager, 'getCurrentFiles') + .returns([file]); form.validate.resolves(true); const content = loadXML('file-field'); @@ -1069,18 +1068,15 @@ describe('Form service', () => { // @ts-ignore const saveDocsSpy = sinon.spy(FormService.prototype, 'saveDocs'); - return service - .save('my-form', form, () => Promise.resolve(true)) - .then(() => { - expect(AddAttachment.calledOnce); - expect(saveDocsSpy.calledOnce); + await service.save('my-form', form, () => Promise.resolve(true)); + expect(AddAttachment.calledOnce).to.be.true; + expect(saveDocsSpy.calledOnce).to.be.true; - expect(AddAttachment.args[0][1]).to.equal('user-file/my-form/my_file'); - expect(AddAttachment.args[0][2]).to.deep.equal({ type: 'image', foo: 'bar' }); - expect(AddAttachment.args[0][3]).to.equal('image'); + expect(AddAttachment.args[0][1]).to.equal(`user-file-${file.name}`); + expect(AddAttachment.args[0][2]).to.deep.equal(file); + expect(AddAttachment.args[0][3]).to.equal(file.type); - expect(globalActions.setSnackbarContent.notCalled); - }); + expect(globalActions.setSnackbarContent.notCalled).to.be.true; }); it('should throw exception if attachments are big', () => { @@ -1317,7 +1313,7 @@ describe('Form service', () => { { provide: AttachmentService, useValue: { add: AddAttachment, remove: removeAttachment } }, { provide: XmlFormsService, useValue: xmlFormsService }, { provide: ZScoreService, useValue: zScoreService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService }, + { provide: CHTDatasourceService, useValue: chtDatasourceService }, { provide: TransitionsService, useValue: transitionsService }, { provide: TranslateService, useValue: translateService }, { provide: TrainingCardsService, useValue: trainingCardsService }, diff --git a/webapp/tests/karma/ts/services/format-data-record.service.spec.ts b/webapp/tests/karma/ts/services/format-data-record.service.spec.ts index c62c50d87f7..39c7577f64b 100644 --- a/webapp/tests/karma/ts/services/format-data-record.service.spec.ts +++ b/webapp/tests/karma/ts/services/format-data-record.service.spec.ts @@ -113,9 +113,9 @@ describe('FormatDataRecord service', () => { return service.format(report).then(result => { expect(result.fields).to.deep.equal([ - { label: 'report.my-form.field1', value: 1, depth: 0, target: undefined }, + { label: 'report.my-form.field1', value: 1, depth: 0, target: undefined, imagePath: undefined }, { label: 'report.my-form.group3', depth: 0 }, - { label: 'report.my-form.group3.field4', value: 3, depth: 1, target: undefined }, + { label: 'report.my-form.group3.field4', value: 3, depth: 1, target: undefined, imagePath: undefined }, ]); }); }); @@ -144,35 +144,82 @@ describe('FormatDataRecord service', () => { return service.format(report).then(result => { expect(result.fields).to.deep.equal([ - { label: 'report.my-form.field1', value: 1, depth: 0, target: undefined }, + { label: 'report.my-form.field1', value: 1, depth: 0, target: undefined, imagePath: undefined }, { label: 'report.my-form.fields', depth: 0 }, - { label: 'report.my-form.fields.field21', value: 1, depth: 1, target: undefined }, + { label: 'report.my-form.fields.field21', value: 1, depth: 1, target: undefined, imagePath: undefined }, { label: 'report.my-form.fields.fields', depth: 1 }, - { label: 'report.my-form.fields.fields.field31', value: 1, depth: 2, target: undefined }, + { label: 'report.my-form.fields.fields.field31', value: 1, depth: 2, target: undefined, imagePath: undefined }, { label: 'report.my-form.fields.fields.fields', depth: 2 }, - { label: 'report.my-form.fields.fields.fields.field41', value: 1, depth: 3, target: undefined }, + { + label: 'report.my-form.fields.fields.fields.field41', + value: 1, depth: 3, target: undefined, imagePath: undefined + }, { label: 'report.my-form.fields.fields.fields.fields', depth: 3 }, - { label: 'report.my-form.fields.fields.fields.fields.field51', value: 1, depth: 3, target: undefined } + { + label: 'report.my-form.fields.fields.fields.fields.field51', + value: 1, depth: 3, target: undefined, imagePath: undefined + } ]); }); }); - it('returns correct image path', () => { - const report = { - _id: 'my-report', - form: 'my-form', - content_type: 'xml', - fields: { - image: 'some image', - deep: { image2: 'other' } - }, - _attachments: { - 'user-file/my-form/image': { content_type: 'image/gif' }, - 'user-file/my-form/deep/image2': { content_type: 'image/png' } - } - }; + describe('image path', () => { + it('returns correct image path', async () => { + const report = { + _id: 'my-report', + form: 'my-form', + content_type: 'xml', + fields: { + image: 'my_image.gif', + deep: { image2: 'other.png' } + }, + _attachments: { + 'user-file-my_image.gif': { content_type: 'image/gif' }, + 'user-file-other.png': { content_type: 'image/png' } + } + }; + + const result = await service.format(report); + + expect(result.fields).to.deep.equal([ + { + label: 'report.my-form.image', + value: 'my_image.gif', + depth: 0, + imagePath: 'user-file-my_image.gif', + target: undefined + }, + { + label: 'report.my-form.deep', + depth: 0 + }, + { + label: 'report.my-form.deep.image2', + value: 'other.png', + depth: 1, + imagePath: 'user-file-other.png', + target: undefined + } + ]); + }); + + it('returns correct image path for attachment with legacy name', async () => { + const report = { + _id: 'my-report', + form: 'my-form', + content_type: 'xml', + fields: { + image: 'some image', + deep: { image2: 'other' } + }, + _attachments: { + 'user-file/my-form/image': { content_type: 'image/gif' }, + 'user-file/my-form/deep/image2': { content_type: 'image/png' } + } + }; + + const result = await service.format(report); - return service.format(report).then(result => { expect(result.fields).to.deep.equal([ { label: 'report.my-form.image', @@ -194,6 +241,31 @@ describe('FormatDataRecord service', () => { } ]); }); + + it('returns empty image path if attachment does not exist for image name', async () => { + const report = { + _id: 'my-report', + form: 'my-form', + content_type: 'xml', + fields: { + image: 'my_image.gif', + }, + _attachments: { + 'thisAintRight.gif': { content_type: 'image/gif' }, + } + }; + + const result = await service.format(report); + expect(result.fields).to.deep.equal([ + { + label: 'report.my-form.image', + value: 'my_image.gif', + depth: 0, + imagePath: undefined, + target: undefined + }, + ]); + }); }); it('detects links to patients', () => { @@ -216,25 +288,29 @@ describe('FormatDataRecord service', () => { label: 'report.my-form.patient_id', value: '1234', depth: 0, - target: { url: ['/contacts', 'some-patient-id'] } + target: { url: ['/contacts', 'some-patient-id'] }, + imagePath: undefined }, { label: 'report.my-form.patient_uuid', value: 'some-uuid', depth: 0, - target: { url: ['/contacts', 'some-patient-id'] } + target: { url: ['/contacts', 'some-patient-id'] }, + imagePath: undefined }, { label: 'report.my-form.patient_name', value: 'linky mclinkface', depth: 0, - target: { url: ['/contacts', 'some-patient-id'] } + target: { url: ['/contacts', 'some-patient-id'] }, + imagePath: undefined }, { label: 'report.my-form.not_patient_id', value: 'pass', depth: 0, - target: undefined + target: undefined, + imagePath: undefined } ]); }); @@ -257,13 +333,15 @@ describe('FormatDataRecord service', () => { label: 'report.my-form.case_id', value: '1234', depth: 0, - target: { filter: 'case_id:1234' } + target: { filter: 'case_id:1234' }, + imagePath: undefined }, { label: 'report.my-form.not_case_id', value: 'pass', depth: 0, - target: undefined + target: undefined, + imagePath: undefined } ]); }); @@ -287,13 +365,15 @@ describe('FormatDataRecord service', () => { label: 'report.my-form.place_id', value: '1234', depth: 0, - target: { url: ['/contacts', 'some-place-id'] } + target: { url: ['/contacts', 'some-place-id'] }, + imagePath: undefined }, { label: 'report.my-form.not_place_id', value: 'pass', depth: 0, - target: undefined + target: undefined, + imagePath: undefined } ]); }); @@ -332,7 +412,8 @@ describe('FormatDataRecord service', () => { label: 'report.my-form.not_case_id', value: 'pass', depth: 0, - target: undefined + target: undefined, + imagePath: undefined } ]); }); diff --git a/webapp/tests/karma/ts/services/rules-engine.service.spec.ts b/webapp/tests/karma/ts/services/rules-engine.service.spec.ts index dfb36250f56..ae696e18d5a 100644 --- a/webapp/tests/karma/ts/services/rules-engine.service.spec.ts +++ b/webapp/tests/karma/ts/services/rules-engine.service.spec.ts @@ -17,7 +17,7 @@ import { ContactTypesService } from '@mm-services/contact-types.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; import { RulesEngineCoreFactoryService, RulesEngineService } from '@mm-services/rules-engine.service'; import { PipesService } from '@mm-services/pipes.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; describe('RulesEngineService', () => { let service: RulesEngineService; @@ -32,7 +32,7 @@ describe('RulesEngineService', () => { let translateFromService; let rulesEngineCoreStubs; let pipesService; - let chtScriptApiService; + let chtDatasourceService; let performanceService; let stopPerformanceTrackStub; let clock; @@ -115,7 +115,7 @@ describe('RulesEngineService', () => { pipesMap: new Map(), getPipeNameVsIsPureMap: PipesService.prototype.getPipeNameVsIsPureMap }; - chtScriptApiService = { getApi: sinon.stub().returns(chtScriptApi) }; + chtDatasourceService = { get: sinon.stub().returns(chtScriptApi) }; stopPerformanceTrackStub = sinon.stub(); performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) }; @@ -176,7 +176,7 @@ describe('RulesEngineService', () => { { provide: TranslateFromService, useValue: translateFromService }, { provide: RulesEngineCoreFactoryService, useValue: rulesEngineCoreFactory }, { provide: PipesService, useValue: pipesService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService } + { provide: CHTDatasourceService, useValue: chtDatasourceService } ] }); }); diff --git a/webapp/tests/karma/ts/services/send-message.service.spec.ts b/webapp/tests/karma/ts/services/send-message.service.spec.ts index 79713f593a9..b320f1b8045 100644 --- a/webapp/tests/karma/ts/services/send-message.service.spec.ts +++ b/webapp/tests/karma/ts/services/send-message.service.spec.ts @@ -20,6 +20,7 @@ describe('SendMessageService', () => { let markReadService; let settingsService; let userSettingsService; + let extractLineageService; beforeEach(() => { const store = { dispatch: sinon.stub() }; @@ -32,6 +33,7 @@ describe('SendMessageService', () => { markReadService = { markAsRead: sinon.stub() }; settingsService = { get: sinon.stub().resolves() }; userSettingsService = { get: sinon.stub().resolves({ phone: '+5551', name: 'jack' }) }; + extractLineageService = { extract: ExtractLineageService.prototype.extract }; TestBed.configureTestingModule({ providers: [ @@ -41,6 +43,7 @@ describe('SendMessageService', () => { { provide: MarkReadService, useValue: markReadService }, { provide: SettingsService, useValue: settingsService }, { provide: UserSettingsService, useValue: userSettingsService }, + { provide: ExtractLineageService, useValue: extractLineageService }, ] }); diff --git a/webapp/tests/karma/ts/services/target-aggregates.service.spec.ts b/webapp/tests/karma/ts/services/target-aggregates.service.spec.ts index 311e1416011..8824995991e 100644 --- a/webapp/tests/karma/ts/services/target-aggregates.service.spec.ts +++ b/webapp/tests/karma/ts/services/target-aggregates.service.spec.ts @@ -84,22 +84,48 @@ describe('TargetAggregatesService', () => { }); describe('isEnabled', () => { - it('should return false when user does not have permission', async () => { - authService.has.resolves(false); + it('should return true when user has permission and user has one facility assigned as array', async () => { + authService.has.resolves(true); + userSettingsService.get.resolves({ facility_id: [ 'facility-1' ] }); const result = await service.isEnabled(); - expect(result).to.equal(false); + expect(result).to.equal(true); + expect(authService.has.callCount).to.equal(1); + expect(authService.has.args[0]).to.deep.equal(['can_aggregate_targets']); + expect(userSettingsService.get.calledOnce).to.be.true; }); - it('should return true when user has permission', async () => { + it('should return true when user has permission and user has one facility assigned as string', async () => { authService.has.resolves(true); + userSettingsService.get.resolves({ facility_id: 'facility-1' }); const result = await service.isEnabled(); expect(result).to.equal(true); expect(authService.has.callCount).to.equal(1); expect(authService.has.args[0]).to.deep.equal(['can_aggregate_targets']); + expect(userSettingsService.get.calledOnce).to.be.true; + }); + + it('should return false when user does not have permission', async () => { + authService.has.resolves(false); + userSettingsService.get.resolves({ facility_id: [ 'facility-1' ] }); + + const result = await service.isEnabled(); + + expect(result).to.equal(false); + expect(userSettingsService.get.notCalled).to.be.true; + }); + + it('should return false when user has more than one facility assigned', async () => { + authService.has.resolves(true); + userSettingsService.get.resolves({ facility_id: [ 'facility-1', 'facility-2' ] }); + + const result = await service.isEnabled(); + + expect(result).to.equal(false); + expect(userSettingsService.get.calledOnce).to.be.true; }); }); diff --git a/webapp/tests/karma/ts/services/transitions/create-user-for-contacts.transition.spec.ts b/webapp/tests/karma/ts/services/transitions/create-user-for-contacts.transition.spec.ts index 1d08d04dc35..c66eb514b9d 100644 --- a/webapp/tests/karma/ts/services/transitions/create-user-for-contacts.transition.spec.ts +++ b/webapp/tests/karma/ts/services/transitions/create-user-for-contacts.transition.spec.ts @@ -1,11 +1,13 @@ import { TestBed } from '@angular/core/testing'; import { provideMockStore } from '@ngrx/store/testing'; -import { DbService } from '@mm-services/db.service'; +import { Person, Place, Qualifier } from '@medic/cht-datasource'; import { CreateUserForContactsService } from '@mm-services/create-user-for-contacts.service'; import { CreateUserForContactsTransition } from '@mm-services/transitions/create-user-for-contacts.transition'; import sinon from 'sinon'; import { expect } from 'chai'; import { UserContactService } from '@mm-services/user-contact.service'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; const deepFreeze = obj => { Object @@ -63,38 +65,50 @@ const getDataRecord = (contact = { _id: ORIGINAL_CONTACT._id }) => ({ }); describe('Create User for Contacts Transition', () => { - let medicDb; - let dbService; + let chtDatasourceService; + let getPerson; + let getPlace; let createUserForContactsService; let userContactService; + let extractLineageService; let transition; beforeEach(() => { - medicDb = { get: sinon.stub() }; - dbService = { - get: sinon - .stub() - .returns(medicDb) + getPerson = sinon.stub(); + getPlace = sinon.stub(); + chtDatasourceService = { + bind: sinon.stub() }; + chtDatasourceService.bind.withArgs(Person.v1.get).resolves(getPerson); + chtDatasourceService.bind.withArgs(Place.v1.get).resolves(getPlace); createUserForContactsService = { isBeingReplaced: sinon.stub(), setReplaced: sinon.stub(), getReplacedBy: sinon.stub(), }; userContactService = { get: sinon.stub() }; + extractLineageService = { extract: ExtractLineageService.prototype.extract }; TestBed.configureTestingModule({ providers: [ provideMockStore(), - { provide: DbService, useValue: dbService }, + { provide: CHTDatasourceService, useValue: chtDatasourceService }, { provide: CreateUserForContactsService, useValue: createUserForContactsService }, { provide: UserContactService, useValue: userContactService }, + { provide: ExtractLineageService, useValue: extractLineageService }, ] }); transition = TestBed.inject(CreateUserForContactsTransition); }); + afterEach(() => { + if (chtDatasourceService.bind.notCalled) { + expect(getPerson.notCalled).to.be.true; + expect(getPlace.notCalled).to.be.true; + } + }); + describe('init', () => { let consoleWarn; @@ -167,8 +181,7 @@ describe('Create User for Contacts Transition', () => { expect(docs).to.be.empty; expect(userContactService.get.callCount).to.equal(0); - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); + expect(chtDatasourceService.bind.notCalled).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(0); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(0); }); @@ -180,8 +193,7 @@ describe('Create User for Contacts Transition', () => { expect(docs).to.deep.equal([REPLACE_USER_DOC]); expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); + expect(chtDatasourceService.bind.notCalled).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(0); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(0); }); @@ -196,8 +208,7 @@ describe('Create User for Contacts Transition', () => { expect(docs).to.deep.equal([submittedDocs[0], submittedDocs[2], submittedDocs[3]]); expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); + expect(chtDatasourceService.bind.notCalled).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(0); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(1); expect(createUserForContactsService.isBeingReplaced.args[0]).to.deep.equal([ORIGINAL_CONTACT]); @@ -207,26 +218,23 @@ describe('Create User for Contacts Transition', () => { it('sets the contact as replaced when the new contact is existing', async () => { const originalUser = { ...ORIGINAL_CONTACT }; userContactService.get.resolves(originalUser); - medicDb.get - .withArgs(NEW_CONTACT._id) - .resolves(NEW_CONTACT); + getPerson.resolves(NEW_CONTACT); const parentPlace = { ...PARENT_PLACE }; - medicDb.get - .withArgs(PARENT_PLACE._id) - .resolves(parentPlace); + getPlace.resolves(parentPlace); const docs = await transition.run([REPLACE_USER_DOC]); - expect(docs).to.deep.equal([REPLACE_USER_DOC, originalUser, parentPlace]); - expect(parentPlace.contact).to.deep.equal({ - _id: NEW_CONTACT._id, - parent: NEW_CONTACT.parent, - }); + expect(docs).to.deep.equal([REPLACE_USER_DOC, originalUser, { + ...parentPlace, + contact: { + _id: NEW_CONTACT._id, + parent: NEW_CONTACT.parent, + } + }]); expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(2); - expect(medicDb.get.callCount).to.equal(2); - expect(medicDb.get.args[0]).to.deep.equal([NEW_CONTACT._id]); - expect(medicDb.get.args[1]).to.deep.equal([parentPlace._id]); + expect(chtDatasourceService.bind.args).to.deep.equal([[Person.v1.get], [Place.v1.get]]); + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(NEW_CONTACT._id))).to.be.true; + expect(getPlace.calledOnceWithExactly(Qualifier.byUuid(parentPlace._id))).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(1); expect(createUserForContactsService.setReplaced.args[0]).to.deep.equal([originalUser, NEW_CONTACT]); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(2); @@ -236,21 +244,20 @@ describe('Create User for Contacts Transition', () => { const originalUser = { ...ORIGINAL_CONTACT }; userContactService.get.resolves(originalUser); const parentPlace = { ...PARENT_PLACE }; - medicDb.get - .withArgs(PARENT_PLACE._id) - .resolves(parentPlace); + getPlace.resolves(parentPlace); const docs = await transition.run([NEW_CONTACT, REPLACE_USER_DOC]); - expect(docs).to.deep.equal([NEW_CONTACT, REPLACE_USER_DOC, originalUser, parentPlace]); - expect(parentPlace.contact).to.deep.equal({ - _id: NEW_CONTACT._id, - parent: NEW_CONTACT.parent, - }); + expect(docs).to.deep.equal([NEW_CONTACT, REPLACE_USER_DOC, originalUser, { + ...parentPlace, + contact: { + _id: NEW_CONTACT._id, + parent: NEW_CONTACT.parent, + } + }]); expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(1); - expect(medicDb.get.callCount).to.equal(1); - expect(medicDb.get.args[0]).to.deep.equal([parentPlace._id]); + expect(chtDatasourceService.bind.args).to.deep.equal([[Place.v1.get]]); + expect(getPlace.calledOnceWithExactly(Qualifier.byUuid(parentPlace._id))).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(1); expect(createUserForContactsService.setReplaced.args[0]).to.deep.equal([originalUser, NEW_CONTACT]); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(2); @@ -261,13 +268,18 @@ describe('Create User for Contacts Transition', () => { userContactService.get.resolves(originalUser); const replaceUserDoc = { ...REPLACE_USER_DOC, contact: { ...REPLACE_USER_DOC.contact } }; const parentPlace = { ...PARENT_PLACE }; - medicDb.get - .withArgs(PARENT_PLACE._id) - .resolves(parentPlace); + getPlace.resolves(parentPlace); const docs0 = await transition.run([NEW_CONTACT, replaceUserDoc]); - expect(docs0).to.deep.equal([NEW_CONTACT, replaceUserDoc, originalUser, parentPlace]); + const updatedParentPlace = { + ...parentPlace, + contact: { + _id: NEW_CONTACT._id, + parent: NEW_CONTACT.parent, + } + }; + expect(docs0).to.deep.equal([NEW_CONTACT, replaceUserDoc, originalUser, updatedParentPlace]); sinon.resetHistory(); const secondNewContact = { ...NEW_CONTACT, id: 'new-contact-2' }; @@ -281,14 +293,17 @@ describe('Create User for Contacts Transition', () => { const anotherDoc = getDataRecord(); createUserForContactsService.isBeingReplaced.returns(true); createUserForContactsService.getReplacedBy.returns(NEW_CONTACT._id); + getPlace.resolves(updatedParentPlace); const docs1 = await transition.run([secondNewContact, secondReplaceUserDoc, anotherDoc]); - expect(docs1).to.deep.equal([secondNewContact, secondReplaceUserDoc, anotherDoc, originalUser, parentPlace]); - expect(parentPlace.contact).to.deep.equal({ - _id: secondNewContact._id, - parent: secondNewContact.parent, - }); + expect(docs1).to.deep.equal([secondNewContact, secondReplaceUserDoc, anotherDoc, originalUser, { + ...updatedParentPlace, + contact: { + _id: secondNewContact._id, + parent: secondNewContact.parent, + } + }]); // Reports re-parented to first new user [secondReplaceUserDoc, anotherDoc].forEach(doc => expect(doc.contact._id).to.equal(NEW_CONTACT._id)); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(2); @@ -296,9 +311,8 @@ describe('Create User for Contacts Transition', () => { expect(createUserForContactsService.getReplacedBy.args).to.deep.equal([[originalUser], [originalUser]]); // User replaced again expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(1); - expect(medicDb.get.callCount).to.equal(1); - expect(medicDb.get.args[0]).to.deep.equal([parentPlace._id]); + expect(chtDatasourceService.bind.args).to.deep.equal([[Place.v1.get]]); + expect(getPlace.calledOnceWithExactly(Qualifier.byUuid(parentPlace._id))).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(1); expect(createUserForContactsService.setReplaced.args[0]).to.deep.equal([originalUser, secondNewContact]); }); @@ -306,23 +320,18 @@ describe('Create User for Contacts Transition', () => { it('does not assign new contact as primary contact when original contact was not primary', async () => { const originalUser = { ...ORIGINAL_CONTACT }; userContactService.get.resolves(originalUser); - medicDb.get - .withArgs(NEW_CONTACT._id) - .resolves(NEW_CONTACT); + getPerson.resolves(NEW_CONTACT); const parentPlace = { ...PARENT_PLACE, contact: { _id: 'different-contact', } }; - medicDb.get - .withArgs(PARENT_PLACE._id) - .resolves(parentPlace); + getPlace.resolves(parentPlace); const docs = await transition.run([REPLACE_USER_DOC]); expect(docs).to.deep.equal([REPLACE_USER_DOC, originalUser]); expect(parentPlace.contact).to.deep.equal({ _id: 'different-contact', }); expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(2); - expect(medicDb.get.callCount).to.equal(2); - expect(medicDb.get.args[0]).to.deep.equal([NEW_CONTACT._id]); - expect(medicDb.get.args[1]).to.deep.equal([parentPlace._id]); + expect(chtDatasourceService.bind.args).to.deep.equal([[Person.v1.get], [Place.v1.get]]); + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(NEW_CONTACT._id))).to.be.true; + expect(getPlace.calledOnceWithExactly(Qualifier.byUuid(parentPlace._id))).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(1); expect(createUserForContactsService.setReplaced.args[0]).to.deep.equal([originalUser, NEW_CONTACT]); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(2); @@ -331,21 +340,16 @@ describe('Create User for Contacts Transition', () => { it('does not assign new contact as primary contact when parent doc not found', async () => { const originalUser = { ...ORIGINAL_CONTACT }; userContactService.get.resolves(originalUser); - medicDb.get - .withArgs(NEW_CONTACT._id) - .resolves(NEW_CONTACT); - medicDb.get - .withArgs(PARENT_PLACE._id) - .rejects({ status: 404 }); + getPerson.resolves(NEW_CONTACT); + getPlace.resolves(null); const docs = await transition.run([REPLACE_USER_DOC]); expect(docs).to.deep.equal([REPLACE_USER_DOC, originalUser]); expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(2); - expect(medicDb.get.callCount).to.equal(2); - expect(medicDb.get.args[0]).to.deep.equal([NEW_CONTACT._id]); - expect(medicDb.get.args[1]).to.deep.equal([PARENT_PLACE._id]); + expect(chtDatasourceService.bind.args).to.deep.equal([[Person.v1.get], [Place.v1.get]]); + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(NEW_CONTACT._id))).to.be.true; + expect(getPlace.calledOnceWithExactly(Qualifier.byUuid(PARENT_PLACE._id))).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(1); expect(createUserForContactsService.setReplaced.args[0]).to.deep.equal([originalUser, NEW_CONTACT]); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(2); @@ -359,17 +363,14 @@ describe('Create User for Contacts Transition', () => { const originalUser = { ...ORIGINAL_CONTACT }; userContactService.get.resolves(originalUser); const newContact = { ...NEW_CONTACT, parent }; - medicDb.get - .withArgs(newContact._id) - .resolves(newContact); + getPerson.resolves(newContact); const docs = await transition.run([REPLACE_USER_DOC]); expect(docs).to.deep.equal([REPLACE_USER_DOC, originalUser]); expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(1); - expect(medicDb.get.callCount).to.equal(1); - expect(medicDb.get.args[0]).to.deep.equal([newContact._id]); + expect(chtDatasourceService.bind.args).to.deep.equal([[Person.v1.get]]); + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(newContact._id))).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(1); expect(createUserForContactsService.setReplaced.args[0]).to.deep.equal([originalUser, newContact]); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(2); @@ -391,8 +392,7 @@ describe('Create User for Contacts Transition', () => { } expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); + expect(chtDatasourceService.bind.notCalled).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(0); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(2); }); @@ -412,8 +412,7 @@ describe('Create User for Contacts Transition', () => { } expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); + expect(chtDatasourceService.bind.notCalled).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(0); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(1); }); @@ -421,7 +420,7 @@ describe('Create User for Contacts Transition', () => { it(`throws an error if the new contact cannot be found`, async () => { userContactService.get.resolves(ORIGINAL_CONTACT); - medicDb.get.rejects({ status: 404 }); + getPerson.resolves(null); try { await transition.run([REPLACE_USER_DOC]); @@ -431,16 +430,15 @@ describe('Create User for Contacts Transition', () => { } expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(1); - expect(medicDb.get.callCount).to.equal(1); - expect(medicDb.get.args[0]).to.deep.equal([NEW_CONTACT._id]); + expect(chtDatasourceService.bind.args).to.deep.equal([[Person.v1.get]]); + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(NEW_CONTACT._id))).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(0); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(2); }); it(`throws an error if an error is encountered getting the new contact`, async () => { userContactService.get.resolves(ORIGINAL_CONTACT); - medicDb.get.rejects({ message: 'Server Error' }); + getPerson.rejects({ message: 'Server Error' }); try { await transition.run([REPLACE_USER_DOC]); @@ -450,9 +448,8 @@ describe('Create User for Contacts Transition', () => { } expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(1); - expect(medicDb.get.callCount).to.equal(1); - expect(medicDb.get.args[0]).to.deep.equal([NEW_CONTACT._id]); + expect(chtDatasourceService.bind.args).to.deep.equal([[Person.v1.get]]); + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(NEW_CONTACT._id))).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(0); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(2); }); @@ -461,9 +458,7 @@ describe('Create User for Contacts Transition', () => { const originalUser = { ...ORIGINAL_CONTACT }; userContactService.get.resolves(originalUser); const parentPlace = { ...PARENT_PLACE }; - medicDb.get - .withArgs(PARENT_PLACE._id) - .resolves(parentPlace); + getPlace.resolves(parentPlace); try { await transition.run([REPLACE_USER_DOC, NEW_CONTACT, REPLACE_USER_DOC]); @@ -473,8 +468,7 @@ describe('Create User for Contacts Transition', () => { } expect(userContactService.get.callCount).to.equal(1); - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); + expect(chtDatasourceService.bind.notCalled).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(0); expect(createUserForContactsService.isBeingReplaced.callCount).to.equal(1); }); @@ -483,8 +477,7 @@ describe('Create User for Contacts Transition', () => { describe(`when the reports submitted do not include a replace user report, but the user is replaced`, () => { afterEach(() => { // Functions from the user replace flow should not be called - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); + expect(chtDatasourceService.bind.notCalled).to.be.true; expect(createUserForContactsService.setReplaced.callCount).to.equal(0); }); diff --git a/webapp/tests/karma/ts/services/update-facility.service.spec.ts b/webapp/tests/karma/ts/services/update-facility.service.spec.ts index 2be74024263..777ec6b9f61 100644 --- a/webapp/tests/karma/ts/services/update-facility.service.spec.ts +++ b/webapp/tests/karma/ts/services/update-facility.service.spec.ts @@ -8,16 +8,18 @@ import { UpdateFacilityService } from '@mm-services/update-facility.service'; describe('UpdateFacility service', () => { let service; + let extractLineageService; let get; let put; beforeEach(() => { get = sinon.stub(); put = sinon.stub(); + extractLineageService = { extract: ExtractLineageService.prototype.extract }; TestBed.configureTestingModule({ providers: [ - ExtractLineageService, + { provide: ExtractLineageService, useValue: extractLineageService }, { provide: DbService, useValue: { get: () => ({ get, put }) } }, ], }); diff --git a/webapp/tests/karma/ts/services/user-contact.service.spec.ts b/webapp/tests/karma/ts/services/user-contact.service.spec.ts index 0cfe87f0c32..85e0ebc0f4d 100644 --- a/webapp/tests/karma/ts/services/user-contact.service.spec.ts +++ b/webapp/tests/karma/ts/services/user-contact.service.spec.ts @@ -1,34 +1,29 @@ import { TestBed } from '@angular/core/testing'; import sinon from 'sinon'; import { expect } from 'chai'; +import { Person, Qualifier } from '@medic/cht-datasource'; import { UserContactService } from '@mm-services/user-contact.service'; import { UserSettingsService } from '@mm-services/user-settings.service'; -import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { DbService } from '@mm-services/db.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; describe('UserContact service', () => { let service: UserContactService; + let getPerson; + let bind; let UserSettings; - let contact; - let medicDb; - let dbService; beforeEach(() => { - contact = sinon.stub(); UserSettings = sinon.stub(); - medicDb = { get: sinon.stub() }; - dbService = { - get: sinon - .stub() - .returns(medicDb) - }; + getPerson = sinon.stub(); + bind = sinon + .stub() + .returns(getPerson); TestBed.configureTestingModule({ providers: [ - { provide: DbService, useValue: dbService }, + { provide: CHTDatasourceService, useValue: { bind } }, { provide: UserSettingsService, useValue: { get: UserSettings } }, - { provide: LineageModelGeneratorService, useValue: { contact } }, ], }); service = TestBed.inject(UserContactService); @@ -41,104 +36,75 @@ describe('UserContact service', () => { it('returns error from user settings', async () => { UserSettings.rejects(new Error('boom')); - try { - await service.get(); - expect(true).to.equal('Expected error to be thrown'); - } catch (err) { - expect(err.message).to.equal('boom'); - } - - expect(contact.callCount).to.equal(0); - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); + + await expect(service.get()).to.be.rejectedWith('boom'); + + expect(bind.notCalled).to.be.true; + expect(getPerson.notCalled).to.be.true; }); - it('returns undefined when no configured contact', async () => { + it('returns null when no configured contact', async () => { UserSettings.resolves({}); + const userContact = await service.get(); - expect(userContact).to.be.undefined; - expect(contact.callCount).to.equal(0); - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); + + expect(userContact).to.be.null; + expect(bind.notCalled).to.be.true; + expect(getPerson.notCalled).to.be.true; }); - describe('when hydrating lineage', () => { - afterEach(() => { - expect(dbService.get.callCount).to.equal(0); - expect(medicDb.get.callCount).to.equal(0); - }); + it('returns null when user settings not in the database', async () => { + UserSettings.rejects({ code: 404, reason: 'missing' }); - it('returns undefined when configured contact not in the database', async () => { - UserSettings.resolves({ contact_id: 'not-found' }); - contact.rejects({ code: 404, reason: 'missing' }); - const userContact = await service.get(); - expect(userContact).to.be.undefined; - expect(contact.callCount).to.equal(1); - expect(contact.args[0]).to.deep.equal(['not-found', { merge: true }]); - }); + const userContact = await service.get(); - it('returns error from getting contact', async () => { - UserSettings.resolves({ contact_id: 'nobody' }); - contact.rejects(new Error('boom')); - try { - await service.get(); - expect(true).to.equal('Expected error to be thrown'); - } catch (err) { - expect(err.message).to.equal('boom'); - } - - expect(contact.callCount).to.equal(1); - expect(contact.args[0]).to.deep.equal(['nobody', { merge: true }]); - }); + expect(userContact).to.be.null; + expect(bind.notCalled).to.be.true; + expect(getPerson.notCalled).to.be.true; + }); - it('returns contact', async () => { - const expected = { _id: 'somebody', name: 'Some Body' }; - UserSettings.resolves({ contact_id: 'somebody' }); - contact.resolves({ doc: expected }); - const actual = await service.get(); - expect(actual).to.deep.equal(expected); - expect(contact.callCount).to.equal(1); - expect(contact.args[0]).to.deep.equal(['somebody', { merge: true }]); - }); + it('returns null when configured contact not in the database', async () => { + UserSettings.resolves({ contact_id: 'not-found' }); + getPerson.resolves(null); + + const userContact = await service.get(); + + expect(userContact).to.be.null; + expect(bind.calledOnceWithExactly(Person.v1.getWithLineage)).to.be.true; + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid('not-found'))).to.be.true; }); - describe('when not hydrating lineage', () => { - afterEach(() => { - expect(contact.callCount).to.equal(0); - expect(dbService.get.callCount).to.equal(1); - }); + it('returns error from getting contact', async () => { + UserSettings.resolves({ contact_id: 'nobody' }); + getPerson.rejects(new Error('boom')); - it('returns undefined when configured contact not in the database', async () => { - UserSettings.resolves({ contact_id: 'not-found' }); - medicDb.get.rejects({ code: 404, reason: 'missing' }); - const contact = await service.get({ hydrateLineage: false }); - expect(contact).to.be.undefined; - expect(medicDb.get.callCount).to.equal(1); - expect(medicDb.get.args[0]).to.deep.equal(['not-found']); - }); + await expect(service.get()).to.be.rejectedWith('boom'); - it('returns error from getting contact', async () => { - UserSettings.resolves({ contact_id: 'nobody' }); - medicDb.get.rejects(new Error('boom')); - try { - await service.get({ hydrateLineage: false }); - expect(true).to.equal('Expected error to be thrown'); - } catch (err) { - expect(err.message).to.equal('boom'); - } - - expect(medicDb.get.callCount).to.equal(1); - expect(medicDb.get.args[0][0], 'nobo).to.equal('); - }); + expect(bind.calledOnceWithExactly(Person.v1.getWithLineage)).to.be.true; + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid('nobody'))).to.be.true; + }); - it('returns contact', async () => { - const expected = { _id: 'somebody', name: 'Some Body' }; - UserSettings.resolves({ contact_id: 'somebody' }); - medicDb.get.resolves(expected); - const actual = await service.get({ hydrateLineage: false }); - expect(actual).to.deep.equal(expected); - expect(medicDb.get.callCount).to.equal(1); - expect(medicDb.get.args[0]).to.deep.equal(['somebody']); - }); + it('returns contact with lineage', async () => { + const expected = { _id: 'somebody', name: 'Some Body' }; + UserSettings.resolves({ contact_id: 'somebody' }); + getPerson.resolves(expected); + + const actual = await service.get(); + + expect(actual).to.equal(expected); + expect(bind.calledOnceWithExactly(Person.v1.getWithLineage)).to.be.true; + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(expected._id))).to.be.true; + }); + + it('returns contact without lineage', async () => { + const expected = { _id: 'somebody', name: 'Some Body' }; + UserSettings.resolves({ contact_id: 'somebody' }); + getPerson.resolves(expected); + + const actual = await service.get({ hydrateLineage: false }); + + expect(actual).to.equal(expected); + expect(bind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(getPerson.calledOnceWithExactly(Qualifier.byUuid(expected._id))).to.be.true; }); }); diff --git a/webapp/tests/mocha/unit/enketo/lib/window.spec.js b/webapp/tests/mocha/unit/enketo/lib/window.spec.js new file mode 100644 index 00000000000..58dd1a6c412 --- /dev/null +++ b/webapp/tests/mocha/unit/enketo/lib/window.spec.js @@ -0,0 +1,18 @@ +const { expect } = require('chai'); +const windowLib = require('../../../../../src/js/enketo/lib/window'); + +describe('window lib', () => { + let originalWindow; + + before(() => originalWindow = global.window); + after(() => global.window = originalWindow); + + it('getCurrentHref', () => { + global.window = { + location: { + href: 'http://example.com' + } + }; + expect(windowLib.getCurrentHref()).to.equal(global.window.location.href); + }); +}); diff --git a/webapp/tsconfig.base.json b/webapp/tsconfig.base.json index df1885dd36f..b41cb20ee68 100755 --- a/webapp/tsconfig.base.json +++ b/webapp/tsconfig.base.json @@ -11,6 +11,7 @@ "importHelpers": true, "target": "es2020", "module": "es2020", + "skipLibCheck": true, "lib": [ "es2016", "dom" diff --git a/webapp/web-components/cht-form/src/app.module.ts b/webapp/web-components/cht-form/src/app.module.ts index 09307c0f3f2..2c4c095673f 100644 --- a/webapp/web-components/cht-form/src/app.module.ts +++ b/webapp/web-components/cht-form/src/app.module.ts @@ -7,11 +7,15 @@ import { DoBootstrap, Injector, NgModule } from '@angular/core'; import { createCustomElement } from '@angular/elements'; import { AppComponent } from './app.component'; import { TranslateService } from '@mm-services/translate.service'; +import { HttpClientModule } from '@angular/common/http'; +import { StoreModule } from '@ngrx/store'; @NgModule({ declarations: [AppComponent], imports: [ BrowserModule, + HttpClientModule, + StoreModule.forRoot(), TranslateModule.forRoot({ loader: { provide: TranslateLoader, diff --git a/webapp/web-components/cht-form/src/stubs/db.service.ts b/webapp/web-components/cht-form/src/stubs/db.service.ts index c7c632896f3..d340d5b0902 100644 --- a/webapp/web-components/cht-form/src/stubs/db.service.ts +++ b/webapp/web-components/cht-form/src/stubs/db.service.ts @@ -4,9 +4,11 @@ import { Injectable } from '@angular/core'; providedIn: 'root' }) export class DbService { - public get(): any { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public get(context?: { meta?: boolean; remote?: boolean }): any { return { get: () => Promise.resolve(), + info: () => Promise.resolve(), query: async (selector, options) => { if (selector === 'medic-client/contacts_by_phone') { // Used by phone-widget to look for contacts with same phone number