From e2701487c997fb534fb0258b2383bdd49a923927 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 18 Oct 2024 14:58:19 -0500 Subject: [PATCH 01/12] Add offline ddoc logic to webapp along with initial contacts_by_freetext view --- webapp/package.json | 2 +- webapp/src/js/offline-ddocs/.eslintrc | 5 + .../contacts_by_freetext.js | 53 ++++++++ webapp/src/ts/app.component.ts | 3 +- webapp/src/ts/services/db-sync.service.ts | 28 ++++ .../ts/services/offline-ddocs/design-doc.ts | 11 ++ .../medic-offline-freetext.ddoc.ts | 10 ++ webapp/tests/karma/ts/app.component.spec.ts | 3 + .../karma/ts/services/db-sync.service.spec.ts | 59 ++++++++ webapp/tests/mocha/ts/.mocharc.js | 8 ++ .../medic-offline-freetext.spec.ts | 126 ++++++++++++++++++ webapp/tests/mocha/unit/views/utils.js | 10 +- webapp/tsconfig.spec.json | 3 +- 13 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 webapp/src/js/offline-ddocs/.eslintrc create mode 100644 webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js create mode 100644 webapp/src/ts/services/offline-ddocs/design-doc.ts create mode 100644 webapp/src/ts/services/offline-ddocs/medic-offline-freetext.ddoc.ts create mode 100644 webapp/tests/mocha/ts/.mocharc.js create mode 100644 webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts diff --git a/webapp/package.json b/webapp/package.json index ad807a7194d..a1479ed1a2d 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -14,7 +14,7 @@ }, "scripts": { "postinstall": "patch-package && ng cache clean", - "unit:mocha": "UNIT_TEST_ENV=1 mocha 'tests/mocha/**/*.spec.js'", + "unit:mocha": "UNIT_TEST_ENV=1 mocha 'tests/mocha/**/*.spec.js' && mocha --config tests/mocha/ts/.mocharc.js", "unit:mocha:tz": "TZ=Canada/Pacific npm run unit:mocha && TZ=Africa/Monrovia npm run unit:mocha && TZ=Pacific/Auckland npm run unit:mocha", "unit:cht-form": "ng test cht-form", "unit": "UNIT_TEST_ENV=1 ng test webapp", diff --git a/webapp/src/js/offline-ddocs/.eslintrc b/webapp/src/js/offline-ddocs/.eslintrc new file mode 100644 index 00000000000..15b79d3fd83 --- /dev/null +++ b/webapp/src/js/offline-ddocs/.eslintrc @@ -0,0 +1,5 @@ +{ + "globals": { + "emit": true + } +} diff --git a/webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js b/webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js new file mode 100644 index 00000000000..2f1de80bfab --- /dev/null +++ b/webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js @@ -0,0 +1,53 @@ +module.exports.map = function(doc) { + const skip = [ '_id', '_rev', 'type', 'contact_type', 'refid', 'geolocation' ]; + + const usedKeys = []; + const emitMaybe = (key, value) => { + if (usedKeys.indexOf(key) === -1 && // Not already used + key.length > 2 // Not too short + ) { + usedKeys.push(key); + emit([key], value); + } + }; + + const emitField = (key, value, order) => { + if (!value) { + return; + } + const lowerKey = key.toLowerCase(); + if (skip.indexOf(lowerKey) !== -1 || /_date$/.test(lowerKey)) { + return; + } + if (typeof value === 'string') { + value + .toLowerCase() + .split(/\s+/) + .forEach((word) => emitMaybe(word, order)); + } + }; + + const getTypeIndex = () => { + const types = [ 'district_hospital', 'health_center', 'clinic', 'person' ]; + if (doc.type !== 'contact') { + return types.indexOf(doc.type); + } + + const contactTypeIdx = types.indexOf(doc.contact_type); + if (contactTypeIdx >= 0) { + return contactTypeIdx; + } + + return doc.contact_type; + }; + + const idx = getTypeIndex(); + if (idx !== -1) { + const dead = !!doc.date_of_death; + const muted = !!doc.muted; + const order = dead + ' ' + muted + ' ' + idx + ' ' + (doc.name && doc.name.toLowerCase()); + Object.keys(doc).forEach(function(key) { + emitField(key, doc[key], order); + }); + } +}; diff --git a/webapp/src/ts/app.component.ts b/webapp/src/ts/app.component.ts index a0079893d52..793a1caded5 100644 --- a/webapp/src/ts/app.component.ts +++ b/webapp/src/ts/app.component.ts @@ -279,7 +279,7 @@ export class AppComponent implements OnInit, AfterViewInit { }); } - ngOnInit(): void { + async ngOnInit() { this.recordStartupTelemetry(); this.subscribeToStore(); this.setupRouter(); @@ -292,6 +292,7 @@ export class AppComponent implements OnInit, AfterViewInit { // initialisation tasks that can occur after the UI has been rendered this.setupPromise = Promise.resolve() + .then(() => this.dbSyncService.init()) .then(() => this.chtDatasourceService.isInitialized()) .then(() => this.checkPrivacyPolicy()) .then(() => (this.initialisationComplete = true)) diff --git a/webapp/src/ts/services/db-sync.service.ts b/webapp/src/ts/services/db-sync.service.ts index a8beac99ccd..eabc80af627 100644 --- a/webapp/src/ts/services/db-sync.service.ts +++ b/webapp/src/ts/services/db-sync.service.ts @@ -14,6 +14,8 @@ import { TranslateService } from '@mm-services/translate.service'; import { MigrationsService } from '@mm-services/migrations.service'; import { ReplicationService } from '@mm-services/replication.service'; import { PerformanceService } from '@mm-services/performance.service'; +import medicOfflineDdoc from '@mm-services/offline-ddocs/medic-offline-freetext.ddoc'; +import { DesignDoc } from '@mm-services/offline-ddocs/design-doc'; const READ_ONLY_TYPES = ['form', 'translations']; const READ_ONLY_IDS = ['resources', 'branding', 'service-worker-meta', 'zscore-charts', 'settings', 'partners']; @@ -24,6 +26,9 @@ const SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes const META_SYNC_INTERVAL = 30 * 60 * 1000; // 30 minutes const BATCH_SIZE = 100; const MAX_SUCCESSIVE_SYNCS = 2; +const OFFLINE_DDOCS = [ + medicOfflineDdoc, +]; const readOnlyFilter = function(doc) { // Never replicate "purged" documents upwards @@ -63,6 +68,21 @@ type SyncState = { type SyncStateListener = Parameters['subscribe']>[0]; +const getRev = async (db, id: string): Promise => db + .get(id) + .then(({ _rev }) => _rev as string) + .catch((e) => { + if (e.status === 404) { + return undefined; + } + throw e; + }); + +const initDdoc = (db) => async (ddoc: DesignDoc) => db.put({ + ...ddoc, + _rev: await getRev(db, ddoc._id), +}); + @Injectable({ providedIn: 'root' }) @@ -100,6 +120,14 @@ export class DBSyncService { return !this.sessionService.isOnlineOnly(); } + init = async () => { + if (!this.isEnabled()) { + return; + } + const medicDb = await this.dbService.get(); + return Promise.all(OFFLINE_DDOCS.map(initDdoc(medicDb))); + }; + private replicateToRetry({ batchSize=BATCH_SIZE }={}) { const telemetryEntry = new DbSyncTelemetry( this.telemetryService, diff --git a/webapp/src/ts/services/offline-ddocs/design-doc.ts b/webapp/src/ts/services/offline-ddocs/design-doc.ts new file mode 100644 index 00000000000..270a77cc6e3 --- /dev/null +++ b/webapp/src/ts/services/offline-ddocs/design-doc.ts @@ -0,0 +1,11 @@ +export interface DesignDoc { + readonly _id: `_design/${string}`; + readonly _rev?: string; + readonly views: { + [key: string]: { + map: string; + }; + }; +} + +export const packageView = ({ map }: { map: Function }) => ({ map: map.toString() }); diff --git a/webapp/src/ts/services/offline-ddocs/medic-offline-freetext.ddoc.ts b/webapp/src/ts/services/offline-ddocs/medic-offline-freetext.ddoc.ts new file mode 100644 index 00000000000..56ae4073533 --- /dev/null +++ b/webapp/src/ts/services/offline-ddocs/medic-offline-freetext.ddoc.ts @@ -0,0 +1,10 @@ +import { DesignDoc, packageView } from './design-doc'; + +import * as contactByFreetext from '../../../js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js'; + +export default { + _id: '_design/medic-offline-freetext', + views: { + contacts_by_freetext: packageView(contactByFreetext), + } +}; diff --git a/webapp/tests/karma/ts/app.component.spec.ts b/webapp/tests/karma/ts/app.component.spec.ts index f19119185ef..388ca764e4b 100644 --- a/webapp/tests/karma/ts/app.component.spec.ts +++ b/webapp/tests/karma/ts/app.component.spec.ts @@ -151,6 +151,7 @@ describe('AppComponent', () => { isOnlineOnly: sinon.stub() }; dbSyncService = { + init: sinon.stub().resolves(), addUpdateListener: sinon.stub(), isEnabled: sinon.stub().returns(false), sync: sinon.stub(), @@ -499,6 +500,7 @@ describe('AppComponent', () => { }]); expect(globalActions.updateReplicationStatus.getCall(1).args).to.deep.equal([{disabled: true}]); expect(dbSyncService.subscribe.callCount).to.equal(1); + expect(dbSyncService.init.calledOnceWithExactly()).to.be.true; }); it('should sync db if enabled', async () => { @@ -519,6 +521,7 @@ describe('AppComponent', () => { expect(dbSyncService.sync.callCount).to.equal(1); expect(dbSyncService.subscribe.callCount).to.equal(1); + expect(dbSyncService.init.calledOnceWithExactly()).to.be.true; }); it('should set dbSync replication status in subcription callback', async () => { diff --git a/webapp/tests/karma/ts/services/db-sync.service.spec.ts b/webapp/tests/karma/ts/services/db-sync.service.spec.ts index 5feab563cae..2b4cc231517 100644 --- a/webapp/tests/karma/ts/services/db-sync.service.spec.ts +++ b/webapp/tests/karma/ts/services/db-sync.service.spec.ts @@ -15,6 +15,7 @@ import { PerformanceService } from '@mm-services/performance.service'; import { TranslateService } from '@mm-services/translate.service'; import { MigrationsService } from '@mm-services/migrations.service'; import { ReplicationService } from '@mm-services/replication.service'; +import medicOfflineDdoc from '@mm-services/offline-ddocs/medic-offline-freetext.ddoc'; describe('DBSync service', () => { let service:DBSyncService; @@ -102,6 +103,8 @@ describe('DBSync service', () => { replicate: { to: to }, info: sinon.stub().resolves({ update_seq: 99 }), allDocs: sinon.stub(), + get: sinon.stub(), + put: sinon.stub(), }; localMetaDb = { replicate: { to: metaTo, from: metaFrom }, @@ -148,6 +151,62 @@ describe('DBSync service', () => { clock.restore(); }); + describe('init', () => { + it('adds new ddoc to database', async () => { + isOnlineOnly.returns(false); + localMedicDb.get.rejects({ status: 404 }); + + await service.init(); + + expect(isOnlineOnly.calledOnceWithExactly()).to.be.true; + expect(db.calledOnceWithExactly()).to.be.true; + expect(localMedicDb.get.calledOnceWithExactly(medicOfflineDdoc._id)).to.be.true; + expect(localMedicDb.put.calledOnceWithExactly({ + ...medicOfflineDdoc, + _rev: undefined, + })).to.be.true; + }); + + it('updates existing ddoc in database', async () => { + isOnlineOnly.returns(false); + localMedicDb.get.resolves({ _rev: '1-abc' }); + + await service.init(); + + expect(isOnlineOnly.calledOnceWithExactly()).to.be.true; + expect(db.calledOnceWithExactly()).to.be.true; + expect(localMedicDb.get.calledOnceWithExactly(medicOfflineDdoc._id)).to.be.true; + expect(localMedicDb.put.calledOnceWithExactly({ + ...medicOfflineDdoc, + _rev: '1-abc', + })).to.be.true; + }); + + it('rejects when there is an error getting the ddoc', async () => { + isOnlineOnly.returns(false); + const expectedError = new Error('Error getting ddoc'); + localMedicDb.get.rejects(expectedError); + + await expect(service.init()).to.be.rejectedWith(expectedError); + + expect(isOnlineOnly.calledOnceWithExactly()).to.be.true; + expect(db.calledOnceWithExactly()).to.be.true; + expect(localMedicDb.get.calledOnceWithExactly(medicOfflineDdoc._id)).to.be.true; + expect(localMedicDb.put.notCalled).to.be.true; + }); + + it('does nothing when online only', async () => { + isOnlineOnly.returns(true); + + await service.init(); + + expect(isOnlineOnly.calledOnceWithExactly()).to.be.true; + expect(db.notCalled).to.be.true; + expect(localMedicDb.get.notCalled).to.be.true; + expect(localMedicDb.put.notCalled).to.be.true; + }); + }); + describe('sync', () => { it('does nothing for admins', () => { isOnlineOnly.returns(true); diff --git a/webapp/tests/mocha/ts/.mocharc.js b/webapp/tests/mocha/ts/.mocharc.js new file mode 100644 index 00000000000..96b1e599f3e --- /dev/null +++ b/webapp/tests/mocha/ts/.mocharc.js @@ -0,0 +1,8 @@ +const chaiAsPromised = require('chai-as-promised'); +const chai = require('chai'); +chai.use(chaiAsPromised); + +module.exports = { + spec: 'tests/mocha/ts/**/*.spec.ts', + require: 'ts-node/register', +}; diff --git a/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts b/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts new file mode 100644 index 00000000000..c9931a41b1f --- /dev/null +++ b/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts @@ -0,0 +1,126 @@ +import medicOfflineFreetext from '../../../../src/ts/services/offline-ddocs/medic-offline-freetext.ddoc'; +import { expect } from 'chai'; +import { buildViewMapFn } from '../../unit/views/utils.js'; + +const expectedValue = ( + {typeIndex, name, dead = false, muted = false }: Record = {} +) => `${dead} ${muted} ${typeIndex} ${name}`; + +describe('medic-offline-freetext', () => { + it('has the correct _id', () => { + expect(medicOfflineFreetext._id).to.equal('_design/medic-offline-freetext'); + }); + + describe('contacts_by_freetext', () => { + const mapFn = buildViewMapFn(medicOfflineFreetext.views.contacts_by_freetext.map); + + afterEach(() => mapFn.reset()); + + [ + ['district_hospital', 0], + ['health_center', 1], + ['clinic', 2], + ['person', 3], + ['contact', 0, 'district_hospital'], + ['contact', 1, 'health_center'], + ['contact', 2, 'clinic'], + ['contact', 3, 'person'] + ].forEach(([type, typeIndex, contactType]) => it('emits numerical index for default type', () => { + const doc = { type, hello: 'world', contact_type: contactType }; + const emitted = mapFn(doc, true); + expect(emitted).to.deep.equal([{ key: ['world'], value: expectedValue({ typeIndex }) }]); + })); + + it('emits contact_type index for custom type', () => { + const typeIndex = 'my_custom_type'; + const doc = { contact_type: typeIndex, type: 'contact', hello: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.deep.equal([{ key: ['world'], value: expectedValue({ typeIndex }) }]); + }); + + it('emits nothing when type is invalid', () => { + const doc = { type: 'invalid', hello: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + }); + + it('emits death status in value', () => { + const doc = { type: 'district_hospital', date_of_death: '2021-01-01' }; + const emitted = mapFn(doc, true); + expect(emitted).to.deep.equal([{ key: ['2021-01-01'], value: expectedValue({ typeIndex: 0, dead: true }) }]); + }); + + it('emits muted status in value', () => { + const doc = { type: 'district_hospital', muted: true, hello: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.deep.equal([{ key: ['world'], value: expectedValue({ typeIndex: 0, muted: true }) }]); + }); + + [ + 'hello', 'HeLlO' + ].forEach(name => it('emits name in value', () => { + const doc = { type: 'district_hospital', name }; + const emitted = mapFn(doc, true); + expect(emitted).to.deep.equal([ + { key: [name.toLowerCase()], value: expectedValue({ typeIndex: 0, name: name.toLowerCase() }) } + ]); + })); + + [ + null, undefined, { hello: 'world' }, {}, 1, true + ].forEach(hello => it('emits nothing when value is not a string', () => { + const doc = { type: 'district_hospital', hello }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); + + [ + '', 't', 'to' + ].forEach(hello => it('emits nothing when value is too short', () => { + const doc = { type: 'district_hospital', hello }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); + + [ + '_id', '_rev', 'type', 'contact_type', 'refid', 'geolocation' + ].forEach(key => it('emits nothing for a skipped field', () => { + const doc = { type: 'district_hospital', [key]: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); + + it('emits nothing for fields that end with "_date"', () => { + const doc = { type: 'district_hospital', reported_date: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + }); + + it('emits value only once', () => { + const doc = { + type: 'district_hospital', + hello: 'world world', + hello1: 'world', + hello3: 'world', + }; + const emitted = mapFn(doc, true); + expect(emitted).to.deep.equal([{ key: ['world'], value: expectedValue({ typeIndex: 0 }) }]); + }); + + it('emits each word in a string', () => { + const doc = { + type: 'district_hospital', + hello: `the quick\nbrown\tfox`, + }; + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([ + { key: ['the'], value }, + { key: ['quick'], value }, + { key: ['brown'], value }, + { key: ['fox'], value }, + ]); + }); + }); +}); diff --git a/webapp/tests/mocha/unit/views/utils.js b/webapp/tests/mocha/unit/views/utils.js index e827ab349a5..39b0cb1d068 100644 --- a/webapp/tests/mocha/unit/views/utils.js +++ b/webapp/tests/mocha/unit/views/utils.js @@ -5,9 +5,7 @@ const vm = require('vm'); const MAP_ARG_NAME = 'doc'; -module.exports.loadView = (dbName, ddocName, viewName) => { - const mapPath = path.join(__dirname, '../../../../../ddocs', dbName, ddocName, 'views', viewName, '/map.js'); - const mapString = fs.readFileSync(mapPath, 'utf8'); +module.exports.buildViewMapFn = (mapString) => { const mapScript = new vm.Script('(' + mapString + ')(' + MAP_ARG_NAME + ');'); const emitted = []; @@ -35,6 +33,12 @@ module.exports.loadView = (dbName, ddocName, viewName) => { return mapFn; }; +module.exports.loadView = (dbName, ddocName, viewName) => { + const mapPath = path.join(__dirname, '../../../../../ddocs', dbName, ddocName, 'views', viewName, '/map.js'); + const mapString = fs.readFileSync(mapPath, 'utf8'); + return module.exports.buildViewMapFn(mapString); +}; + module.exports.assertIncludesPair = (array, pair) => { assert.ok(array.find((keyArray) => keyArray[0] === pair[0] && keyArray[1] === pair[1])); }; diff --git a/webapp/tsconfig.spec.json b/webapp/tsconfig.spec.json index 6301e1bcc3c..74acd4099e7 100755 --- a/webapp/tsconfig.spec.json +++ b/webapp/tsconfig.spec.json @@ -16,6 +16,7 @@ ], "include": [ "tests/karma/**/*.spec.ts", - "tests/karma/**/*.d.ts" + "tests/karma/**/*.d.ts", + "tests/mocha/ts/**/*.spec.ts", ] } From 710fdb092546c3b1479ee92386b76c1ad55939c3 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 21 Nov 2024 16:30:48 -0600 Subject: [PATCH 02/12] Emit key:value entries from offline freetext index. --- .../contacts_by_freetext.js | 19 +++-- .../medic-offline-freetext.spec.ts | 76 ++++++++++++++++--- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js b/webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js index 2f1de80bfab..41db51f237d 100644 --- a/webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js +++ b/webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js @@ -25,6 +25,9 @@ module.exports.map = function(doc) { .split(/\s+/) .forEach((word) => emitMaybe(word, order)); } + if (typeof value === 'number' || typeof value === 'string') { + emitMaybe(lowerKey + ':' + value, order); + } }; const getTypeIndex = () => { @@ -42,12 +45,14 @@ module.exports.map = function(doc) { }; const idx = getTypeIndex(); - if (idx !== -1) { - const dead = !!doc.date_of_death; - const muted = !!doc.muted; - const order = dead + ' ' + muted + ' ' + idx + ' ' + (doc.name && doc.name.toLowerCase()); - Object.keys(doc).forEach(function(key) { - emitField(key, doc[key], order); - }); + if (idx === -1) { + return; } + + const dead = !!doc.date_of_death; + const muted = !!doc.muted; + const order = `${dead} ${muted} ${idx} ${(doc.name && doc.name.toLowerCase())}`; + Object + .keys(doc) + .forEach((key) => emitField(key, doc[key], order)); }; diff --git a/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts b/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts index c9931a41b1f..ff4d7fb75cb 100644 --- a/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts +++ b/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts @@ -27,15 +27,26 @@ describe('medic-offline-freetext', () => { ['contact', 3, 'person'] ].forEach(([type, typeIndex, contactType]) => it('emits numerical index for default type', () => { const doc = { type, hello: 'world', contact_type: contactType }; + const emitted = mapFn(doc, true); - expect(emitted).to.deep.equal([{ key: ['world'], value: expectedValue({ typeIndex }) }]); + + const value = expectedValue({ typeIndex }); + expect(emitted).to.deep.equal([ + { key: ['world'], value }, + { key: ['hello:world'], value } + ]); })); it('emits contact_type index for custom type', () => { const typeIndex = 'my_custom_type'; const doc = { contact_type: typeIndex, type: 'contact', hello: 'world' }; const emitted = mapFn(doc, true); - expect(emitted).to.deep.equal([{ key: ['world'], value: expectedValue({ typeIndex }) }]); + + const value = expectedValue({ typeIndex }); + expect(emitted).to.deep.equal([ + { key: ['world'], value }, + { key: ['hello:world'], value } + ]); }); it('emits nothing when type is invalid', () => { @@ -46,42 +57,76 @@ describe('medic-offline-freetext', () => { it('emits death status in value', () => { const doc = { type: 'district_hospital', date_of_death: '2021-01-01' }; + const emitted = mapFn(doc, true); - expect(emitted).to.deep.equal([{ key: ['2021-01-01'], value: expectedValue({ typeIndex: 0, dead: true }) }]); + + const value = expectedValue({ typeIndex: 0, dead: true }); + expect(emitted).to.deep.equal([ + { key: ['2021-01-01'], value }, + { key: ['date_of_death:2021-01-01'], value } + ]); }); it('emits muted status in value', () => { const doc = { type: 'district_hospital', muted: true, hello: 'world' }; + const emitted = mapFn(doc, true); - expect(emitted).to.deep.equal([{ key: ['world'], value: expectedValue({ typeIndex: 0, muted: true }) }]); + + const value = expectedValue({ typeIndex: 0, muted: true }); + expect(emitted).to.deep.equal([ + { key: ['world'], value }, + { key: ['hello:world'], value } + ]); }); [ 'hello', 'HeLlO' ].forEach(name => it('emits name in value', () => { const doc = { type: 'district_hospital', name }; + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0, name: name.toLowerCase() }); expect(emitted).to.deep.equal([ - { key: [name.toLowerCase()], value: expectedValue({ typeIndex: 0, name: name.toLowerCase() }) } + { key: [name.toLowerCase()], value }, + { key: [`name:${name}`], value } ]); })); [ - null, undefined, { hello: 'world' }, {}, 1, true - ].forEach(hello => it('emits nothing when value is not a string', () => { + null, undefined, { hello: 'world' }, {}, true + ].forEach(hello => it('emits nothing when value is not a string or number', () => { const doc = { type: 'district_hospital', hello }; const emitted = mapFn(doc, true); expect(emitted).to.be.empty; })); + it('emits only key:value when value is number', () => { + const doc = { type: 'district_hospital', hello: 1234 }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([{ key: ['hello:1234'], value }]); + }); + [ - '', 't', 'to' - ].forEach(hello => it('emits nothing when value is too short', () => { + 't', 'to' + ].forEach(hello => it('emits nothing but key:value when value is too short', () => { const doc = { type: 'district_hospital', hello }; + const emitted = mapFn(doc, true); - expect(emitted).to.be.empty; + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([{ key: [`hello:${hello}`], value }]); })); + it('emits nothing when value is empty', () => { + const doc = { type: 'district_hospital', hello: '' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + }); + [ '_id', '_rev', 'type', 'contact_type', 'refid', 'geolocation' ].forEach(key => it('emits nothing for a skipped field', () => { @@ -103,8 +148,16 @@ describe('medic-offline-freetext', () => { hello1: 'world', hello3: 'world', }; + const emitted = mapFn(doc, true); - expect(emitted).to.deep.equal([{ key: ['world'], value: expectedValue({ typeIndex: 0 }) }]); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([ + { key: ['world'], value }, + { key: ['hello:world world'], value }, + { key: ['hello1:world'], value }, + { key: ['hello3:world'], value } + ]); }); it('emits each word in a string', () => { @@ -120,6 +173,7 @@ describe('medic-offline-freetext', () => { { key: ['quick'], value }, { key: ['brown'], value }, { key: ['fox'], value }, + { key: ['hello:the quick\nbrown\tfox'], value }, ]); }); }); From c2e3122790fa5a22a32b2b770fcd54cd8e304120 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 21 Nov 2024 16:34:33 -0600 Subject: [PATCH 03/12] Fix ngOnInit to not be async --- webapp/src/ts/app.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/ts/app.component.ts b/webapp/src/ts/app.component.ts index 793a1caded5..53a7c91133a 100644 --- a/webapp/src/ts/app.component.ts +++ b/webapp/src/ts/app.component.ts @@ -279,7 +279,7 @@ export class AppComponent implements OnInit, AfterViewInit { }); } - async ngOnInit() { + ngOnInit(): void { this.recordStartupTelemetry(); this.subscribeToStore(); this.setupRouter(); From def8409943b37690db16dacf5786becb8d0967b3 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 22 Nov 2024 11:31:15 -0600 Subject: [PATCH 04/12] Hack shared-libs/search to use offline freetext search --- shared-libs/search/src/generate-search-requests.js | 9 +++++---- shared-libs/search/src/search.js | 10 ++++++++-- shared-libs/search/test/search.js | 3 ++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/shared-libs/search/src/generate-search-requests.js b/shared-libs/search/src/generate-search-requests.js index dcb936001e3..cb695528b90 100644 --- a/shared-libs/search/src/generate-search-requests.js +++ b/shared-libs/search/src/generate-search-requests.js @@ -258,10 +258,10 @@ const requestBuilders = { } return requests; }, - contacts: (filters, extensions) => { + contacts: (filters, freetextDdocName, extensions) => { const shouldSortByLastVisitedDate = module.exports.shouldSortByLastVisitedDate(extensions); - const freetextRequests = freetextRequest(filters, 'medic-client/contacts_by_freetext'); + const freetextRequests = freetextRequest(filters, `${freetextDdocName}/contacts_by_freetext`); const contactsByParentRequest = getContactsByParentRequest(filters); const typeRequest = contactTypeRequest(filters, shouldSortByLastVisitedDate); const hasTypeRequest = typeRequest?.params.keys.length; @@ -313,12 +313,13 @@ const requestBuilders = { // // NB: options is not required: it is an optimisation shortcut module.exports = { - generate: (type, filters, extensions) => { + generate: (type, filters, extensions, offline) => { + const freetextDdocName = offline ? 'medic-offline-freetext' : 'medic-client'; const builder = requestBuilders[type]; if (!builder) { throw new Error('Unknown type: ' + type); } - return builder(filters, extensions); + return builder(filters, freetextDdocName, extensions); }, shouldSortByLastVisitedDate: (extensions) => { return Boolean(extensions?.sortByLastVisitedDate); diff --git a/shared-libs/search/src/search.js b/shared-libs/search/src/search.js index 69855223e1b..2168ea8fabd 100644 --- a/shared-libs/search/src/search.js +++ b/shared-libs/search/src/search.js @@ -9,6 +9,11 @@ _.flatten = require('lodash/flatten'); _.intersection = require('lodash/intersection'); const GenerateSearchRequests = require('./generate-search-requests'); +const ddocExists = (db, ddocId) => db + .get(ddocId) + .then(() => true) + .catch(() => false); + module.exports = function(Promise, DB) { // Get the subset of rows, in appropriate order, according to options. const getPageRows = function(type, rows, options) { @@ -111,17 +116,18 @@ module.exports = function(Promise, DB) { }); }; - return function(type, filters, options, extensions) { + return async (type, filters, options, extensions) => { options = options || {}; _.defaults(options, { limit: 50, skip: 0 }); + const offline = await ddocExists(DB, '_design/medic-offline-freetext'); const cacheQueryResults = GenerateSearchRequests.shouldSortByLastVisitedDate(extensions); let requests; try { - requests = GenerateSearchRequests.generate(type, filters, extensions); + requests = GenerateSearchRequests.generate(type, filters, extensions, offline); } catch (err) { return Promise.reject(err); } diff --git a/shared-libs/search/test/search.js b/shared-libs/search/test/search.js index 4c296ee8f5f..bc26439dd12 100644 --- a/shared-libs/search/test/search.js +++ b/shared-libs/search/test/search.js @@ -14,7 +14,8 @@ describe('Search service', function() { GenerateSearchRequests.generate = sinon.stub(); GenerateSearchRequests.shouldSortByLastVisitedDate = sinon.stub(); DB = { - query: sinon.stub() + query: sinon.stub(), + get: sinon.stub().rejects() }; service = Search(Promise, DB); From eab88967fb4e1cbb51e67ae92b65e6cde2b20361 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 22 Nov 2024 15:56:12 -0600 Subject: [PATCH 05/12] Move offline ddoc logic to the bootstrapper js code. --- .../db/initial-replication.wdio-spec.js | 6 +- webapp/package.json | 2 +- webapp/src/js/bootstrapper/index.js | 4 +- .../offline-ddocs/.eslintrc | 0 .../js/bootstrapper/offline-ddocs/index.js | 22 ++ .../contacts_by_freetext.js | 12 +- .../medic-offline-freetext/index.js | 10 + webapp/src/ts/app.component.ts | 1 - webapp/src/ts/services/db-sync.service.ts | 28 -- .../ts/services/offline-ddocs/design-doc.ts | 11 - .../medic-offline-freetext.ddoc.ts | 10 - webapp/tests/karma/ts/app.component.spec.ts | 3 - .../karma/ts/services/db-sync.service.spec.ts | 59 ---- webapp/tests/mocha/ts/.mocharc.js | 8 - .../medic-offline-freetext.spec.ts | 180 ----------- webapp/tests/mocha/unit/bootstrapper.spec.js | 15 + .../unit/views/contacts_by_freetext.spec.js | 289 ++++++++++++------ webapp/tsconfig.spec.json | 3 +- 18 files changed, 258 insertions(+), 405 deletions(-) rename webapp/src/js/{ => bootstrapper}/offline-ddocs/.eslintrc (100%) create mode 100644 webapp/src/js/bootstrapper/offline-ddocs/index.js rename webapp/src/js/{ => bootstrapper}/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js (82%) create mode 100644 webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js delete mode 100644 webapp/src/ts/services/offline-ddocs/design-doc.ts delete mode 100644 webapp/src/ts/services/offline-ddocs/medic-offline-freetext.ddoc.ts delete mode 100644 webapp/tests/mocha/ts/.mocharc.js delete mode 100644 webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts diff --git a/tests/e2e/default/db/initial-replication.wdio-spec.js b/tests/e2e/default/db/initial-replication.wdio-spec.js index 92a907973d7..cc9d7db8b16 100644 --- a/tests/e2e/default/db/initial-replication.wdio-spec.js +++ b/tests/e2e/default/db/initial-replication.wdio-spec.js @@ -6,6 +6,8 @@ const commonPage = require('@page-objects/default/common/common.wdio.page'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const dataFactory = require('@factories/cht/generate'); +const LOCAL_ONLY_DOC_IDS = ['_design/medic-offline-freetext']; + describe('initial-replication', () => { const LOCAL_LOG = '_local/initial-replication'; @@ -52,11 +54,11 @@ describe('initial-replication', () => { await commonPage.sync(false, 7000); - const localAllDocs = await chtDbUtils.getDocs(); + const localAllDocs = (await chtDbUtils.getDocs()).filter(doc => !LOCAL_ONLY_DOC_IDS.includes(doc.id)); const localDocIds = dataFactory.ids(localAllDocs); // no additional docs to download - expect(docIdsPreSync).to.have.members(localDocIds); + expect(docIdsPreSync).to.have.members([...localDocIds, ...LOCAL_ONLY_DOC_IDS]); const serverAllDocs = await getServerDocs(localDocIds); diff --git a/webapp/package.json b/webapp/package.json index a1479ed1a2d..ad807a7194d 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -14,7 +14,7 @@ }, "scripts": { "postinstall": "patch-package && ng cache clean", - "unit:mocha": "UNIT_TEST_ENV=1 mocha 'tests/mocha/**/*.spec.js' && mocha --config tests/mocha/ts/.mocharc.js", + "unit:mocha": "UNIT_TEST_ENV=1 mocha 'tests/mocha/**/*.spec.js'", "unit:mocha:tz": "TZ=Canada/Pacific npm run unit:mocha && TZ=Africa/Monrovia npm run unit:mocha && TZ=Pacific/Auckland npm run unit:mocha", "unit:cht-form": "ng test cht-form", "unit": "UNIT_TEST_ENV=1 ng test webapp", diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index f0136478a51..d19013fac29 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -7,6 +7,7 @@ const utils = require('./utils'); const purger = require('./purger'); const initialReplicationLib = require('./initial-replication'); + const offlineDdocs = require('./offline-ddocs'); const ONLINE_ROLE = 'mm-online'; @@ -112,7 +113,8 @@ .all([ initialReplicationLib.isReplicationNeeded(localDb, userCtx), swRegistration, - setReplicationId(POUCHDB_OPTIONS, localDb) + setReplicationId(POUCHDB_OPTIONS, localDb), + offlineDdocs.init(localDb) ]) .then(([isInitialReplicationNeeded]) => { utils.setOptions(POUCHDB_OPTIONS); diff --git a/webapp/src/js/offline-ddocs/.eslintrc b/webapp/src/js/bootstrapper/offline-ddocs/.eslintrc similarity index 100% rename from webapp/src/js/offline-ddocs/.eslintrc rename to webapp/src/js/bootstrapper/offline-ddocs/.eslintrc diff --git a/webapp/src/js/bootstrapper/offline-ddocs/index.js b/webapp/src/js/bootstrapper/offline-ddocs/index.js new file mode 100644 index 00000000000..e50b91c215d --- /dev/null +++ b/webapp/src/js/bootstrapper/offline-ddocs/index.js @@ -0,0 +1,22 @@ +const medicOfflineFreetext = require('./medic-offline-freetext'); + +const getRev = async (db, id) => db + .get(id) + .then(({ _rev }) => _rev) + .catch((e) => { + if (e.status === 404) { + return undefined; + } + throw e; + }); + +const initDdoc = async (db, ddoc) => db.put({ + ...ddoc, + _rev: await getRev(db, ddoc._id), +}); + +const init = async (db) => initDdoc(db, medicOfflineFreetext); + +module.exports = { + init +}; diff --git a/webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js similarity index 82% rename from webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js rename to webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js index 41db51f237d..3af7c0ea508 100644 --- a/webapp/src/js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js @@ -1,5 +1,5 @@ module.exports.map = function(doc) { - const skip = [ '_id', '_rev', 'type', 'contact_type', 'refid', 'geolocation' ]; + const skip = [ '_id', '_rev', 'type', 'refid', 'geolocation' ]; const usedKeys = []; const emitMaybe = (key, value) => { @@ -20,13 +20,13 @@ module.exports.map = function(doc) { return; } if (typeof value === 'string') { - value - .toLowerCase() + const lowerValue = value.toLowerCase(); + lowerValue .split(/\s+/) .forEach((word) => emitMaybe(word, order)); - } - if (typeof value === 'number' || typeof value === 'string') { - emitMaybe(lowerKey + ':' + value, order); + emitMaybe(`${lowerKey}:${lowerValue}`, order); + } else if (typeof value === 'number') { + emitMaybe(`${lowerKey}:${value}`, order); } }; diff --git a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js new file mode 100644 index 00000000000..5be830f37a2 --- /dev/null +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js @@ -0,0 +1,10 @@ +const contactByFreetext = require('./contacts_by_freetext'); + +const packageView = ({ map }) => ({ map: map.toString() }); + +module.exports = { + _id: '_design/medic-offline-freetext', + views: { + contacts_by_freetext: packageView(contactByFreetext), + } +}; diff --git a/webapp/src/ts/app.component.ts b/webapp/src/ts/app.component.ts index 53a7c91133a..a0079893d52 100644 --- a/webapp/src/ts/app.component.ts +++ b/webapp/src/ts/app.component.ts @@ -292,7 +292,6 @@ export class AppComponent implements OnInit, AfterViewInit { // initialisation tasks that can occur after the UI has been rendered this.setupPromise = Promise.resolve() - .then(() => this.dbSyncService.init()) .then(() => this.chtDatasourceService.isInitialized()) .then(() => this.checkPrivacyPolicy()) .then(() => (this.initialisationComplete = true)) diff --git a/webapp/src/ts/services/db-sync.service.ts b/webapp/src/ts/services/db-sync.service.ts index eabc80af627..a8beac99ccd 100644 --- a/webapp/src/ts/services/db-sync.service.ts +++ b/webapp/src/ts/services/db-sync.service.ts @@ -14,8 +14,6 @@ import { TranslateService } from '@mm-services/translate.service'; import { MigrationsService } from '@mm-services/migrations.service'; import { ReplicationService } from '@mm-services/replication.service'; import { PerformanceService } from '@mm-services/performance.service'; -import medicOfflineDdoc from '@mm-services/offline-ddocs/medic-offline-freetext.ddoc'; -import { DesignDoc } from '@mm-services/offline-ddocs/design-doc'; const READ_ONLY_TYPES = ['form', 'translations']; const READ_ONLY_IDS = ['resources', 'branding', 'service-worker-meta', 'zscore-charts', 'settings', 'partners']; @@ -26,9 +24,6 @@ const SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes const META_SYNC_INTERVAL = 30 * 60 * 1000; // 30 minutes const BATCH_SIZE = 100; const MAX_SUCCESSIVE_SYNCS = 2; -const OFFLINE_DDOCS = [ - medicOfflineDdoc, -]; const readOnlyFilter = function(doc) { // Never replicate "purged" documents upwards @@ -68,21 +63,6 @@ type SyncState = { type SyncStateListener = Parameters['subscribe']>[0]; -const getRev = async (db, id: string): Promise => db - .get(id) - .then(({ _rev }) => _rev as string) - .catch((e) => { - if (e.status === 404) { - return undefined; - } - throw e; - }); - -const initDdoc = (db) => async (ddoc: DesignDoc) => db.put({ - ...ddoc, - _rev: await getRev(db, ddoc._id), -}); - @Injectable({ providedIn: 'root' }) @@ -120,14 +100,6 @@ export class DBSyncService { return !this.sessionService.isOnlineOnly(); } - init = async () => { - if (!this.isEnabled()) { - return; - } - const medicDb = await this.dbService.get(); - return Promise.all(OFFLINE_DDOCS.map(initDdoc(medicDb))); - }; - private replicateToRetry({ batchSize=BATCH_SIZE }={}) { const telemetryEntry = new DbSyncTelemetry( this.telemetryService, diff --git a/webapp/src/ts/services/offline-ddocs/design-doc.ts b/webapp/src/ts/services/offline-ddocs/design-doc.ts deleted file mode 100644 index 270a77cc6e3..00000000000 --- a/webapp/src/ts/services/offline-ddocs/design-doc.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface DesignDoc { - readonly _id: `_design/${string}`; - readonly _rev?: string; - readonly views: { - [key: string]: { - map: string; - }; - }; -} - -export const packageView = ({ map }: { map: Function }) => ({ map: map.toString() }); diff --git a/webapp/src/ts/services/offline-ddocs/medic-offline-freetext.ddoc.ts b/webapp/src/ts/services/offline-ddocs/medic-offline-freetext.ddoc.ts deleted file mode 100644 index 56ae4073533..00000000000 --- a/webapp/src/ts/services/offline-ddocs/medic-offline-freetext.ddoc.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DesignDoc, packageView } from './design-doc'; - -import * as contactByFreetext from '../../../js/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js'; - -export default { - _id: '_design/medic-offline-freetext', - views: { - contacts_by_freetext: packageView(contactByFreetext), - } -}; diff --git a/webapp/tests/karma/ts/app.component.spec.ts b/webapp/tests/karma/ts/app.component.spec.ts index 388ca764e4b..f19119185ef 100644 --- a/webapp/tests/karma/ts/app.component.spec.ts +++ b/webapp/tests/karma/ts/app.component.spec.ts @@ -151,7 +151,6 @@ describe('AppComponent', () => { isOnlineOnly: sinon.stub() }; dbSyncService = { - init: sinon.stub().resolves(), addUpdateListener: sinon.stub(), isEnabled: sinon.stub().returns(false), sync: sinon.stub(), @@ -500,7 +499,6 @@ describe('AppComponent', () => { }]); expect(globalActions.updateReplicationStatus.getCall(1).args).to.deep.equal([{disabled: true}]); expect(dbSyncService.subscribe.callCount).to.equal(1); - expect(dbSyncService.init.calledOnceWithExactly()).to.be.true; }); it('should sync db if enabled', async () => { @@ -521,7 +519,6 @@ describe('AppComponent', () => { expect(dbSyncService.sync.callCount).to.equal(1); expect(dbSyncService.subscribe.callCount).to.equal(1); - expect(dbSyncService.init.calledOnceWithExactly()).to.be.true; }); it('should set dbSync replication status in subcription callback', async () => { diff --git a/webapp/tests/karma/ts/services/db-sync.service.spec.ts b/webapp/tests/karma/ts/services/db-sync.service.spec.ts index 2b4cc231517..5feab563cae 100644 --- a/webapp/tests/karma/ts/services/db-sync.service.spec.ts +++ b/webapp/tests/karma/ts/services/db-sync.service.spec.ts @@ -15,7 +15,6 @@ import { PerformanceService } from '@mm-services/performance.service'; import { TranslateService } from '@mm-services/translate.service'; import { MigrationsService } from '@mm-services/migrations.service'; import { ReplicationService } from '@mm-services/replication.service'; -import medicOfflineDdoc from '@mm-services/offline-ddocs/medic-offline-freetext.ddoc'; describe('DBSync service', () => { let service:DBSyncService; @@ -103,8 +102,6 @@ describe('DBSync service', () => { replicate: { to: to }, info: sinon.stub().resolves({ update_seq: 99 }), allDocs: sinon.stub(), - get: sinon.stub(), - put: sinon.stub(), }; localMetaDb = { replicate: { to: metaTo, from: metaFrom }, @@ -151,62 +148,6 @@ describe('DBSync service', () => { clock.restore(); }); - describe('init', () => { - it('adds new ddoc to database', async () => { - isOnlineOnly.returns(false); - localMedicDb.get.rejects({ status: 404 }); - - await service.init(); - - expect(isOnlineOnly.calledOnceWithExactly()).to.be.true; - expect(db.calledOnceWithExactly()).to.be.true; - expect(localMedicDb.get.calledOnceWithExactly(medicOfflineDdoc._id)).to.be.true; - expect(localMedicDb.put.calledOnceWithExactly({ - ...medicOfflineDdoc, - _rev: undefined, - })).to.be.true; - }); - - it('updates existing ddoc in database', async () => { - isOnlineOnly.returns(false); - localMedicDb.get.resolves({ _rev: '1-abc' }); - - await service.init(); - - expect(isOnlineOnly.calledOnceWithExactly()).to.be.true; - expect(db.calledOnceWithExactly()).to.be.true; - expect(localMedicDb.get.calledOnceWithExactly(medicOfflineDdoc._id)).to.be.true; - expect(localMedicDb.put.calledOnceWithExactly({ - ...medicOfflineDdoc, - _rev: '1-abc', - })).to.be.true; - }); - - it('rejects when there is an error getting the ddoc', async () => { - isOnlineOnly.returns(false); - const expectedError = new Error('Error getting ddoc'); - localMedicDb.get.rejects(expectedError); - - await expect(service.init()).to.be.rejectedWith(expectedError); - - expect(isOnlineOnly.calledOnceWithExactly()).to.be.true; - expect(db.calledOnceWithExactly()).to.be.true; - expect(localMedicDb.get.calledOnceWithExactly(medicOfflineDdoc._id)).to.be.true; - expect(localMedicDb.put.notCalled).to.be.true; - }); - - it('does nothing when online only', async () => { - isOnlineOnly.returns(true); - - await service.init(); - - expect(isOnlineOnly.calledOnceWithExactly()).to.be.true; - expect(db.notCalled).to.be.true; - expect(localMedicDb.get.notCalled).to.be.true; - expect(localMedicDb.put.notCalled).to.be.true; - }); - }); - describe('sync', () => { it('does nothing for admins', () => { isOnlineOnly.returns(true); diff --git a/webapp/tests/mocha/ts/.mocharc.js b/webapp/tests/mocha/ts/.mocharc.js deleted file mode 100644 index 96b1e599f3e..00000000000 --- a/webapp/tests/mocha/ts/.mocharc.js +++ /dev/null @@ -1,8 +0,0 @@ -const chaiAsPromised = require('chai-as-promised'); -const chai = require('chai'); -chai.use(chaiAsPromised); - -module.exports = { - spec: 'tests/mocha/ts/**/*.spec.ts', - require: 'ts-node/register', -}; diff --git a/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts b/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts deleted file mode 100644 index ff4d7fb75cb..00000000000 --- a/webapp/tests/mocha/ts/offline-ddocs/medic-offline-freetext.spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -import medicOfflineFreetext from '../../../../src/ts/services/offline-ddocs/medic-offline-freetext.ddoc'; -import { expect } from 'chai'; -import { buildViewMapFn } from '../../unit/views/utils.js'; - -const expectedValue = ( - {typeIndex, name, dead = false, muted = false }: Record = {} -) => `${dead} ${muted} ${typeIndex} ${name}`; - -describe('medic-offline-freetext', () => { - it('has the correct _id', () => { - expect(medicOfflineFreetext._id).to.equal('_design/medic-offline-freetext'); - }); - - describe('contacts_by_freetext', () => { - const mapFn = buildViewMapFn(medicOfflineFreetext.views.contacts_by_freetext.map); - - afterEach(() => mapFn.reset()); - - [ - ['district_hospital', 0], - ['health_center', 1], - ['clinic', 2], - ['person', 3], - ['contact', 0, 'district_hospital'], - ['contact', 1, 'health_center'], - ['contact', 2, 'clinic'], - ['contact', 3, 'person'] - ].forEach(([type, typeIndex, contactType]) => it('emits numerical index for default type', () => { - const doc = { type, hello: 'world', contact_type: contactType }; - - const emitted = mapFn(doc, true); - - const value = expectedValue({ typeIndex }); - expect(emitted).to.deep.equal([ - { key: ['world'], value }, - { key: ['hello:world'], value } - ]); - })); - - it('emits contact_type index for custom type', () => { - const typeIndex = 'my_custom_type'; - const doc = { contact_type: typeIndex, type: 'contact', hello: 'world' }; - const emitted = mapFn(doc, true); - - const value = expectedValue({ typeIndex }); - expect(emitted).to.deep.equal([ - { key: ['world'], value }, - { key: ['hello:world'], value } - ]); - }); - - it('emits nothing when type is invalid', () => { - const doc = { type: 'invalid', hello: 'world' }; - const emitted = mapFn(doc, true); - expect(emitted).to.be.empty; - }); - - it('emits death status in value', () => { - const doc = { type: 'district_hospital', date_of_death: '2021-01-01' }; - - const emitted = mapFn(doc, true); - - const value = expectedValue({ typeIndex: 0, dead: true }); - expect(emitted).to.deep.equal([ - { key: ['2021-01-01'], value }, - { key: ['date_of_death:2021-01-01'], value } - ]); - }); - - it('emits muted status in value', () => { - const doc = { type: 'district_hospital', muted: true, hello: 'world' }; - - const emitted = mapFn(doc, true); - - const value = expectedValue({ typeIndex: 0, muted: true }); - expect(emitted).to.deep.equal([ - { key: ['world'], value }, - { key: ['hello:world'], value } - ]); - }); - - [ - 'hello', 'HeLlO' - ].forEach(name => it('emits name in value', () => { - const doc = { type: 'district_hospital', name }; - - const emitted = mapFn(doc, true); - - const value = expectedValue({ typeIndex: 0, name: name.toLowerCase() }); - expect(emitted).to.deep.equal([ - { key: [name.toLowerCase()], value }, - { key: [`name:${name}`], value } - ]); - })); - - [ - null, undefined, { hello: 'world' }, {}, true - ].forEach(hello => it('emits nothing when value is not a string or number', () => { - const doc = { type: 'district_hospital', hello }; - const emitted = mapFn(doc, true); - expect(emitted).to.be.empty; - })); - - it('emits only key:value when value is number', () => { - const doc = { type: 'district_hospital', hello: 1234 }; - - const emitted = mapFn(doc, true); - - const value = expectedValue({ typeIndex: 0 }); - expect(emitted).to.deep.equal([{ key: ['hello:1234'], value }]); - }); - - [ - 't', 'to' - ].forEach(hello => it('emits nothing but key:value when value is too short', () => { - const doc = { type: 'district_hospital', hello }; - - const emitted = mapFn(doc, true); - - const value = expectedValue({ typeIndex: 0 }); - expect(emitted).to.deep.equal([{ key: [`hello:${hello}`], value }]); - })); - - it('emits nothing when value is empty', () => { - const doc = { type: 'district_hospital', hello: '' }; - const emitted = mapFn(doc, true); - expect(emitted).to.be.empty; - }); - - [ - '_id', '_rev', 'type', 'contact_type', 'refid', 'geolocation' - ].forEach(key => it('emits nothing for a skipped field', () => { - const doc = { type: 'district_hospital', [key]: 'world' }; - const emitted = mapFn(doc, true); - expect(emitted).to.be.empty; - })); - - it('emits nothing for fields that end with "_date"', () => { - const doc = { type: 'district_hospital', reported_date: 'world' }; - const emitted = mapFn(doc, true); - expect(emitted).to.be.empty; - }); - - it('emits value only once', () => { - const doc = { - type: 'district_hospital', - hello: 'world world', - hello1: 'world', - hello3: 'world', - }; - - const emitted = mapFn(doc, true); - - const value = expectedValue({ typeIndex: 0 }); - expect(emitted).to.deep.equal([ - { key: ['world'], value }, - { key: ['hello:world world'], value }, - { key: ['hello1:world'], value }, - { key: ['hello3:world'], value } - ]); - }); - - it('emits each word in a string', () => { - const doc = { - type: 'district_hospital', - hello: `the quick\nbrown\tfox`, - }; - const emitted = mapFn(doc, true); - - const value = expectedValue({ typeIndex: 0 }); - expect(emitted).to.deep.equal([ - { key: ['the'], value }, - { key: ['quick'], value }, - { key: ['brown'], value }, - { key: ['fox'], value }, - { key: ['hello:the quick\nbrown\tfox'], value }, - ]); - }); - }); -}); diff --git a/webapp/tests/mocha/unit/bootstrapper.spec.js b/webapp/tests/mocha/unit/bootstrapper.spec.js index d3cf5580b54..b587bff5b86 100644 --- a/webapp/tests/mocha/unit/bootstrapper.spec.js +++ b/webapp/tests/mocha/unit/bootstrapper.spec.js @@ -11,6 +11,7 @@ const bootstrapper = rewire('../../../src/js/bootstrapper'); const purger = require('../../../src/js/bootstrapper/purger'); const utils = require('../../../src/js/bootstrapper/utils'); const initialReplication = require('../../../src/js/bootstrapper/initial-replication'); +const offlineDdocs = require('../../../src/js/bootstrapper/offline-ddocs'); let originalDocument; let originalWindow; @@ -25,6 +26,7 @@ let localAllDocs; let localId; let purgeOn; let localMetaClose; +let offlineDdocsInit; let localMedicDb; let remoteMedicDb; @@ -41,6 +43,7 @@ describe('bootstrapper', () => { localAllDocs = sinon.stub(); localId = sinon.stub().resolves(); localMetaClose = sinon.stub(); + offlineDdocsInit = sinon.stub(offlineDdocs, 'init').resolves(); localMedicDb = { get: localGet, @@ -126,6 +129,7 @@ describe('bootstrapper', () => { setUserCtxCookie({ name: 'jimbo', roles: [ '_admin' ] }); await bootstrapper(pouchDbOptions); assert.equal(pouchDb.callCount, 0); + assert.isTrue(offlineDdocsInit.notCalled); }); it('should initialize replication header with local db id', async () => { @@ -149,6 +153,7 @@ describe('bootstrapper', () => { }); assert.equal(utils.setOptions.callCount, 1); assert.equal(purger.purgeMeta.callCount, 1); + assert.isTrue(offlineDdocsInit.calledOnce); }); it('should initialize purger with correct options', async () => { @@ -163,6 +168,7 @@ describe('bootstrapper', () => { assert.equal(utils.setOptions.callCount, 1); assert.deepEqual(utils.setOptions.args[0], [pouchDbOptions]); + assert.isTrue(offlineDdocsInit.calledOnce); }); it('returns if initial replication is not needed', async () => { @@ -183,6 +189,7 @@ describe('bootstrapper', () => { localMedicDb, { locale: undefined, name: 'jim' }, ]]); + assert.isTrue(offlineDdocsInit.calledOnce); }); it('performs initial replication', async () => { @@ -207,6 +214,7 @@ describe('bootstrapper', () => { ]); expect(initialReplication.replicate.callCount).to.equal(1); expect(initialReplication.replicate.args).to.deep.equal([[ remoteMedicDb, localMedicDb ]]); + assert.isTrue(offlineDdocsInit.calledOnce); }); it('should redirect to login when no userCtx cookie found', async () => { @@ -224,6 +232,7 @@ describe('bootstrapper', () => { window.location.href, '/medic/login?redirect=http%3A%2F%2Flocalhost%3A5988%2Fmedic%2F_design%2Fmedic%2F_rewrite%2F%23%2Fmessages' ); + assert.isTrue(offlineDdocsInit.notCalled); }); it('should redirect to login when initial replication returns unauthorized', async () => { @@ -240,6 +249,7 @@ describe('bootstrapper', () => { window.location.href, '/medic/login?redirect=http%3A%2F%2Flocalhost%3A5988%2Fmedic%2F_design%2Fmedic%2F_rewrite%2F%23%2Fmessages' ); + assert.isTrue(offlineDdocsInit.calledOnce); }); it('returns other errors in initial replication', async () => { @@ -250,6 +260,7 @@ describe('bootstrapper', () => { sinon.stub(initialReplication, 'replicate').rejects(new Error('message')); await expect(bootstrapper(pouchDbOptions)).to.be.rejectedWith(Error, 'message'); + assert.isTrue(offlineDdocsInit.calledOnce); }); it('returns error if initial replication is still needed', async () => { @@ -266,6 +277,7 @@ describe('bootstrapper', () => { assert.equal(remoteClose.callCount, 1); assert.equal(utils.setOptions.callCount, 1); expect(initialReplication.isReplicationNeeded.callCount).to.equal(2); + assert.isTrue(offlineDdocsInit.calledOnce); }); it('error results if service worker fails registration', async () => { @@ -276,6 +288,7 @@ describe('bootstrapper', () => { window.navigator.serviceWorker.register = failingRegister; await expect(bootstrapper(pouchDbOptions)).to.be.rejectedWith(Error, 'redundant'); + assert.isTrue(offlineDdocsInit.calledOnce); }); it('should run meta purge on startup', async () => { @@ -288,6 +301,7 @@ describe('bootstrapper', () => { await bootstrapper(pouchDbOptions); assert.equal(purger.purgeMeta.callCount, 1); + assert.isTrue(offlineDdocsInit.calledOnce); }); it('should catch meta purge errors', async () => { @@ -308,5 +322,6 @@ describe('bootstrapper', () => { assert.equal(purger.purgeMeta.callCount, 1); assert.deepEqual(purger.purgeMeta.args[0], [localMetaDb]); + assert.isTrue(offlineDdocsInit.calledOnce); }); }); diff --git a/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js b/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js index 6d5d5e3f1eb..bec8e66e834 100644 --- a/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js +++ b/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js @@ -1,95 +1,198 @@ -const _ = require('lodash'); -const assert = require('chai').assert; -const utils = require('./utils'); - -const doc = { - _id: '3c0c4575468bc9b7ce066a279b022e8e', - _rev: '2-5fb6ead9b03232a4cf1e0171c5434469', - name: 'Test Contact of Clinic', - date_of_birth: '', - phone: '+13125551212', - alternate_phone: '', - notes: '', - type: 'person', - reported_date: 1491910934051, - transitions: { - maintain_info_document: { - last_rev: 2, - seq: '241-g1AAAACbeJzLYWBgYMpgTmEQTM4vTc5ISXLIyU9OzMnILy7JAUklMiTV____', - ok: true - } - } -}; - -const nonAsciiDoc = { - _id: '3e32235b-7111-4a69-a0a1-b3094f257891', - _rev: '1-e19cb2355b26c5f71abd1cc67b4b1bc0', - name: 'बुद्ध Élève', - date_of_birth: '', - phone: '+254777444333', - alternate_phone: '', - notes: '', - parent: { - _id: 'd978f02c-093b-4266-81cd-3983749f9c99' - }, - type: 'person', - reported_date: 1496068842996 -}; - -describe('contacts_by_freetext view', () => { - - it('indexes doc name', () => { - // given - const map = utils.loadView('medic-db', 'medic-client', 'contacts_by_freetext'); - - // when - const emitted = map(doc); - - // then - // Keys are arrays, so flatten the array of arrays for easier asserts. - const flattened = _.flattenDeep(emitted); - assert.include(flattened, 'test'); - assert.include(flattened, 'clinic'); - assert.include(flattened, 'contact'); +const { loadView, buildViewMapFn } = require('./utils'); +const medicOfflineFreetext = require('../../../../src/js/bootstrapper/offline-ddocs/medic-offline-freetext'); +const { expect } = require('chai'); + +const expectedValue = ( + {typeIndex, name, dead = false, muted = false } = {} +) => `${dead} ${muted} ${typeIndex} ${name}`; + +describe('contacts_by_freetext', () => { + [ + ['online view', loadView('medic-db', 'medic-client', 'contacts_by_freetext')], + ['offline view', buildViewMapFn(medicOfflineFreetext.views.contacts_by_freetext.map)], + ].forEach(([name, mapFn]) => { + describe(name, () => { + afterEach(() => mapFn.reset()); + [ + ['district_hospital', 0], + ['health_center', 1], + ['clinic', 2], + ['person', 3], + ].forEach(([type, typeIndex]) => it('emits numerical index for default type', () => { + const doc = { type, hello: 'world' }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex }); + expect(emitted).to.deep.equal([ + { key: ['world'], value }, + { key: ['hello:world'], value } + ]); + })); + + [ + ['contact', 0, 'district_hospital'], + ['contact', 1, 'health_center'], + ['contact', 2, 'clinic'], + ['contact', 3, 'person'] + ].forEach(([type, typeIndex, contactType]) => it( + 'emits numerical index for default type when used as custom type', + () => { + const doc = { type, hello: 'world', contact_type: contactType }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex }); + expect(emitted).to.deep.equal([ + { key: ['world'], value }, + { key: ['hello:world'], value }, + { key: [contactType], value }, + { key: [`contact_type:${contactType}`], value }, + ]); + } + )); + + it('emits contact_type index for custom type', () => { + const typeIndex = 'my_custom_type'; + const doc = { contact_type: typeIndex, type: 'contact', hello: 'world' }; + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex }); + expect(emitted).to.deep.equal([ + { key: [typeIndex], value }, + { key: [`contact_type:${typeIndex}`], value }, + { key: ['world'], value }, + { key: ['hello:world'], value }, + ]); + }); + + it('emits nothing when type is invalid', () => { + const doc = { type: 'invalid', hello: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + }); + + it('emits death status in value', () => { + const doc = { type: 'district_hospital', date_of_death: '2021-01-01' }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0, dead: true }); + expect(emitted).to.deep.equal([ + { key: ['2021-01-01'], value }, + { key: ['date_of_death:2021-01-01'], value } + ]); + }); + + it('emits muted status in value', () => { + const doc = { type: 'district_hospital', muted: true, hello: 'world' }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0, muted: true }); + expect(emitted).to.deep.equal([ + { key: ['world'], value }, + { key: ['hello:world'], value } + ]); + }); + + [ + 'hello', 'HeLlO' + ].forEach(name => it('emits name in value', () => { + const doc = { type: 'district_hospital', name }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0, name: name.toLowerCase() }); + expect(emitted).to.deep.equal([ + { key: [name.toLowerCase()], value }, + { key: [`name:${name.toLowerCase()}`], value } + ]); + })); + + [ + null, undefined, { hello: 'world' }, {}, true + ].forEach(hello => it('emits nothing when value is not a string or number', () => { + const doc = { type: 'district_hospital', hello }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); + + it('emits only key:value when value is number', () => { + const doc = { type: 'district_hospital', hello: 1234 }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([{ key: ['hello:1234'], value }]); + }); + + [ + 't', 'to' + ].forEach(hello => it('emits nothing but key:value when value is too short', () => { + const doc = { type: 'district_hospital', hello }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([{ key: [`hello:${hello}`], value }]); + })); + + it('emits nothing when value is empty', () => { + const doc = { type: 'district_hospital', hello: '' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + }); + + [ + '_id', '_rev', 'type', 'refid', 'geolocation' + ].forEach(key => it('emits nothing for a skipped field', () => { + const doc = { type: 'district_hospital', [key]: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); + + it('emits nothing for fields that end with "_date"', () => { + const doc = { type: 'district_hospital', reported_date: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + }); + + it('emits value only once', () => { + const doc = { + type: 'district_hospital', + hello: 'world world', + hello1: 'world', + hello3: 'world', + }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([ + { key: ['world'], value }, + { key: ['hello:world world'], value }, + { key: ['hello1:world'], value }, + { key: ['hello3:world'], value } + ]); + }); + + it('emits each word in a string', () => { + const doc = { + type: 'district_hospital', + hello: `the quick\nbrown\tfox`, + }; + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([ + { key: ['the'], value }, + { key: ['quick'], value }, + { key: ['brown'], value }, + { key: ['fox'], value }, + { key: ['hello:the quick\nbrown\tfox'], value }, + ]); + }); + }); }); - - it('indexes non-ascii doc name', () => { - // given - const map = utils.loadView('medic-db', 'medic-client', 'contacts_by_freetext'); - - // when - const emitted = map(nonAsciiDoc); - - // then - // Keys are arrays, so flatten the array of arrays for easier asserts. - const flattened = _.flattenDeep(emitted); - assert.include(flattened, 'बुद्ध'); - assert.include(flattened, 'élève'); - }); - - it('does not index words of less than 3 chars', () => { - // given - const map = utils.loadView('medic-db', 'medic-client', 'contacts_by_freetext'); - - // when - const emitted = map(doc); - - // then - // Keys are arrays, so flatten the array of arrays for easier asserts. - const flattened = _.flattenDeep(emitted); - assert.notInclude(flattened, 'of'); - }); - - it('does not index non-contact docs', () => { - // given - const map = utils.loadView('medic-db', 'medic-client', 'contacts_by_freetext'); - - // when - const emitted = map({ type: 'data_record', name: 'do not index me'}); - - // then - // Keys are arrays, so flatten the array of arrays for easier asserts. - assert.equal(emitted.length, 0); - }); - }); diff --git a/webapp/tsconfig.spec.json b/webapp/tsconfig.spec.json index 74acd4099e7..6301e1bcc3c 100755 --- a/webapp/tsconfig.spec.json +++ b/webapp/tsconfig.spec.json @@ -16,7 +16,6 @@ ], "include": [ "tests/karma/**/*.spec.ts", - "tests/karma/**/*.d.ts", - "tests/mocha/ts/**/*.spec.ts", + "tests/karma/**/*.d.ts" ] } From 24f486902deb184166ea501179253b8e2ea72526 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 22 Nov 2024 17:13:38 -0600 Subject: [PATCH 06/12] Update search e2e test to run for both online and offline users --- .../contacts/search-contacts.wdio-spec.js | 110 ++++++++++++------ .../js/bootstrapper/offline-ddocs/index.js | 8 +- .../unit/views/contacts_by_freetext.spec.js | 22 +++- 3 files changed, 95 insertions(+), 45 deletions(-) diff --git a/tests/e2e/default/contacts/search-contacts.wdio-spec.js b/tests/e2e/default/contacts/search-contacts.wdio-spec.js index 71c81193f63..c07b9aef773 100644 --- a/tests/e2e/default/contacts/search-contacts.wdio-spec.js +++ b/tests/e2e/default/contacts/search-contacts.wdio-spec.js @@ -5,63 +5,101 @@ const contactPage = require('@page-objects/default/contacts/contacts.wdio.page') const commonPage = require('@page-objects/default/common/common.wdio.page'); const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); +const userFactory = require('@factories/cht/users/users'); describe('Contact Search', () => { const places = placeFactory.generateHierarchy(); + const districtHospitalId = places.get('district_hospital')._id; - const sittuHospital = placeFactory.place().build({ - name: 'Sittu Hospital', - type: 'district_hospital', - parent: { _id: '', parent: { _id: '' } } + const sittuHealthCenter = placeFactory.place().build({ + name: 'Sittu Health Center', + type: 'health_center', + parent: { _id: districtHospitalId, parent: { _id: '' } } }); - const potuHospital = placeFactory.place().build({ - name: 'Potu Hospital', - type: 'district_hospital', - parent: { _id: '', parent: { _id: '' } } + const potuHealthCenter = placeFactory.place().build({ + name: 'Potu Health Center', + type: 'health_center', + parent: { _id: districtHospitalId, parent: { _id: '' } } }); const sittuPerson = personFactory.build({ name: 'Sittu', - parent: { _id: sittuHospital._id, parent: sittuHospital.parent } + parent: { _id: sittuHealthCenter._id, parent: sittuHealthCenter.parent } }); const potuPerson = personFactory.build({ name: 'Potu', - parent: { _id: sittuHospital._id, parent: sittuHospital.parent } + parent: { _id: sittuHealthCenter._id, parent: sittuHealthCenter.parent } }); - before(async () => { - await utils.saveDocs([...places.values(), sittuHospital, sittuPerson, potuHospital, potuPerson]); - await loginPage.cookieLogin(); - await commonPage.goToPeople(); + const supervisorPerson = personFactory.build({ + name: 'Supervisor', + parent: { _id: districtHospitalId } }); - it('search by NON empty string should display results with contains match and clears search', async () => { - await contactPage.getAllLHSContactsNames(); - - await searchPage.performSearch('sittu'); - expect(await contactPage.getAllLHSContactsNames()).to.have.members([ - sittuPerson.name, - sittuHospital.name, - ]); + const offlineUser = userFactory.build({ + username: 'offline-search-user', + place: districtHospitalId, + roles: ['chw_supervisor'], + contact: supervisorPerson._id + }); + const onlineUser = userFactory.build({ + username: 'online-search-user', + place: districtHospitalId, + roles: ['program_officer'], + contact: supervisorPerson._id + }); - await searchPage.clearSearch(); - expect(await contactPage.getAllLHSContactsNames()).to.have.members([ - potuHospital.name, - sittuHospital.name, - places.get('district_hospital').name, + before(async () => { + await utils.saveDocs([ + ...places.values(), sittuHealthCenter, sittuPerson, potuHealthCenter, potuPerson, supervisorPerson ]); + await utils.createUsers([offlineUser, onlineUser]); }); - it('search should clear RHS selected contact', async () => { - await contactPage.selectLHSRowByText(potuHospital.name, false); - await contactPage.waitForContactLoaded(); - expect(await (await contactPage.contactCardSelectors.contactCardName()).getText()).to.equal(potuHospital.name); + after(() => utils.deleteUsers([offlineUser, onlineUser])); - await searchPage.performSearch('sittu'); - await contactPage.waitForContactUnloaded(); - const url = await browser.getUrl(); - expect(url.endsWith('/contacts')).to.equal(true); - }); + [ + ['online', onlineUser], + ['offline', offlineUser], + ].forEach(([userType, user]) => describe(`Logged in as an ${userType} user`, () => { + before(async () => { + await loginPage.login(user); + await commonPage.goToPeople(); + }); + + after(commonPage.logout); + + it('search by NON empty string should display results with contains match and clears search', async () => { + await contactPage.getAllLHSContactsNames(); + + await searchPage.performSearch('sittu'); + expect(await contactPage.getAllLHSContactsNames()).to.have.members([ + sittuPerson.name, + sittuHealthCenter.name, + ]); + + await searchPage.clearSearch(); + expect(await contactPage.getAllLHSContactsNames()).to.have.members([ + potuHealthCenter.name, + sittuHealthCenter.name, + places.get('district_hospital').name, + places.get('health_center').name, + ]); + }); + + it('search should clear RHS selected contact', async () => { + await contactPage.selectLHSRowByText(potuHealthCenter.name, false); + await contactPage.waitForContactLoaded(); + expect( + await (await contactPage.contactCardSelectors.contactCardName()).getText() + ).to.equal(potuHealthCenter.name); + + await searchPage.performSearch('sittu'); + await contactPage.waitForContactUnloaded(); + const url = await browser.getUrl(); + expect(url.endsWith('/contacts')).to.equal(true); + }); + })); }); diff --git a/webapp/src/js/bootstrapper/offline-ddocs/index.js b/webapp/src/js/bootstrapper/offline-ddocs/index.js index e50b91c215d..b6cda237df4 100644 --- a/webapp/src/js/bootstrapper/offline-ddocs/index.js +++ b/webapp/src/js/bootstrapper/offline-ddocs/index.js @@ -1,4 +1,4 @@ -const medicOfflineFreetext = require('./medic-offline-freetext'); +const contactsByFreetext = require('./medic-offline-freetext'); const getRev = async (db, id) => db .get(id) @@ -15,8 +15,4 @@ const initDdoc = async (db, ddoc) => db.put({ _rev: await getRev(db, ddoc._id), }); -const init = async (db) => initDdoc(db, medicOfflineFreetext); - -module.exports = { - init -}; +module.exports.init = async (db) => initDdoc(db, contactsByFreetext); diff --git a/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js b/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js index bec8e66e834..0ad4646d9cf 100644 --- a/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js +++ b/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js @@ -66,11 +66,14 @@ describe('contacts_by_freetext', () => { ]); }); - it('emits nothing when type is invalid', () => { - const doc = { type: 'invalid', hello: 'world' }; + [ + undefined, + 'invalid' + ].forEach(type => it('emits nothing when type is invalid', () => { + const doc = { type, hello: 'world' }; const emitted = mapFn(doc, true); expect(emitted).to.be.empty; - }); + })); it('emits death status in value', () => { const doc = { type: 'district_hospital', date_of_death: '2021-01-01' }; @@ -193,6 +196,19 @@ describe('contacts_by_freetext', () => { { key: ['hello:the quick\nbrown\tfox'], value }, ]); }); + + it('emits non-ascii values', () => { + const doc = { type: 'district_hospital', name: 'बुद्ध Élève' }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0, name: 'बुद्ध élève' }); + expect(emitted).to.deep.equal([ + { key: ['बुद्ध'], value }, + { key: ['élève'], value }, + { key: ['name:बुद्ध élève'], value } + ]); + }); }); }); }); From 54981ccc4ae353d6d330afd9727262ed05dbe2bb Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 25 Nov 2024 14:33:19 -0600 Subject: [PATCH 07/12] Refactor bootstrapper to use proper async/await code --- webapp/src/js/bootstrapper/index.js | 97 +++++++++++++---------------- 1 file changed, 44 insertions(+), 53 deletions(-) diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index d19013fac29..87d374b1f0c 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -85,7 +85,7 @@ }; /* pouch db set up function */ - module.exports = (POUCHDB_OPTIONS) => { + module.exports = async (POUCHDB_OPTIONS) => { const dbInfo = getDbInfo(); const userCtx = getUserCtx(); @@ -109,59 +109,50 @@ const localMetaDb = window.PouchDB(getLocalMetaDbName(dbInfo, userCtx.name), POUCHDB_OPTIONS.local); - return Promise - .all([ - initialReplicationLib.isReplicationNeeded(localDb, userCtx), - swRegistration, - setReplicationId(POUCHDB_OPTIONS, localDb), - offlineDdocs.init(localDb) - ]) - .then(([isInitialReplicationNeeded]) => { - utils.setOptions(POUCHDB_OPTIONS); - - if (isInitialReplicationNeeded) { - const replicationStarted = performance.now(); - // Polling the document count from the db. - return initialReplicationLib - .replicate(remoteDb, localDb) - .then(() => initialReplicationLib.isReplicationNeeded(localDb, userCtx)) - .then(isReplicationStillNeeded => { - if (isReplicationStillNeeded) { - throw new Error('Initial replication failed'); - } - }) - .then(() => window.startupTimes.replication = performance.now() - replicationStarted); - } - }) - .then(() => { - const purgeMetaStarted = performance.now(); - return purger - .purgeMeta(localMetaDb) - .on('should-purge', shouldPurge => window.startupTimes.purgingMeta = shouldPurge) - .on('start', () => setUiStatus('PURGE_META')) - .on('done', () => window.startupTimes.purgeMeta = performance.now() - purgeMetaStarted) - .catch(err => { - console.error('Error attempting to purge meta db - continuing', err); - window.startupTimes.purgingMetaFailed = err.message; - }); - }) - .then(() => setUiStatus('STARTING_APP')) - .catch(err => err) - .then(err => { - localDb.close(); - remoteDb.close(); - localMetaDb.close(); - - if (err) { - const errorCode = err.status || err.code; - if (errorCode === 401) { - return redirectToLogin(dbInfo); - } - setUiError(err); - throw (err); + try { + const [isInitialReplicationNeeded] = await Promise + .all([ + initialReplicationLib.isReplicationNeeded(localDb, userCtx), + swRegistration, + setReplicationId(POUCHDB_OPTIONS, localDb), + offlineDdocs.init(localDb) + ]); + + utils.setOptions(POUCHDB_OPTIONS); + + if (isInitialReplicationNeeded) { + const replicationStarted = performance.now(); + // Polling the document count from the db. + await initialReplicationLib.replicate(remoteDb, localDb); + if (await initialReplicationLib.isReplicationNeeded(localDb, userCtx)) { + throw new Error('Initial replication failed'); } - }); + window.startupTimes.replication = performance.now() - replicationStarted; + } + const purgeMetaStarted = performance.now(); + await purger + .purgeMeta(localMetaDb) + .on('should-purge', shouldPurge => window.startupTimes.purgingMeta = shouldPurge) + .on('start', () => setUiStatus('PURGE_META')) + .on('done', () => window.startupTimes.purgeMeta = performance.now() - purgeMetaStarted) + .catch(err => { + console.error('Error attempting to purge meta db - continuing', err); + window.startupTimes.purgingMetaFailed = err.message; + }); + + setUiStatus('STARTING_APP'); + } catch (err) { + const errorCode = err.status || err.code; + if (errorCode === 401) { + return redirectToLogin(dbInfo); + } + setUiError(err); + throw (err); + } finally { + localDb.close(); + remoteDb.close(); + localMetaDb.close(); + } }; - }()); From 8c01ca1ab520fe880f65e0fdb8999ac1f87224fd Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Tue, 26 Nov 2024 15:01:00 -0600 Subject: [PATCH 08/12] Add offline contacts_by_type_freetext index --- .../search/src/generate-search-requests.js | 10 +- .../enketo/db-object-widget.wdio-spec.js | 144 ++++---- .../default/enketo/generic-form.wdio.page.js | 21 +- .../contacts_by_freetext.js | 2 +- .../contacts_by_type_freetext.js | 63 ++++ .../medic-offline-freetext/index.js | 2 + .../views/contacts_by_type_freetext.spec.js | 314 ++++++++++++------ 7 files changed, 381 insertions(+), 175 deletions(-) create mode 100644 webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_type_freetext.js diff --git a/shared-libs/search/src/generate-search-requests.js b/shared-libs/search/src/generate-search-requests.js index cb695528b90..7a48c2109fd 100644 --- a/shared-libs/search/src/generate-search-requests.js +++ b/shared-libs/search/src/generate-search-requests.js @@ -200,9 +200,9 @@ const makeCombinedParams = (freetextRequest, typeKey) => { return params; }; -const getContactsByTypeAndFreetextRequest = (typeRequests, freetextRequest) => { +const getContactsByTypeAndFreetextRequest = (typeRequests, freetextRequest, freetextDdocName) => { const result = { - view: 'medic-client/contacts_by_type_freetext', + view: `${freetextDdocName}/contacts_by_type_freetext`, union: typeRequests.params.keys.length > 1 }; @@ -217,9 +217,9 @@ const getContactsByTypeAndFreetextRequest = (typeRequests, freetextRequest) => { return result; }; -const getCombinedContactsRequests = (freetextRequests, contactsByParentRequest, typeRequest) => { +const getCombinedContactsRequests = (freetextRequests, contactsByParentRequest, typeRequest, freetextDdocName) => { const combinedRequests = freetextRequests.map(freetextRequest => { - return getContactsByTypeAndFreetextRequest(typeRequest, freetextRequest); + return getContactsByTypeAndFreetextRequest(typeRequest, freetextRequest, freetextDdocName); }); if (contactsByParentRequest) { combinedRequests.unshift(contactsByParentRequest); @@ -272,7 +272,7 @@ const requestBuilders = { } if (hasTypeRequest && freetextRequests?.length) { - return getCombinedContactsRequests(freetextRequests, contactsByParentRequest, typeRequest); + return getCombinedContactsRequests(freetextRequests, contactsByParentRequest, typeRequest, freetextDdocName); } const requests = _.compact(_.flatten([ freetextRequests, typeRequest, contactsByParentRequest ])); diff --git a/tests/e2e/default/enketo/db-object-widget.wdio-spec.js b/tests/e2e/default/enketo/db-object-widget.wdio-spec.js index 78614392c66..f2109a6eef8 100644 --- a/tests/e2e/default/enketo/db-object-widget.wdio-spec.js +++ b/tests/e2e/default/enketo/db-object-widget.wdio-spec.js @@ -18,81 +18,107 @@ describe('DB Object Widget', () => { parent: { _id: districtHospital._id } }); - const offlineUser = userFactory.build({ place: districtHospital._id, roles: [ 'chw' ] }); + const offlineUser = userFactory.build({ + username: 'offline-db-object-widget-user', + place: districtHospital._id, + roles: [ 'chw' ] + }); offlineUser.contact.sex = 'female'; - const personArea1 = personFactory.build({ parent: { _id: area1._id, parent: area1.parent } }); - const personArea2 = personFactory.build({ name: 'Patricio', parent: { _id: area2._id, parent: area2.parent } }); + offlineUser.contact.name = 'offline user pat'; + const onlineUser = userFactory.build({ + username: 'online-db-object-widget-user', + place: districtHospital._id, + roles: [ 'program_officer' ], + contact: offlineUser.contact._id + }); + const personArea1 = personFactory.build({ name: 'Patricio1', parent: { _id: area1._id, parent: area1.parent } }); + const personArea2 = personFactory.build({ name: 'Patricio2', parent: { _id: area2._id, parent: area2.parent } }); before(async () => { await utils.saveDocIfNotExists(commonPage.createFormDoc(`${__dirname}/forms/db-object-form`)); await utils.saveDocs([ ...places.values(), area2, personArea1, personArea2 ]); - await utils.createUsers([ offlineUser ]); - await loginPage.login(offlineUser); + await utils.createUsers([ offlineUser, onlineUser ]); }); - it('should load contacts in non-relevant inputs group and from calculations', async () => { - await commonPage.goToReports(); - await commonPage.openFastActionReport('db-object-form', false); + after(() => utils.deleteUsers([offlineUser, onlineUser])); + + [ + ['online', onlineUser], + ['offline', offlineUser], + ].forEach(([userType, user]) => describe(`Logged in as an ${userType} user`, () => { + before(async () => { + await loginPage.login(user); + }); - await genericForm.submitForm(); + after(commonPage.logout); - const reportId = await reportsPage.getCurrentReportId(); - await commonPage.sync(); - const { fields } = await utils.getDoc(reportId); - expect(fields).excluding(['meta']).to.deep.equal({ - inputs: { - meta: { location: { lat: '', long: '', error: '', message: '' } }, - user: { - contact_id: offlineUser.contact._id, - name: offlineUser.contact.name, - sex: offlineUser.contact.sex - }, - user_contact: { - _id: offlineUser.contact._id, - name: offlineUser.contact.name, - sex: offlineUser.contact.sex + it('should load contacts in non-relevant inputs group and from calculations', async () => { + await commonPage.goToReports(); + await commonPage.openFastActionReport('db-object-form', false); + + await genericForm.submitForm(); + + const reportId = await reportsPage.getCurrentReportId(); + if (userType === 'offline') { + await commonPage.sync(); + } + const { fields } = await utils.getDoc(reportId); + expect(fields).excluding(['meta']).to.deep.equal({ + inputs: { + meta: { location: { lat: '', long: '', error: '', message: '' } }, + user: { + contact_id: offlineUser.contact._id, + name: offlineUser.contact.name, + sex: offlineUser.contact.sex + }, + user_contact: { + _id: offlineUser.contact._id, + name: offlineUser.contact.name, + sex: offlineUser.contact.sex + }, }, - }, - people: { - user_contact: { - _id: offlineUser.contact._id, - name: offlineUser.contact.name, - sex: offlineUser.contact.sex + people: { + user_contact: { + _id: offlineUser.contact._id, + name: offlineUser.contact.name, + sex: offlineUser.contact.sex + }, + person_test_same_parent: '', + person_test_all: '' }, - person_test_same_parent: '', - person_test_all: '' - }, + }); }); - }); - it('should display only the contacts from the parent contact', async () => { - await commonPage.goToPeople(area1._id); - await commonPage.openFastActionReport('db-object-form'); + it('should display only the contacts from the parent contact', async () => { + await commonPage.goToPeople(area1._id); + await commonPage.openFastActionReport('db-object-form'); - const sameParent = await genericForm.getDBObjectWidgetValues('/db-object-form/people/person_test_same_parent'); - await sameParent[0].click(); - expect(sameParent.length).to.equal(1); - expect(sameParent[0].name).to.equal(personArea1.name); + await genericForm.searchContact('Select a person from the same parent', 'pat'); + const sameParent = await genericForm.getDBObjectWidgetValues(); + await sameParent[0].click(); + expect(sameParent.length).to.equal(1); + expect(sameParent[0].name).to.equal(personArea1.name); - const allContacts = await genericForm.getDBObjectWidgetValues('/db-object-form/people/person_test_all'); - await allContacts[2].click(); - expect(allContacts.length).to.equal(3); - expect(allContacts[0].name).to.equal(personArea1.name); - expect(allContacts[1].name).to.equal(offlineUser.contact.name); - expect(allContacts[2].name).to.equal(personArea2.name); + await genericForm.searchContact('Select a person from all', 'pat'); + const allContacts = await genericForm.getDBObjectWidgetValues(); + await allContacts[1].click(); + expect(allContacts.length).to.equal(3); + expect(allContacts[0].name).to.equal(personArea1.name); + expect(allContacts[1].name).to.equal(personArea2.name); + expect(allContacts[2].name).to.equal(offlineUser.contact.name); - await genericForm.submitForm(); - await commonPage.goToReports(); + await genericForm.submitForm(); + await commonPage.goToReports(); - const firstReport = await reportsPage.getListReportInfo(await reportsPage.leftPanelSelectors.firstReport()); - expect(firstReport.heading).to.equal(offlineUser.contact.name); - expect(firstReport.form).to.equal('db-object-form'); - - await reportsPage.openReport(firstReport.dataId); - expect((await reportsPage.getDetailReportRowContent('report.db-object-form.people.person_test_same_parent')) - .rowValues[0]).to.equal(personArea1._id); - expect((await reportsPage.getDetailReportRowContent('report.db-object-form.people.person_test_all')) - .rowValues[0]).to.equal(personArea2._id); - }); + const firstReport = await reportsPage.getListReportInfo(await reportsPage.leftPanelSelectors.firstReport()); + expect(firstReport.heading).to.equal(offlineUser.contact.name); + expect(firstReport.form).to.equal('db-object-form'); + await reportsPage.openReport(firstReport.dataId); + expect((await reportsPage.getDetailReportRowContent('report.db-object-form.people.person_test_same_parent')) + .rowValues[0]).to.equal(personArea1._id); + expect((await reportsPage.getDetailReportRowContent('report.db-object-form.people.person_test_all')) + .rowValues[0]).to.equal(personArea2._id); + }); + })); }); diff --git a/tests/page-objects/default/enketo/generic-form.wdio.page.js b/tests/page-objects/default/enketo/generic-form.wdio.page.js index 7eed056b39f..32e18f4d5ef 100644 --- a/tests/page-objects/default/enketo/generic-form.wdio.page.js +++ b/tests/page-objects/default/enketo/generic-form.wdio.page.js @@ -30,14 +30,18 @@ const nextPage = async (numberOfPages = 1, waitForLoad = true) => { } }; -const selectContact = async (contactName, label, searchTerm = '') => { +const searchContact = async (label, searchTerm) => { const searchField = await $('.select2-search__field'); if (!await searchField.isDisplayed()) { await (await select2Selection(label)).click(); } - await searchField.setValue(searchTerm || contactName); + await searchField.setValue(searchTerm); await $('.select2-results__option.loading-results').waitForDisplayed({ reverse: true }); +}; + +const selectContact = async (contactName, label, searchTerm = '') => { + await searchContact(label, searchTerm || contactName); const contact = await $(`.name*=${contactName}`); await contact.waitForDisplayed(); await contact.click(); @@ -78,11 +82,7 @@ const getFormTitle = async () => { return await (await formTitle()).getText(); }; -const getDBObjectWidgetValues = async (field) => { - const widget = $(`[data-contains-ref-target="${field}"] .selection`); - await (await widget).waitForClickable(); - await (await widget).click(); - +const getDBObjectWidgetValues = async () => { const dropdown = $('.select2-dropdown--below'); await (await dropdown).waitForDisplayed(); const firstElement = $('.select2-results__options > li'); @@ -91,8 +91,12 @@ const getDBObjectWidgetValues = async (field) => { const list = await $$('.select2-results__options > li'); const contacts = []; for (const item of list) { + const itemName = item.$('.name'); + if (!(await itemName.isExisting())) { + continue; + } contacts.push({ - name: await (item.$('.name').getText()), + name: await itemName.getText(), click: () => item.click(), }); } @@ -109,6 +113,7 @@ module.exports = { nextPage, nameField, fieldByName, + searchContact, selectContact, clearSelectedContact, cancelForm, diff --git a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js index 3af7c0ea508..3248d2f8dcb 100644 --- a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_freetext.js @@ -1,4 +1,4 @@ -module.exports.map = function(doc) { +module.exports.map = (doc) => { const skip = [ '_id', '_rev', 'type', 'refid', 'geolocation' ]; const usedKeys = []; diff --git a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_type_freetext.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_type_freetext.js new file mode 100644 index 00000000000..f11959c0752 --- /dev/null +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_type_freetext.js @@ -0,0 +1,63 @@ +module.exports.map = (doc) => { + const skip = [ '_id', '_rev', 'type', 'refid', 'geolocation' ]; + + const usedKeys = []; + const emitMaybe = function(type, key, value) { + if (usedKeys.indexOf(key) === -1 && // Not already used + key.length > 2 // Not too short + ) { + usedKeys.push(key); + emit([ type, key ], value); + } + }; + + const emitField = (type, key, value, order) => { + if (!key || !value) { + return; + } + const lowerKey = key.toLowerCase(); + if (skip.indexOf(lowerKey) !== -1 || /_date$/.test(lowerKey)) { + return; + } + if (typeof value === 'string') { + const lowerValue = value.toLowerCase(); + lowerValue + .split(/\s+/) + .forEach(word => emitMaybe(type, word, order)); + emitMaybe(type, `${lowerKey}:${lowerValue}`, order); + } else if (typeof value === 'number') { + emitMaybe(type, `${lowerKey}:${value}`, order); + } + }; + + const getType = () => { + if (doc.type !== 'contact') { + return doc.type; + } + + return doc.contact_type; + }; + + const getTypeIndex = type => { + const types = [ 'district_hospital', 'health_center', 'clinic', 'person' ]; + const typeIndex = types.indexOf(type); + if (typeIndex === -1 && doc.type === 'contact') { + return type; + } + + return typeIndex; + }; + + const type = getType(); + const idx = getTypeIndex(type); + if (idx === -1) { + return; + } + + const dead = !!doc.date_of_death; + const muted = !!doc.muted; + const order = `${dead} ${muted} ${idx} ${doc.name && doc.name.toLowerCase()}`; + Object + .keys(doc) + .forEach(key => emitField(type, key, doc[key], order)); +}; diff --git a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js index 5be830f37a2..36d98b79845 100644 --- a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js @@ -1,4 +1,5 @@ const contactByFreetext = require('./contacts_by_freetext'); +const contactsByTypeFreetext = require('./contacts_by_type_freetext'); const packageView = ({ map }) => ({ map: map.toString() }); @@ -6,5 +7,6 @@ module.exports = { _id: '_design/medic-offline-freetext', views: { contacts_by_freetext: packageView(contactByFreetext), + contacts_by_type_freetext: packageView(contactsByTypeFreetext), } }; diff --git a/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js b/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js index 4384080ba94..ce04c62200f 100644 --- a/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js +++ b/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js @@ -1,104 +1,214 @@ -const assert = require('chai').assert; -const utils = require('./utils'); - -const doc = { - _id: '3c0c4575468bc9b7ce066a279b022e8e', - _rev: '2-5fb6ead9b03232a4cf1e0171c5434469', - name: 'Test Contact of Clinic', - date_of_birth: '', - phone: '+13125551212', - alternate_phone: '', - notes: '', - type: 'person', - reported_date: 1491910934051, - transitions: { - maintain_info_document: { - last_rev: 2, - seq: '241-g1AAAACbeJzLYWBgYMpgTmEQTM4vTc5ISXLIyU9OzMnILy7JAUklMiTV____', - ok: true - } - } -}; - -const nonAsciiDoc = { - _id: '3e32235b-7111-4a69-a0a1-b3094f257891', - _rev: '1-e19cb2355b26c5f71abd1cc67b4b1bc0', - name: 'बुद्ध Élève', - date_of_birth: '', - phone: '+254777444333', - alternate_phone: '', - notes: '', - parent: { - _id: 'd978f02c-093b-4266-81cd-3983749f9c99' - }, - type: 'person', - reported_date: 1496068842996 -}; - -const configurableHierarchyDoc = { - _id: '3e32235b-7111-4a69-a0a1-b3094f257892', - _rev: '1-e19cb2355b26c5f71abd1cc67b4b1bc0', - name: 'jessie', - date_of_birth: '', - phone: '+254777444333', - alternate_phone: '', - notes: '', - parent: { - _id: 'd978f02c-093b-4266-81cd-3983749f9c99' - }, - type: 'contact', - contact_type: 'chp', - reported_date: 1496068842996 -}; - -let map; - -describe('contacts_by_type_freetext view', () => { - - beforeEach(() => map = utils.loadView('medic-db', 'medic-client', 'contacts_by_type_freetext')); - - it('indexes doc name and type', () => { - // when - const emitted = map(doc); - - // then - utils.assertIncludesPair(emitted, ['person', 'test']); - utils.assertIncludesPair(emitted, ['person', 'clinic']); - utils.assertIncludesPair(emitted, ['person', 'contact']); +const { loadView, buildViewMapFn } = require('./utils'); +const medicOfflineFreetext = require('../../../../src/js/bootstrapper/offline-ddocs/medic-offline-freetext'); +const { expect } = require('chai'); + +const expectedValue = ( + {typeIndex, name, dead = false, muted = false } = {} +) => `${dead} ${muted} ${typeIndex} ${name}`; + +describe('contacts_by_type_freetext', () => { + [ + ['online view', loadView('medic-db', 'medic-client', 'contacts_by_type_freetext')], + ['offline view', buildViewMapFn(medicOfflineFreetext.views.contacts_by_type_freetext.map)], + ].forEach(([name, mapFn]) => { + describe(name, () => { + afterEach(() => mapFn.reset()); + [ + ['district_hospital', 0], + ['health_center', 1], + ['clinic', 2], + ['person', 3], + ].forEach(([type, typeIndex]) => it('emits numerical index for default type', () => { + const doc = { type, hello: 'world' }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex }); + expect(emitted).to.deep.equal([ + { key: [type, 'world'], value }, + { key: [type, 'hello:world'], value } + ]); + })); + + [ + ['contact', 0, 'district_hospital'], + ['contact', 1, 'health_center'], + ['contact', 2, 'clinic'], + ['contact', 3, 'person'] + ].forEach(([type, typeIndex, contactType]) => it( + 'emits numerical index for default type when used as custom type', + () => { + const doc = { type, hello: 'world', contact_type: contactType }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex }); + expect(emitted).to.deep.equal([ + { key: [contactType, 'world'], value }, + { key: [contactType, 'hello:world'], value }, + { key: [contactType, contactType], value }, + { key: [contactType, `contact_type:${contactType}`], value }, + ]); + } + )); + + it('emits contact_type index for custom type', () => { + const typeIndex = 'my_custom_type'; + const doc = { contact_type: typeIndex, type: 'contact', hello: 'world' }; + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex }); + expect(emitted).to.deep.equal([ + { key: [typeIndex, typeIndex], value }, + { key: [typeIndex, `contact_type:${typeIndex}`], value }, + { key: [typeIndex, 'world'], value }, + { key: [typeIndex, 'hello:world'], value }, + ]); + }); + + [ + undefined, + 'invalid' + ].forEach(type => it('emits nothing when type is invalid', () => { + const doc = { type, hello: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); + + it('emits death status in value', () => { + const doc = { type: 'district_hospital', date_of_death: '2021-01-01' }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0, dead: true }); + expect(emitted).to.deep.equal([ + { key: ['district_hospital', '2021-01-01'], value }, + { key: ['district_hospital', 'date_of_death:2021-01-01'], value } + ]); + }); + + it('emits muted status in value', () => { + const doc = { type: 'district_hospital', muted: true, hello: 'world' }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0, muted: true }); + expect(emitted).to.deep.equal([ + { key: ['district_hospital', 'world'], value }, + { key: ['district_hospital', 'hello:world'], value } + ]); + }); + + [ + 'hello', 'HeLlO' + ].forEach(name => it('emits name in value', () => { + const doc = { type: 'district_hospital', name }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0, name: name.toLowerCase() }); + expect(emitted).to.deep.equal([ + { key: ['district_hospital', name.toLowerCase()], value }, + { key: ['district_hospital', `name:${name.toLowerCase()}`], value } + ]); + })); + + [ + null, undefined, { hello: 'world' }, {}, true + ].forEach(hello => it('emits nothing when value is not a string or number', () => { + const doc = { type: 'district_hospital', hello }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); + + it('emits only key:value when value is number', () => { + const doc = { type: 'district_hospital', hello: 1234 }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([{ key: ['district_hospital', 'hello:1234'], value }]); + }); + + [ + 't', 'to' + ].forEach(hello => it('emits nothing but key:value when value is too short', () => { + const doc = { type: 'district_hospital', hello }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([{ key: ['district_hospital', `hello:${hello}`], value }]); + })); + + it('emits nothing when value is empty', () => { + const doc = { type: 'district_hospital', hello: '' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + }); + + [ + '_id', '_rev', 'type', 'refid', 'geolocation' + ].forEach(key => it('emits nothing for a skipped field', () => { + const doc = { type: 'district_hospital', [key]: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); + + it('emits nothing for fields that end with "_date"', () => { + const doc = { type: 'district_hospital', reported_date: 'world' }; + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + }); + + it('emits value only once', () => { + const doc = { + type: 'district_hospital', + hello: 'world world', + hello1: 'world', + hello3: 'world', + }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([ + { key: ['district_hospital', 'world'], value }, + { key: ['district_hospital', 'hello:world world'], value }, + { key: ['district_hospital', 'hello1:world'], value }, + { key: ['district_hospital', 'hello3:world'], value } + ]); + }); + + it('emits each word in a string', () => { + const doc = { + type: 'district_hospital', + hello: `the quick\nbrown\tfox`, + }; + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0 }); + expect(emitted).to.deep.equal([ + { key: ['district_hospital', 'the'], value }, + { key: ['district_hospital', 'quick'], value }, + { key: ['district_hospital', 'brown'], value }, + { key: ['district_hospital', 'fox'], value }, + { key: ['district_hospital', 'hello:the quick\nbrown\tfox'], value }, + ]); + }); + + it('emits non-ascii values', () => { + const doc = { type: 'district_hospital', name: 'बुद्ध Élève' }; + + const emitted = mapFn(doc, true); + + const value = expectedValue({ typeIndex: 0, name: 'बुद्ध élève' }); + expect(emitted).to.deep.equal([ + { key: ['district_hospital', 'बुद्ध'], value }, + { key: ['district_hospital', 'élève'], value }, + { key: ['district_hospital', 'name:बुद्ध élève'], value } + ]); + }); + }); }); - - it('indexes non-ascii doc name', () => { - // when - const emitted = map(nonAsciiDoc); - - // then - utils.assertIncludesPair(emitted, ['person', 'बुद्ध']); - utils.assertIncludesPair(emitted, ['person', 'élève']); - }); - - it('does not index words of less than 3 chars', () => { - // when - const emitted = map(doc); - - // then - utils.assertDoesNotIncludePair(emitted, ['person', 'of']); - }); - - it('does not index non-contact docs', () => { - // when - const emitted = map({ type: 'data_record', name: 'do not index me'}); - - // then - // Keys are arrays, so flatten the array of arrays for easier asserts. - assert.equal(emitted.length, 0); - }); - - it('returns configurable type', () => { - // when - const emitted = map(configurableHierarchyDoc); - - // then - utils.assertIncludesPair(emitted, ['chp', 'jessie']); - }); - }); From 6ee10208ff70114671376872ad2e535a0567efb6 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Wed, 27 Nov 2024 09:40:59 -0600 Subject: [PATCH 09/12] Fix SONAR issues --- webapp/src/js/bootstrapper/index.js | 34 ++++++++++++------- .../contacts_by_type_freetext.js | 3 +- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index 87d374b1f0c..dde8331f2a0 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -68,6 +68,15 @@ window.location.href = '/' + dbInfo.name + '/login?redirect=' + currentUrl; }; + const handleBootstrappingError = (err, dbInfo) => { + const errorCode = err.status || err.code; + if (errorCode === 401) { + return redirectToLogin(dbInfo); + } + setUiError(err); + throw (err); + }; + // TODO Use a shared library for this duplicated code #4021 const hasRole = function(userCtx, role) { if (userCtx.roles) { @@ -84,6 +93,16 @@ return hasRole(userCtx, '_admin') || hasRole(userCtx, ONLINE_ROLE); }; + const doInitialReplication = async (remoteDb, localDb, userCtx) => { + const replicationStarted = performance.now(); + // Polling the document count from the db. + await initialReplicationLib.replicate(remoteDb, localDb); + if (await initialReplicationLib.isReplicationNeeded(localDb, userCtx)) { + throw new Error('Initial replication failed'); + } + window.startupTimes.replication = performance.now() - replicationStarted; + }; + /* pouch db set up function */ module.exports = async (POUCHDB_OPTIONS) => { @@ -121,13 +140,7 @@ utils.setOptions(POUCHDB_OPTIONS); if (isInitialReplicationNeeded) { - const replicationStarted = performance.now(); - // Polling the document count from the db. - await initialReplicationLib.replicate(remoteDb, localDb); - if (await initialReplicationLib.isReplicationNeeded(localDb, userCtx)) { - throw new Error('Initial replication failed'); - } - window.startupTimes.replication = performance.now() - replicationStarted; + await doInitialReplication(remoteDb, localDb, userCtx); } const purgeMetaStarted = performance.now(); @@ -143,12 +156,7 @@ setUiStatus('STARTING_APP'); } catch (err) { - const errorCode = err.status || err.code; - if (errorCode === 401) { - return redirectToLogin(dbInfo); - } - setUiError(err); - throw (err); + return handleBootstrappingError(err, dbInfo); } finally { localDb.close(); remoteDb.close(); diff --git a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_type_freetext.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_type_freetext.js index f11959c0752..689a5669701 100644 --- a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_type_freetext.js +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/contacts_by_type_freetext.js @@ -1,5 +1,6 @@ module.exports.map = (doc) => { const skip = [ '_id', '_rev', 'type', 'refid', 'geolocation' ]; + const keyShouldBeSkipped = key => skip.indexOf(key) !== -1 || /_date$/.test(key); const usedKeys = []; const emitMaybe = function(type, key, value) { @@ -16,7 +17,7 @@ module.exports.map = (doc) => { return; } const lowerKey = key.toLowerCase(); - if (skip.indexOf(lowerKey) !== -1 || /_date$/.test(lowerKey)) { + if (keyShouldBeSkipped(lowerKey)) { return; } if (typeof value === 'string') { From cb6abd92233ba0221cf31d29133ace34da7c6a9e Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Wed, 27 Nov 2024 13:54:15 -0600 Subject: [PATCH 10/12] Add offline index for reports_by_freetext --- .../search/src/generate-search-requests.js | 4 +- .../reports/search-reports.wdio-spec.js | 99 ++++-- .../medic-offline-freetext/index.js | 2 + .../reports_by_freetext.js | 49 +++ .../unit/views/contacts_by_freetext.spec.js | 4 +- .../views/contacts_by_type_freetext.spec.js | 4 +- .../unit/views/reports_by_freetext.spec.js | 323 +++++++++--------- 7 files changed, 285 insertions(+), 200 deletions(-) create mode 100644 webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/reports_by_freetext.js diff --git a/shared-libs/search/src/generate-search-requests.js b/shared-libs/search/src/generate-search-requests.js index 7a48c2109fd..11c4fd6abed 100644 --- a/shared-libs/search/src/generate-search-requests.js +++ b/shared-libs/search/src/generate-search-requests.js @@ -241,14 +241,14 @@ const setDefaultContactsRequests = (requests, shouldSortByLastVisitedDate) => { }; const requestBuilders = { - reports: (filters) => { + reports: (filters, freetextDdocName) => { let requests = [ reportedDateRequest(filters), formRequest(filters), validityRequest(filters), verificationRequest(filters), placeRequest(filters), - freetextRequest(filters, 'medic-client/reports_by_freetext'), + freetextRequest(filters, `${freetextDdocName}/reports_by_freetext`), subjectRequest(filters) ]; diff --git a/tests/e2e/default/reports/search-reports.wdio-spec.js b/tests/e2e/default/reports/search-reports.wdio-spec.js index 4b4327bb807..d6b1b8ed2a6 100644 --- a/tests/e2e/default/reports/search-reports.wdio-spec.js +++ b/tests/e2e/default/reports/search-reports.wdio-spec.js @@ -7,11 +7,15 @@ const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); const pregnancyFactory = require('@factories/cht/reports/pregnancy'); const smsPregnancyFactory = require('@factories/cht/reports/sms-pregnancy'); +const userFactory = require('@factories/cht/users/users'); describe('Reports Search', () => { - let reportDocs; const sittuHospital = placeFactory.place().build({ name: 'Sittu Hospital', type: 'district_hospital' }); - const potuHospital = placeFactory.place().build({ name: 'Potu Hospital', type: 'district_hospital' }); + const potuHealthCenter = placeFactory.place().build({ + name: 'Potu Health Center', + type: 'health_center', + parent: { _id: sittuHospital._id } + }); const sittuPerson = personFactory.build({ name: 'Sittu', @@ -21,7 +25,7 @@ describe('Reports Search', () => { const potuPerson = personFactory.build({ name: 'Potu', patient_id: 'potu-patient', - parent: { _id: potuHospital._id, parent: potuHospital.parent }, + parent: { _id: potuHealthCenter._id, parent: potuHealthCenter.parent }, }); const reports = [ @@ -31,44 +35,67 @@ describe('Reports Search', () => { pregnancyFactory.build({ fields: { patient_id: potuPerson.patient_id, case_id: 'case-12' } }), ]; + const offlineUser = userFactory.build({ + username: 'offline-search-user', + place: sittuHospital._id, + roles: ['chw_supervisor'], + contact: sittuPerson._id + }); + const onlineUser = userFactory.build({ + username: 'online-search-user', + place: sittuHospital._id, + roles: ['program_officer'], + contact: sittuPerson._id + }); + before(async () => { - await utils.saveDocs([ sittuHospital, sittuPerson, potuHospital, potuPerson ]); - reportDocs = await utils.saveDocs(reports); - await loginPage.cookieLogin(); + await utils.saveDocs([ sittuHospital, sittuPerson, potuHealthCenter, potuPerson ]); + await utils.saveDocs(reports); + await utils.createUsers([offlineUser, onlineUser]); }); - it('should return results matching the search term and then return all data when clearing search', async () => { - const [ sittuSMSPregnancy, potuSMSPregnancy, sittuPregnancy, potuPregnancy ] = reportDocs; - await commonPage.goToReports(); - // Asserting first load reports - expect((await reportsPage.reportsListDetails()).length).to.equal(reportDocs.length); + after(() => utils.deleteUsers([offlineUser, onlineUser])); - await searchPage.performSearch('sittu'); - await commonPage.waitForLoaders(); - expect((await reportsPage.reportsListDetails()).length).to.equal(2); - expect(await (await reportsPage.leftPanelSelectors.reportByUUID(sittuSMSPregnancy.id)).isDisplayed()).to.be.true; - expect(await (await reportsPage.leftPanelSelectors.reportByUUID(sittuPregnancy.id)).isDisplayed()).to.be.true; + [ + ['online', onlineUser, [reports[0], reports[2]], reports], + ['offline', offlineUser, [reports[2]], [reports[2], reports[3]]], + ].forEach(([userType, user, filteredReports, allReports]) => describe(`Logged in as an ${userType} user`, () => { + before(() => loginPage.login(user)); - await searchPage.clearSearch(); - expect((await reportsPage.reportsListDetails()).length).to.equal(reportDocs.length); - expect(await (await reportsPage.leftPanelSelectors.reportByUUID(sittuSMSPregnancy.id)).isDisplayed()).to.be.true; - expect(await (await reportsPage.leftPanelSelectors.reportByUUID(potuSMSPregnancy.id)).isDisplayed()).to.be.true; - expect(await (await reportsPage.leftPanelSelectors.reportByUUID(sittuPregnancy.id)).isDisplayed()).to.be.true; - expect(await (await reportsPage.leftPanelSelectors.reportByUUID(potuPregnancy.id)).isDisplayed()).to.be.true; - }); + after(commonPage.logout); - it('should return results when searching by case_id', async () => { - const sittuPregnancy = reportDocs[2]; - const potuPregnancy = reportDocs[3]; - await commonPage.goToReports(); - // Asserting first load reports - expect((await reportsPage.reportsListDetails()).length).to.equal(reportDocs.length); + it('should return results matching the search term and then return all data when clearing search', async () => { + await commonPage.goToReports(); + // Asserting first load reports + expect((await reportsPage.reportsListDetails()).length).to.equal(allReports.length); - await reportsPage.openReport(sittuPregnancy.id); - await reportsPage.clickOnCaseId(); - await commonPage.waitForLoaders(); - expect((await reportsPage.reportsListDetails()).length).to.equal(2); - expect(await (await reportsPage.leftPanelSelectors.reportByUUID(sittuPregnancy.id)).isDisplayed()).to.be.true; - expect(await (await reportsPage.leftPanelSelectors.reportByUUID(potuPregnancy.id)).isDisplayed()).to.be.true; - }); + await searchPage.performSearch('sittu'); + await commonPage.waitForLoaders(); + expect((await reportsPage.reportsListDetails()).length).to.equal(filteredReports.length); + for (const report of filteredReports) { + expect(await (await reportsPage.leftPanelSelectors.reportByUUID(report._id)).isDisplayed()).to.be.true; + } + + await searchPage.clearSearch(); + expect((await reportsPage.reportsListDetails()).length).to.equal(allReports.length); + for (const report of allReports) { + expect(await (await reportsPage.leftPanelSelectors.reportByUUID(report._id)).isDisplayed()).to.be.true; + } + }); + + it('should return results when searching by case_id', async () => { + const sittuPregnancy = reports[2]; + const potuPregnancy = reports[3]; + await commonPage.goToReports(); + // Asserting first load reports + expect((await reportsPage.reportsListDetails()).length).to.equal(allReports.length); + + await reportsPage.openReport(sittuPregnancy._id); + await reportsPage.clickOnCaseId(); + await commonPage.waitForLoaders(); + expect((await reportsPage.reportsListDetails()).length).to.equal(2); + expect(await (await reportsPage.leftPanelSelectors.reportByUUID(sittuPregnancy._id)).isDisplayed()).to.be.true; + expect(await (await reportsPage.leftPanelSelectors.reportByUUID(potuPregnancy._id)).isDisplayed()).to.be.true; + }); + })); }); diff --git a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js index 36d98b79845..67bc037eb73 100644 --- a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/index.js @@ -1,5 +1,6 @@ const contactByFreetext = require('./contacts_by_freetext'); const contactsByTypeFreetext = require('./contacts_by_type_freetext'); +const reportsByFreetext = require('./reports_by_freetext'); const packageView = ({ map }) => ({ map: map.toString() }); @@ -8,5 +9,6 @@ module.exports = { views: { contacts_by_freetext: packageView(contactByFreetext), contacts_by_type_freetext: packageView(contactsByTypeFreetext), + reports_by_freetext: packageView(reportsByFreetext), } }; diff --git a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/reports_by_freetext.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/reports_by_freetext.js new file mode 100644 index 00000000000..d3656176c75 --- /dev/null +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-freetext/reports_by_freetext.js @@ -0,0 +1,49 @@ +module.exports.map = (doc) => { + const skip = [ '_id', '_rev', 'type', 'refid', 'content' ]; + const keyShouldBeSkipped = key => skip.indexOf(key) !== -1 || /_date$/.test(key); + + const usedKeys = []; + const emitMaybe = (key, value) => { + if (usedKeys.indexOf(key) === -1 && // Not already used + key.length > 2 // Not too short + ) { + usedKeys.push(key); + emit([key], value); + } + }; + + const emitField = (key, value, reportedDate) => { + if (!key || !value) { + return; + } + const lowerKey = key.toLowerCase(); + if (keyShouldBeSkipped(lowerKey)) { + return; + } + if (typeof value === 'string') { + const lowerValue = value.toLowerCase(); + lowerValue + .split(/\s+/) + .forEach((word) => emitMaybe(word, reportedDate)); + emitMaybe(`${lowerKey}:${lowerValue}`, reportedDate); + } else if (typeof value === 'number') { + emitMaybe(`${lowerKey}:${value}`, reportedDate); + } + }; + + if (doc.type !== 'data_record' || !doc.form) { + return; + } + + Object + .keys(doc) + .forEach((key) => emitField(key, doc[key], doc.reported_date)); + if (doc.fields) { + Object + .keys(doc.fields) + .forEach((key) => emitField(key, doc.fields[key], doc.reported_date)); + } + if (doc.contact && doc.contact._id) { + emitMaybe(`contact:${doc.contact._id.toLowerCase()}`, doc.reported_date); + } +}; diff --git a/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js b/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js index 0ad4646d9cf..2e057ff488d 100644 --- a/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js +++ b/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js @@ -148,7 +148,7 @@ describe('contacts_by_freetext', () => { }); [ - '_id', '_rev', 'type', 'refid', 'geolocation' + '_id', '_rev', 'type', 'refid', 'geolocation', 'Refid' ].forEach(key => it('emits nothing for a skipped field', () => { const doc = { type: 'district_hospital', [key]: 'world' }; const emitted = mapFn(doc, true); @@ -183,7 +183,7 @@ describe('contacts_by_freetext', () => { it('emits each word in a string', () => { const doc = { type: 'district_hospital', - hello: `the quick\nbrown\tfox`, + hello: `the quick\nBrown\tfox`, }; const emitted = mapFn(doc, true); diff --git a/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js b/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js index ce04c62200f..42b0694364d 100644 --- a/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js +++ b/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js @@ -148,7 +148,7 @@ describe('contacts_by_type_freetext', () => { }); [ - '_id', '_rev', 'type', 'refid', 'geolocation' + '_id', '_rev', 'type', 'refid', 'geolocation', 'Refid' ].forEach(key => it('emits nothing for a skipped field', () => { const doc = { type: 'district_hospital', [key]: 'world' }; const emitted = mapFn(doc, true); @@ -183,7 +183,7 @@ describe('contacts_by_type_freetext', () => { it('emits each word in a string', () => { const doc = { type: 'district_hospital', - hello: `the quick\nbrown\tfox`, + hello: `the quick\nBrown\tfox`, }; const emitted = mapFn(doc, true); diff --git a/webapp/tests/mocha/unit/views/reports_by_freetext.spec.js b/webapp/tests/mocha/unit/views/reports_by_freetext.spec.js index 49cdaa1b0fc..72dc5a9cf84 100644 --- a/webapp/tests/mocha/unit/views/reports_by_freetext.spec.js +++ b/webapp/tests/mocha/unit/views/reports_by_freetext.spec.js @@ -1,174 +1,181 @@ -const _ = require('lodash'); -const assert = require('chai').assert; -const utils = require('./utils'); - -const doc = { - _id: '7383B568-4A6C-2C97-B463-3CC2630A562E', - _rev: '1-ddec60a626c8f5b17b0f5fcdc2031c39', - content: '', - 'fields': { - 'inputs': { - 'source': 'task', - source_id: '82CFA683-D6F5-3427-95C7-45D792EA5A08', - t_lmp_date: '2015-09-23T07:00:00.000Z', - contact: { - _id: 'd57c76c42de8b76bfcc2c07956ce879f', - name: 'Patient With A Problem', - date_of_birth: '1985-03-24', - sex: 'female', - phone: '+254777888999', - parent: { - contact: { - phone: '+254777888999' - } - } - } - }, - patient_age_in_years: '25', - patient_contact_phone: '+254777888999', - patient_id: 'd57c76c42de8b76bfcc2c07956ce879f', - patient_name: 'Patient With A Problem', - lmp_date: '2015-09-23T07:00:00.000Z', - follow_up_method: 'in_person', - delivery_plan_discussed: '', - danger_signs: 'd7', - referral_follow_up_needed: 'true', - days_since_lmp: '271.69', - weeks_since_lmp: '38.81', - edd: 'Jun 29, 2016', - p_note: '', - group_followup_options: { - g_follow_up_method: 'in_person', - call_button: '' - }, - group_danger_signs: { - g_danger_signs: 'd7' - }, - group_review: { - submit: '', - r_summary: '', - r_pregnancy_details: '', - r_referral: '', - r_referral_note: '', - r_danger_sign1: '', - r_danger_sign2: '', - r_danger_sign3: '', - r_danger_sign4: '', - r_danger_sign5: '', - r_danger_sign6: '', - r_danger_sign7: '', - r_danger_sign8: '', - r_danger_sign9: '', - r_reminders: '', - r_reminder_trim1: '', - r_reminder_trim2: '', - r_reminder_trim3: '', - r_followup_instructions: 'Follow up in 1 day to ensure that patient goes to a health facility', - r_followup: '', - r_followup_note: '' - }, - group_delivery_plan: { - no_delivery_plan_discussed: '', - delivery_plan: '', - g_delivery_plan_discussed: '' - }, - group_healthy_newborn_practices: { - healthy_newborn_practices: '', - submit: '' - } - }, - form: 'pregnancy_visit', - type: 'data_record', - content_type: 'xml', - reported_date: 1466466049001, - contact: { - name: 'Robert', - phone: '+254777111222', - parent: { - type: 'health_center', - name: 'HippieLand CHP Area1', - contact: { - type: 'person', - name: 'HippieLand CHP Area1 Person', - phone: '+254702123123' - }, - _id: '6850E77F-5FFC-9B01-8D5B-3D6E33DFA73E', - _rev: '1-9ed31f1ee070eb64351c6f2a4f8dfe5c' - }, - type: 'person', - _id: 'DFEF75F5-4D25-EA47-8706-2B12500EFD8F', - _rev: '1-4c6b5d0545c0aba0b5f9213cc29b4e14' - }, - from: '+254777111222', - hidden_fields: [ - 'days_since_lmp', - 'weeks_since_lmp', - 'p_note', - 'group_followup_options', - 'group_danger_signs', - 'group_review', - 'group_delivery_plan', - 'group_healthy_newborn_practices' - ] +const { loadView, buildViewMapFn } = require('./utils'); +const medicOfflineFreetext = require('../../../../src/js/bootstrapper/offline-ddocs/medic-offline-freetext'); +const { expect } = require('chai'); + +const createReport = (data = {}) => { + return { + type: 'data_record', + form: 'test', + reported_date: 1466466049001, + ...data, + }; }; -describe('reports_by_freetext view', () => { +describe('reports_by_freetext', () => { + [ + ['online view', loadView('medic-db', 'medic-client', 'reports_by_freetext')], + ['offline view', buildViewMapFn(medicOfflineFreetext.views.reports_by_freetext.map)], + ].forEach(([name, mapFn]) => { + describe(name, () => { + afterEach(() => mapFn.reset()); - it('indexes doc name', () => { - // given - const map = utils.loadView('medic-db', 'medic-client', 'reports_by_freetext'); + [ + undefined, + 'invalid', + 'contact', + 'person', + ].forEach(type => it('emits nothing when type is invalid', () => { + const doc = createReport({ type }); + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); - // when - const emitted = map(doc); + [ + undefined, + null, + '', + ].forEach(form => it('emits nothing when form is not valued', () => { + const doc = createReport({ form }); + const emitted = mapFn(doc, true); + expect(emitted).to.be.empty; + })); - // then - // Keys are arrays, so flatten the array of arrays for easier asserts. - const flattened = _.flattenDeep(emitted); - assert.include(flattened, 'patient'); - assert.include(flattened, 'with'); - assert.include(flattened, 'problem'); - }); + [ + null, undefined, { hello: 'world' }, {}, true + ].forEach(hello => it('emits nothing for a field when value is not a string or number', () => { + const doc = createReport({ hello }); - it('indexes non-ascii doc name', () => { - // given - const map = utils.loadView('medic-db', 'medic-client', 'reports_by_freetext'); + const emitted = mapFn(doc, true); - // when - doc.name = 'बुद्ध Élève'; - const emitted = map(doc); + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + ]); + })); - // then - // Keys are arrays, so flatten the array of arrays for easier asserts. - const flattened = _.flattenDeep(emitted); - assert.include(flattened, 'बुद्ध'); - assert.include(flattened, 'élève'); - }); + it('emits only key:value for field when value is number', () => { + const doc = createReport({ hello: 1234 }); - it('does not index words of less than 3 chars', () => { - // given - const map = utils.loadView('medic-db', 'medic-client', 'reports_by_freetext'); + const emitted = mapFn(doc, true); - // when - const emitted = map(doc); + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + { key: ['hello:1234'], value: doc.reported_date } + ]); + }); - // then - // Keys are arrays, so flatten the array of arrays for easier asserts. - const flattened = _.flattenDeep(emitted); - assert.notInclude(flattened, 'a'); - }); + [ + 't', 'to' + ].forEach(hello => it('emits nothing but key:value when value is too short', () => { + const doc = createReport({ hello }); - it('does not index non-reports docs', () => { - // given - const map = utils.loadView('medic-db', 'medic-client', 'reports_by_freetext'); + const emitted = mapFn(doc, true); - // when - const emitted = map({ - type: 'person', - name: 'do not index me' - }); + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + { key: [`hello:${hello}`], value: doc.reported_date } + ]); + })); + + it('emits nothing for field when value is empty', () => { + const doc = createReport({ hello: '' }); + + const emitted = mapFn(doc, true); + + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + ]); + }); + + [ + '_id', '_rev', 'refid', 'content', 'Refid', + ].forEach(key => it(`emits nothing for a skipped field: ${key}`, () => { + const doc = createReport({ [key]: 'world' }); + + const emitted = mapFn(doc, true); + + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + ]); + })); + + it('emits nothing for fields that end with "_date"', () => { + const doc = createReport({ birth_date: 'world' }); + + const emitted = mapFn(doc, true); - // then - // Keys are arrays, so flatten the array of arrays for easier asserts. - assert.equal(emitted.length, 0); + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + ]); + }); + + it('emits value only once', () => { + const doc = createReport({ + hello: 'world world', + hello1: 'world', + hello3: 'world', + }); + + const emitted = mapFn(doc, true); + + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + { key: ['world'], value: doc.reported_date }, + { key: ['hello:world world'], value: doc.reported_date }, + { key: ['hello1:world'], value: doc.reported_date }, + { key: ['hello3:world'], value: doc.reported_date } + ]); + }); + + it('normalizes keys and values to lowercase', () => { + const doc = createReport({ HeLlo: 'WoRlD', NBR: 1234 }); + + const emitted = mapFn(doc, true); + + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + { key: ['world'], value: doc.reported_date }, + { key: ['hello:world'], value: doc.reported_date }, + { key: ['nbr:1234'], value: doc.reported_date }, + ]); + }); + + it('emits each word in a string', () => { + const doc = createReport({ hello: `the quick\nBrown\tfox` }); + + const emitted = mapFn(doc, true); + + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + { key: ['the'], value: doc.reported_date }, + { key: ['quick'], value: doc.reported_date }, + { key: ['brown'], value: doc.reported_date }, + { key: ['fox'], value: doc.reported_date }, + { key: ['hello:the quick\nbrown\tfox'], value: doc.reported_date }, + ]); + }); + + it('emits non-ascii values', () => { + const doc = createReport({ name: 'बुद्ध Élève' }); + + const emitted = mapFn(doc, true); + + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + { key: ['बुद्ध'], value: doc.reported_date }, + { key: ['élève'], value: doc.reported_date }, + { key: ['name:बुद्ध élève'], value: doc.reported_date } + ]); + }); + }); }); }); From 02878bea1f2bea5327951e5fb2ac492873cb6b64 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 6 Jan 2025 15:55:33 -0600 Subject: [PATCH 11/12] Fix test name Co-authored-by: Sugat Bajracharya <30311933+sugat009@users.noreply.github.com> --- tests/e2e/default/contacts/search-contacts.wdio-spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/default/contacts/search-contacts.wdio-spec.js b/tests/e2e/default/contacts/search-contacts.wdio-spec.js index c07b9aef773..a6c4c6ef3c7 100644 --- a/tests/e2e/default/contacts/search-contacts.wdio-spec.js +++ b/tests/e2e/default/contacts/search-contacts.wdio-spec.js @@ -71,7 +71,7 @@ describe('Contact Search', () => { after(commonPage.logout); - it('search by NON empty string should display results with contains match and clears search', async () => { + it('search by NON empty string should display results which contains match and clears search', async () => { await contactPage.getAllLHSContactsNames(); await searchPage.performSearch('sittu'); From 471bf18be2cd2c5be5ff20b2b1cc8921251b800c Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 6 Jan 2025 16:08:51 -0600 Subject: [PATCH 12/12] Update parameterized test names to include parameter --- .../unit/views/contacts_by_freetext.spec.js | 14 +++++----- .../views/contacts_by_type_freetext.spec.js | 14 +++++----- .../unit/views/reports_by_freetext.spec.js | 27 ++++++++++--------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js b/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js index 2e057ff488d..cae989a09f4 100644 --- a/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js +++ b/webapp/tests/mocha/unit/views/contacts_by_freetext.spec.js @@ -18,7 +18,7 @@ describe('contacts_by_freetext', () => { ['health_center', 1], ['clinic', 2], ['person', 3], - ].forEach(([type, typeIndex]) => it('emits numerical index for default type', () => { + ].forEach(([type, typeIndex]) => it(`emits numerical index [${typeIndex}] for default type`, () => { const doc = { type, hello: 'world' }; const emitted = mapFn(doc, true); @@ -36,7 +36,7 @@ describe('contacts_by_freetext', () => { ['contact', 2, 'clinic'], ['contact', 3, 'person'] ].forEach(([type, typeIndex, contactType]) => it( - 'emits numerical index for default type when used as custom type', + `emits numerical index [${typeIndex}] for default type when used as custom type`, () => { const doc = { type, hello: 'world', contact_type: contactType }; @@ -69,7 +69,7 @@ describe('contacts_by_freetext', () => { [ undefined, 'invalid' - ].forEach(type => it('emits nothing when type is invalid', () => { + ].forEach(type => it(`emits nothing when type is invalid [${type}]`, () => { const doc = { type, hello: 'world' }; const emitted = mapFn(doc, true); expect(emitted).to.be.empty; @@ -101,7 +101,7 @@ describe('contacts_by_freetext', () => { [ 'hello', 'HeLlO' - ].forEach(name => it('emits name in value', () => { + ].forEach(name => it(`emits name in value [${name}]`, () => { const doc = { type: 'district_hospital', name }; const emitted = mapFn(doc, true); @@ -115,7 +115,7 @@ describe('contacts_by_freetext', () => { [ null, undefined, { hello: 'world' }, {}, true - ].forEach(hello => it('emits nothing when value is not a string or number', () => { + ].forEach(hello => it(`emits nothing when value is not a string or number [${JSON.stringify(hello)}]`, () => { const doc = { type: 'district_hospital', hello }; const emitted = mapFn(doc, true); expect(emitted).to.be.empty; @@ -132,7 +132,7 @@ describe('contacts_by_freetext', () => { [ 't', 'to' - ].forEach(hello => it('emits nothing but key:value when value is too short', () => { + ].forEach(hello => it(`emits nothing but key:value when value is too short [${hello}]`, () => { const doc = { type: 'district_hospital', hello }; const emitted = mapFn(doc, true); @@ -149,7 +149,7 @@ describe('contacts_by_freetext', () => { [ '_id', '_rev', 'type', 'refid', 'geolocation', 'Refid' - ].forEach(key => it('emits nothing for a skipped field', () => { + ].forEach(key => it(`emits nothing for a skipped field [${key}]`, () => { const doc = { type: 'district_hospital', [key]: 'world' }; const emitted = mapFn(doc, true); expect(emitted).to.be.empty; diff --git a/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js b/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js index 42b0694364d..7e8a8bc4319 100644 --- a/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js +++ b/webapp/tests/mocha/unit/views/contacts_by_type_freetext.spec.js @@ -18,7 +18,7 @@ describe('contacts_by_type_freetext', () => { ['health_center', 1], ['clinic', 2], ['person', 3], - ].forEach(([type, typeIndex]) => it('emits numerical index for default type', () => { + ].forEach(([type, typeIndex]) => it(`emits numerical index [${typeIndex}] for default type`, () => { const doc = { type, hello: 'world' }; const emitted = mapFn(doc, true); @@ -36,7 +36,7 @@ describe('contacts_by_type_freetext', () => { ['contact', 2, 'clinic'], ['contact', 3, 'person'] ].forEach(([type, typeIndex, contactType]) => it( - 'emits numerical index for default type when used as custom type', + `emits numerical index [${typeIndex}] for default type when used as custom type`, () => { const doc = { type, hello: 'world', contact_type: contactType }; @@ -69,7 +69,7 @@ describe('contacts_by_type_freetext', () => { [ undefined, 'invalid' - ].forEach(type => it('emits nothing when type is invalid', () => { + ].forEach(type => it(`emits nothing when type is invalid [${type}]`, () => { const doc = { type, hello: 'world' }; const emitted = mapFn(doc, true); expect(emitted).to.be.empty; @@ -101,7 +101,7 @@ describe('contacts_by_type_freetext', () => { [ 'hello', 'HeLlO' - ].forEach(name => it('emits name in value', () => { + ].forEach(name => it(`emits name in value [${name}]`, () => { const doc = { type: 'district_hospital', name }; const emitted = mapFn(doc, true); @@ -115,7 +115,7 @@ describe('contacts_by_type_freetext', () => { [ null, undefined, { hello: 'world' }, {}, true - ].forEach(hello => it('emits nothing when value is not a string or number', () => { + ].forEach(hello => it(`emits nothing when value is not a string or number [${JSON.stringify(hello)}]`, () => { const doc = { type: 'district_hospital', hello }; const emitted = mapFn(doc, true); expect(emitted).to.be.empty; @@ -132,7 +132,7 @@ describe('contacts_by_type_freetext', () => { [ 't', 'to' - ].forEach(hello => it('emits nothing but key:value when value is too short', () => { + ].forEach(hello => it(`emits nothing but key:value when value is too short [${hello}]`, () => { const doc = { type: 'district_hospital', hello }; const emitted = mapFn(doc, true); @@ -149,7 +149,7 @@ describe('contacts_by_type_freetext', () => { [ '_id', '_rev', 'type', 'refid', 'geolocation', 'Refid' - ].forEach(key => it('emits nothing for a skipped field', () => { + ].forEach(key => it(`emits nothing for a skipped field [${key}]`, () => { const doc = { type: 'district_hospital', [key]: 'world' }; const emitted = mapFn(doc, true); expect(emitted).to.be.empty; diff --git a/webapp/tests/mocha/unit/views/reports_by_freetext.spec.js b/webapp/tests/mocha/unit/views/reports_by_freetext.spec.js index 72dc5a9cf84..c5fcf613754 100644 --- a/webapp/tests/mocha/unit/views/reports_by_freetext.spec.js +++ b/webapp/tests/mocha/unit/views/reports_by_freetext.spec.js @@ -24,7 +24,7 @@ describe('reports_by_freetext', () => { 'invalid', 'contact', 'person', - ].forEach(type => it('emits nothing when type is invalid', () => { + ].forEach(type => it(`emits nothing when type is invalid [${type}]`, () => { const doc = createReport({ type }); const emitted = mapFn(doc, true); expect(emitted).to.be.empty; @@ -34,7 +34,7 @@ describe('reports_by_freetext', () => { undefined, null, '', - ].forEach(form => it('emits nothing when form is not valued', () => { + ].forEach(form => it(`emits nothing when form is not valued [${form}]`, () => { const doc = createReport({ form }); const emitted = mapFn(doc, true); expect(emitted).to.be.empty; @@ -42,16 +42,19 @@ describe('reports_by_freetext', () => { [ null, undefined, { hello: 'world' }, {}, true - ].forEach(hello => it('emits nothing for a field when value is not a string or number', () => { - const doc = createReport({ hello }); + ].forEach(hello => it( + `emits nothing for a field when value is not a string or number [${JSON.stringify(hello)}]`, + () => { + const doc = createReport({ hello }); - const emitted = mapFn(doc, true); + const emitted = mapFn(doc, true); - expect(emitted).to.deep.equal([ - { key: ['test'], value: doc.reported_date }, - { key: ['form:test'], value: doc.reported_date }, - ]); - })); + expect(emitted).to.deep.equal([ + { key: ['test'], value: doc.reported_date }, + { key: ['form:test'], value: doc.reported_date }, + ]); + } + )); it('emits only key:value for field when value is number', () => { const doc = createReport({ hello: 1234 }); @@ -67,7 +70,7 @@ describe('reports_by_freetext', () => { [ 't', 'to' - ].forEach(hello => it('emits nothing but key:value when value is too short', () => { + ].forEach(hello => it(`emits nothing but key:value when value is too short [${hello}]`, () => { const doc = createReport({ hello }); const emitted = mapFn(doc, true); @@ -92,7 +95,7 @@ describe('reports_by_freetext', () => { [ '_id', '_rev', 'refid', 'content', 'Refid', - ].forEach(key => it(`emits nothing for a skipped field: ${key}`, () => { + ].forEach(key => it(`emits nothing for a skipped field: [${key}]`, () => { const doc = createReport({ [key]: 'world' }); const emitted = mapFn(doc, true);