From 9eb471f109af89df24e42443f8ea00525f5fdd3a Mon Sep 17 00:00:00 2001 From: Anro Date: Wed, 15 Jan 2025 17:47:02 +0200 Subject: [PATCH 1/4] fix: prototype duplicate prevention --- webapp/package-lock.json | 27 +++ webapp/package.json | 4 +- webapp/src/css/enketo/medic.less | 52 +++++ webapp/src/ts/components/components.module.ts | 3 + .../duplicate-info.component.html | 53 +++++ .../duplicate-info.component.ts | 40 ++++ .../components/enketo/enketo.component.html | 1 + .../contacts/contacts-edit.component.html | 6 +- .../contacts/contacts-edit.component.ts | 28 ++- webapp/src/ts/services/form.service.ts | 61 ++++- webapp/src/ts/services/utils/deduplicate.ts | 94 ++++++++ .../xml-forms-context-utils.service.ts | 9 + .../contacts/contacts-edit.component.spec.ts | 12 +- .../karma/ts/services/form.service.spec.ts | 219 +++++++++++++++++- .../ts/services/utils/deduplicate.spec.ts | 156 +++++++++++++ 15 files changed, 749 insertions(+), 16 deletions(-) create mode 100644 webapp/src/ts/components/duplicate-info/duplicate-info.component.html create mode 100644 webapp/src/ts/components/duplicate-info/duplicate-info.component.ts create mode 100644 webapp/src/ts/services/utils/deduplicate.ts create mode 100644 webapp/tests/karma/ts/services/utils/deduplicate.spec.ts diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 73f53129a40..c1602a78e1f 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -25,6 +25,7 @@ "@ngrx/effects": "^17.1.1", "@ngrx/store": "^17.1.1", "@ngx-translate/core": "^16.0.3", + "@types/levenshtein": "1.0.4", "@webcomponents/webcomponentsjs": "^2.8.0", "bikram-sambat-bootstrap": "^1.6.0", "bootstrap": "^3.4.1", @@ -34,6 +35,7 @@ "eurodigit": "^3.1.3", "font-awesome": "^4.7.0", "jquery": "3.5.1", + "levenshtein": "1.0.5", "lodash-es": "^4.17.21", "moment-locales-webpack-plugin": "^1.2.0", "ngrx-store-logger": "^0.2.4", @@ -4808,6 +4810,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/levenshtein": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz", + "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA==", + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -8687,6 +8695,15 @@ "node": ">=0.10.0" } }, + "node_modules/levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz", + "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA==", + "engines": [ + "node >=0.2.0" + ], + "license": "Public Domain" + }, "node_modules/license-webpack-plugin": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", @@ -16839,6 +16856,11 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "@types/levenshtein": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz", + "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA==" + }, "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -19648,6 +19670,11 @@ "integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==", "dev": true }, + "levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz", + "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA==" + }, "license-webpack-plugin": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", diff --git a/webapp/package.json b/webapp/package.json index ad807a7194d..00660774e8c 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -61,7 +61,9 @@ "select2": "4.0.3", "signature_pad": "2.3.x", "tslib": "^2.5.3", - "zone.js": "^0.14.4" + "zone.js": "^0.14.4", + "levenshtein": "1.0.5", + "@types/levenshtein": "1.0.4" }, "overrides": { "minimist": ">=1.2.6" diff --git a/webapp/src/css/enketo/medic.less b/webapp/src/css/enketo/medic.less index f4801f4a7df..0785b782389 100644 --- a/webapp/src/css/enketo/medic.less +++ b/webapp/src/css/enketo/medic.less @@ -443,6 +443,58 @@ .pages.or .or-repeat-info[role="page"] { display: block; } + + #duplicate_info { + width: 100%; + min-height: 20px; + padding-left: 20px; + padding-right: 20px; + background-color: #ffe7e8; + + .results_header { + font-size: large; + color: #e33030; + } + + .acknowledge_label { + -webkit-user-select: none; -ms-user-select: none; user-select: none; + } + + .acknowledge_checkbox { + margin-right: 5px; + } + + .divider { + background-color: #e33030; + height: 1px; + margin-top: 5px; + margin-bottom: 5px; + } + + .card { + border: 1px solid #ddd; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 5px; + } + + .nested-section { + margin-left: 1.5rem; + } + + .toggle-button { + background: none; + border: none; + color: #007bff; + cursor: pointer; + font-weight: bold; + padding-left: 0px; + } + + .toggle-button:hover { + text-decoration: underline; + } + } } @media (max-width: @media-mobile) { diff --git a/webapp/src/ts/components/components.module.ts b/webapp/src/ts/components/components.module.ts index 0c930b97e08..c26a5c85a79 100644 --- a/webapp/src/ts/components/components.module.ts +++ b/webapp/src/ts/components/components.module.ts @@ -47,6 +47,7 @@ import { PanelHeaderComponent } from '@mm-components/panel-header/panel-header.c import { SidebarMenuComponent } from '@mm-components/sidebar-menu/sidebar-menu.component'; import { ToolBarComponent } from '@mm-components/tool-bar/tool-bar.component'; import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/training-cards-form.component'; +import {DuplicateInfoComponent} from '@mm-components/duplicate-info/duplicate-info.component'; @NgModule({ declarations: [ @@ -78,6 +79,7 @@ import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/t SidebarMenuComponent, TrainingCardsFormComponent, ToolBarComponent, + DuplicateInfoComponent, ], imports: [ CommonModule, @@ -122,6 +124,7 @@ import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/t SidebarMenuComponent, TrainingCardsFormComponent, ToolBarComponent, + DuplicateInfoComponent, ] }) export class ComponentsModule { } diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.html b/webapp/src/ts/components/duplicate-info/duplicate-info.component.html new file mode 100644 index 00000000000..5e061f5f16a --- /dev/null +++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.html @@ -0,0 +1,53 @@ +
+

{{ duplicates.length }} {{'potential duplicate item(s) found:' | translate }}

+
+
+ +
+
+
+
+ {{ 'Item number:' | translate }} {{ i + 1 }} +
+

+ {{ 'Name:' | translate }} {{ duplicate.name }} +
+ {{ 'Created on:' | translate }} {{ duplicate.reported_date | date: 'EEE MMM dd yyyy HH:mm:ss' }} +

+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ + {{ key.key }}: {{ key.value }} + +
+
+
diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts new file mode 100644 index 00000000000..844088f9226 --- /dev/null +++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts @@ -0,0 +1,40 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'mm-duplicate-info', + templateUrl: './duplicate-info.component.html', +}) +export class DuplicateInfoComponent { + @Input() acknowledged: boolean = false; + @Output() acknowledgedChange = new EventEmitter(); + @Output() navigateToDuplicate = new EventEmitter(); + @Input() duplicates: { _id: string; name: string; reported_date: string | Date; [key: string]: string | Date }[] = []; + + toggleAcknowledged() { + this.acknowledged = !this.acknowledged; + this.acknowledgedChange.emit(this.acknowledged); + } + + _navigateToDuplicate(_id: string){ + this.navigateToDuplicate.emit(_id); + } + + // Handles collapse / expand of duplicate doc details + expandedSections = new Map(); + + toggleSection(path: string): void { + this.expandedSections.set(path, !this.expandedSections.get(path)); + } + + isExpanded(path: string): boolean { + return this.expandedSections.get(path) || false; + } + + isObject(value: any): boolean { + return value && typeof value === 'object' && !Array.isArray(value); + } + + getPath(parentPath: string, key: string): string { + return parentPath ? `${parentPath}.${key}` : key; + } +} diff --git a/webapp/src/ts/components/enketo/enketo.component.html b/webapp/src/ts/components/enketo/enketo.component.html index 1a4f81d24b0..773c8d0b6b5 100644 --- a/webapp/src/ts/components/enketo/enketo.component.html +++ b/webapp/src/ts/components/enketo/enketo.component.html @@ -1,5 +1,6 @@
+
- + +
+ +
+
diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index e0407b7caf4..8e3cb82ee24 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -5,7 +5,7 @@ import { isEqual as _isEqual } from 'lodash-es'; import { ActivatedRoute, Router } from '@angular/router'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { FormService } from '@mm-services/form.service'; +import { FormService, DuplicatesFoundError, Duplicate } from '@mm-services/form.service'; import { EnketoFormContext } from '@mm-services/enketo.service'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { DbService } from '@mm-services/db.service'; @@ -55,6 +55,18 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private trackSave; private trackMetadata = { action: '', form: '' }; + private duplicateCheck; + acknowledged = false; + onAcknowledgeChange(value: boolean) { + this.acknowledged = value; + } + + onNavigateToDuplicate(_id: string){ + this.router.navigate(['/contacts', _id, 'edit']); + } + + duplicates: Duplicate[] = []; + ngOnInit() { this.trackRender = this.performanceService.track(); this.subscribeToStore(); @@ -153,6 +165,10 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.contentError = false; this.errorTranslationKey = false; + // Reset when when navigated to duplicate + this.duplicates = []; + this.acknowledged = false; + try { const contact = await this.getContact(); const contactTypeId = this.contactTypesService.getTypeId(contact) || this.routeSnapshot.params?.type; @@ -272,6 +288,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private async renderForm(formId: string, titleKey: string) { const formDoc = await this.dbService.get().get(formId); this.xmlVersion = formDoc.xmlVersion; + this.duplicateCheck = formDoc.context?.duplicate_check; this.globalActions.setEnketoEditedStatus(false); @@ -326,7 +343,9 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { $('form.or').trigger('beforesave'); return this.formService - .saveContact(form, docId, this.enketoContact.type, this.xmlVersion) + .saveContact({ + form, docId, type: this.enketoContact.type, xmlVersion: this.xmlVersion + }, this.duplicateCheck, this.acknowledged) .then((result) => { console.debug('saved contact', result); @@ -345,6 +364,11 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.router.navigate(['/contacts', result.docId]); }) .catch((err) => { + if (err instanceof DuplicatesFoundError){ + this.duplicates = err.duplicates; + err = Error(err.message); + } + console.error('Error submitting form data', err); this.globalActions.setEnketoSavingStatus(false); diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index 2329c820407..65225f3f3d0 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -26,6 +26,9 @@ import { reduce as _reduce } from 'lodash-es'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; +import { extractExpression, requestSiblings, getDuplicates, Doc, DuplicateCheck } from './utils/deduplicate'; /** * Service for interacting with forms. This is the primary entry-point for CHT code to render forms and save the @@ -58,6 +61,8 @@ export class FormService { private enketoService: EnketoService, private targetAggregatesService: TargetAggregatesService, private contactViewModelGeneratorService: ContactViewModelGeneratorService, + private readonly parseProvider: ParseProvider, + private readonly xmlFormsDuplicateUtilsService: XmlFormsContextUtilsService, ) { this.inited = this.init(); this.globalActions = new GlobalActions(store); @@ -326,7 +331,39 @@ export class FormService { }, null); } - async saveContact(form, docId, type, xmlVersion) { + async checkForDuplicates(doc, duplicateCheck, acknowledged) { + const parentId = doc ? doc.parent?._id : undefined; + const contactType = doc ? doc.contact_type ?? doc.type : undefined; + const siblings = await requestSiblings(this.dbService, parentId, contactType); + const expression = extractExpression(duplicateCheck); + const isCanonical = doc.is_canonical ? doc.is_canonical === 'true' : false; + acknowledged = acknowledged ?? false; + + if (!isCanonical && expression && !acknowledged){ + const duplicates = getDuplicates( + doc, + siblings, + { + expression, + parseProvider: this.parseProvider, + xmlFormsContextUtilsService: this.xmlFormsDuplicateUtilsService + } + ); + return duplicates; + } + } + + async saveContact( + contactInfo: { + form: any; + docId: string| undefined; + type: string | undefined; + xmlVersion: string | undefined; + }, + duplicateCheck: DuplicateCheck, + acknowledged: boolean + ) { + const { form, docId, type, xmlVersion } = contactInfo; const typeFields = this.contactTypesService.isHardcodedType(type) ? { type } : { type: 'contact', contact_type: type }; @@ -335,6 +372,16 @@ export class FormService { const preparedDocs = await this.applyTransitions(docs); const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type); + + const duplicates = await this.checkForDuplicates( + primaryDoc || preparedDocs.preparedDocs[0], + duplicateCheck, + acknowledged + ); + if (duplicates && duplicates.length > 0){ + throw new DuplicatesFoundError('Duplicates found', duplicates); + } + this.servicesActions.setLastChangedDoc(primaryDoc || preparedDocs.preparedDocs[0]); const bulkDocsResult = await this.dbService.get().bulkDocs(preparedDocs.preparedDocs); const failureMessage = this.generateFailureMessage(bulkDocsResult); @@ -350,4 +397,14 @@ export class FormService { this.enketoService.unload(form); } } - +export class DuplicatesFoundError extends Error { + duplicates: Duplicate[]; + + constructor(message: string, duplicates: Duplicate[]) { + super(message); + this.message = message; + this.duplicates = duplicates; + this.name = 'DuplicatesFoundError'; + } +} +export type Duplicate = Doc; diff --git a/webapp/src/ts/services/utils/deduplicate.ts b/webapp/src/ts/services/utils/deduplicate.ts new file mode 100644 index 00000000000..2aa7a3f6233 --- /dev/null +++ b/webapp/src/ts/services/utils/deduplicate.ts @@ -0,0 +1,94 @@ +import * as Levenshtein from 'levenshtein'; +import { DbService } from '@mm-services/db.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; + + +export type Doc = { _id: string; name: string; reported_date: number;[key: string]: any }; + +const DEFAULT_CONTACT_DUPLICATE_EXPRESSION = 'levenshteinEq(3, current.name, existing.name)'; + +// Normalize the distance by dividing by the length of the longer string. +// This can make the metric more adaptable across different string lengths +const normalizedLevenshteinEq = function (str1: string, str2: string) { + const distance = levenshteinEq(str1, str2); + const maxLen = Math.max(str1.length, str2.length); + return (maxLen === 0) ? 0 : (distance / maxLen); +}; + +// The Levenshtein distance is a measure of the number of edits (insertions, deletions, and substitutions) +// required to change one string into another. +const levenshteinEq = function (str1: string, str2: string): number { + return new Levenshtein(str1, str2).distance; +}; + + +const requestSiblings = async function (dbService: DbService, parentId: string, contactType: string) { + const siblings: Doc[] = []; + const results = parentId && contactType && await dbService.get().query('medic-client/contacts_by_parent', { + startkey: [parentId, contactType], + endkey: [parentId, contactType, {}], + include_docs: true + }); + + if (results) { + // Desc order - reverse order by switching props + siblings.push(...results.rows.map((row: { doc: Doc }) => row.doc) + .sort((a: Doc, b: Doc) => (b.reported_date || 0) - (a.reported_date || 0))); + } + return siblings; +}; + +export type DuplicateCheck = { expression?: string; disabled?: boolean } | undefined; +const extractExpression = function (duplicateCheck: DuplicateCheck) { + // eslint-disable-next-line eqeqeq + if (duplicateCheck != null) { + if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'expression')) { + return duplicateCheck.expression as string; + } else if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'disabled') && duplicateCheck.disabled) { + return null; // No duplicate check should be performed + } + } + + return DEFAULT_CONTACT_DUPLICATE_EXPRESSION; +}; + +const getDuplicates = function ( + doc: Doc, + siblings: Array, + config: { + expression: string; + parseProvider: ParseProvider; + xmlFormsContextUtilsService: XmlFormsContextUtilsService; + } +) { + const { expression, parseProvider, xmlFormsContextUtilsService } = config; + // eslint-disable-next-line eqeqeq + const _siblings: Doc[] = siblings.filter((s) => !((doc._id != null && s._id === doc._id))); + // Remove the currently edited doc from the sibling list + + const duplicates: Array = []; + for (const sibling of _siblings) { + const parsed = parseProvider.parse(expression); + const test = parsed(xmlFormsContextUtilsService, { + current: doc, + existing: sibling, + }); + if (test) { + duplicates.push(sibling); + } + } + + return duplicates; +}; + +export { + normalizedLevenshteinEq, + levenshteinEq, + + requestSiblings, + extractExpression, + getDuplicates, + + DEFAULT_CONTACT_DUPLICATE_EXPRESSION +}; diff --git a/webapp/src/ts/services/xml-forms-context-utils.service.ts b/webapp/src/ts/services/xml-forms-context-utils.service.ts index a3d86cfc2aa..74e8f887946 100644 --- a/webapp/src/ts/services/xml-forms-context-utils.service.ts +++ b/webapp/src/ts/services/xml-forms-context-utils.service.ts @@ -1,5 +1,6 @@ import * as moment from 'moment'; import { Injectable } from '@angular/core'; +import { normalizedLevenshteinEq, levenshteinEq } from './utils/deduplicate'; /** * Util functions available to a form doc's `.context` function for checking if @@ -31,4 +32,12 @@ export class XmlFormsContextUtilsService { return this.getDateDiff(contact, 'years'); } + levenshteinEq(threshold: number, current: string, existing: string){ + return current && existing ? levenshteinEq(current, existing) < threshold : false; + } + + normalizedLevenshteinEq(threshold: number, current: string, existing: string){ + return current && existing ? normalizedLevenshteinEq(current, existing) < threshold : false; + } + } diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts index 61c63b0d16c..cf43470bc9e 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts @@ -709,7 +709,9 @@ describe('ContactsEdit component', () => { expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); - expect(formService.saveContact.args[0]).to.deep.equal([ form, null, 'clinic', undefined ]); + expect(formService.saveContact.args[0]).to.deep.equal([ + { form, docId: null, type: 'clinic', xmlVersion: undefined }, undefined, false + ]); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'new_clinic_id']]); }); @@ -744,7 +746,9 @@ describe('ContactsEdit component', () => { expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); - expect(formService.saveContact.args[0]).to.deep.equal([ form, 'the_person', 'person', undefined ]); + expect(formService.saveContact.args[0]).to.deep.equal( + [ {form, docId: 'the_person', type: 'person', xmlVersion: undefined}, undefined, false ] + ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_person']]); expect(performanceService.track.calledThrice).to.be.true; @@ -792,7 +796,9 @@ describe('ContactsEdit component', () => { expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); - expect(formService.saveContact.args[0]).to.deep.equal([ form, 'the_patient', 'patient', undefined ]); + expect(formService.saveContact.args[0]).to.deep.equal( + [ { form, docId: 'the_patient', type: 'patient', xmlVersion: undefined }, undefined, false ] + ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_patient']]); expect(performanceService.track.calledThrice).to.be.true; diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index 5b8599081a5..eec8501a382 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -37,6 +37,9 @@ import * as FileManager from '../../../../src/js/enketo/file-manager.js'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; + describe('Form service', () => { // return a mock form ready for putting in #dbContent const mockEnketoDoc = formInternalId => { @@ -63,6 +66,7 @@ describe('Form service', () => { let dbGetAttachment; let dbGet; let dbBulkDocs; + let dbQuery; let ContactSummary; let Form2Sms; let UserContact; @@ -94,12 +98,15 @@ describe('Form service', () => { let extractLineageService; let targetAggregatesService; let contactViewModelGeneratorService; + let parserProvider; + let xmlFormsContextUtilsService; beforeEach(() => { enketoInit = sinon.stub(); dbGetAttachment = sinon.stub(); dbGet = sinon.stub(); dbBulkDocs = sinon.stub(); + dbQuery = sinon.stub(); ContactSummary = sinon.stub(); Form2Sms = sinon.stub(); UserContact = sinon.stub(); @@ -163,13 +170,16 @@ describe('Form service', () => { targetAggregatesService = { getTargetDocs: sinon.stub() }; contactViewModelGeneratorService = { loadReports: sinon.stub() }; + parserProvider = sinon.stub(); + xmlFormsContextUtilsService = sinon.stub(); + TestBed.configureTestingModule({ providers: [ provideMockStore(), { provide: DbService, useValue: { - get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs }) + get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs, query: dbQuery }) } }, { provide: ContactSummaryService, useValue: { get: ContactSummary } }, @@ -193,6 +203,8 @@ describe('Form service', () => { { provide: ExtractLineageService, useValue: extractLineageService }, { provide: TargetAggregatesService, useValue: targetAggregatesService }, { provide: ContactViewModelGeneratorService, useValue: contactViewModelGeneratorService }, + { provide: ParseProvider, useValue: parserProvider}, + { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService } ], }); @@ -1299,19 +1311,24 @@ describe('Form service', () => { let extractLineageService; let enketoTranslationService; + let parse; + beforeEach(() => { extractLineageService = { extract: sinon.stub() }; enketoTranslationService = { contactRecordToJs: sinon.stub(), }; + parse = sinon.stub(); + parserProvider = { parse }; + TestBed.configureTestingModule({ providers: [ provideMockStore(), { provide: DbService, useValue: { - get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs }) + get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs, query: dbQuery }) } }, { provide: ContactSummaryService, useValue: { get: ContactSummary } }, @@ -1334,6 +1351,8 @@ describe('Form service', () => { { provide: TranslateService, useValue: translateService }, { provide: TrainingCardsService, useValue: trainingCardsService }, { provide: FeedbackService, useValue: feedbackService }, + { provide: ParseProvider, useValue: parserProvider}, + { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService }, ], }); @@ -1357,7 +1376,7 @@ describe('Form service', () => { extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1391,7 +1410,7 @@ describe('Form service', () => { extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1435,7 +1454,7 @@ describe('Form service', () => { dbBulkDocs.resolves([]); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.isTrue(dbBulkDocs.calledOnce); @@ -1494,7 +1513,7 @@ describe('Form service', () => { clock = sinon.useFakeTimers(5000); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.equal(dbGet.callCount, 2); assert.deepEqual(dbGet.args[0], ['main1']); @@ -1543,7 +1562,7 @@ describe('Form service', () => { clock = sinon.useFakeTimers(1000); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1580,5 +1599,191 @@ describe('Form service', () => { assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); }); }); + + it('should throw an error with duplicates found', async function () { + const form = { getDataStr: () => '' }; + const docId = null; + const type = 'some-contact-type'; + + dbGet.resolves({ }); + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } + }); + extractLineageService.extract.returns({ _id: 'parent1'}); + transitionsService.applyTransitions.callsFake((docs) => { + docs[0].transitioned = true; + return Promise.resolve(docs); + }); + dbQuery.resolves({ + offset: 0, + rows: [ + { id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + ], + total_rows: 2 + }); + dbBulkDocs.resolves([]); + clock = sinon.useFakeTimers(1000); + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-undef + parse.callsFake(() => (XmlFormsContextUtilsService, ctx) => true); + try { + await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false); + // Fail the test if no error is thrown + throw new Error('Expected saveContact to throw an error, but it did not.'); + } catch (e) { + expect(e.message).to.include('Duplicates found'); + expect(e.duplicates).to.have.lengthOf(2); + expect(e.duplicates).to.deep.equal([ + { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + ]); + } + }); + + it('should pass duplicate check when duplicates are acknowledged', async function () { + const form = { getDataStr: () => '' }; + const docId = null; + const type = 'some-contact-type'; + + dbGet.resolves({ }); + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } + }); + extractLineageService.extract.returns({ _id: 'parent1'}); + transitionsService.applyTransitions.callsFake((docs) => { + docs[0].transitioned = true; + return Promise.resolve(docs); + }); + dbQuery.resolves({ + offset: 0, + rows: [ + { id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + ], + total_rows: 2 + }); + dbBulkDocs.resolves([]); + clock = sinon.useFakeTimers(1000); + + await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, true); + assert.equal(transitionsService.applyTransitions.callCount, 1); + assert.deepEqual(transitionsService.applyTransitions.args[0], [[ + { + _id: 'main1', + name: 'Main', + type: 'contact', + contact_type: type, + parent: { _id: 'parent1' }, + reported_date: 1000, + contact: undefined, + transitioned: true + } + ]]); + }); + + it('should pass duplicate check when record is marked as canonical', async function () { + const form = { getDataStr: () => '' }; + const docId = null; + const type = 'some-contact-type'; + + dbGet.resolves({ }); + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' }, is_canonical: 'true' } + }); + extractLineageService.extract.returns({ _id: 'parent1'}); + transitionsService.applyTransitions.callsFake((docs) => { + docs[0].transitioned = true; + return Promise.resolve(docs); + }); + dbQuery.resolves({ + offset: 0, + rows: [ + { id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + ], + total_rows: 2 + }); + dbBulkDocs.resolves([]); + clock = sinon.useFakeTimers(1000); + + await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false); + assert.equal(transitionsService.applyTransitions.callCount, 1); + assert.deepEqual(transitionsService.applyTransitions.args[0], [[ + { + _id: 'main1', + name: 'Main', + type: 'contact', + contact_type: type, + parent: { _id: 'parent1' }, + reported_date: 1000, + contact: undefined, + transitioned: true, + is_canonical: 'true' + } + ]]); + }); }); }); diff --git a/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts b/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts new file mode 100644 index 00000000000..be5a6e89d3a --- /dev/null +++ b/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts @@ -0,0 +1,156 @@ +import { TestBed } from '@angular/core/testing'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +import { DbService } from '@mm-services/db.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; +import { + normalizedLevenshteinEq, + levenshteinEq, + requestSiblings, + extractExpression, + DEFAULT_CONTACT_DUPLICATE_EXPRESSION, + getDuplicates, +} from '../../../../../src/ts/services/utils/deduplicate'; + +describe('Deduplicate', () => { + let dbService; + let query; + + beforeEach(() => { + query = sinon.stub(); + dbService = { + get: () => ({ query }) + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: DbService, useValue: dbService } + ] + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('normalizedLevenshteinEq', () => { + it('should return return a score of 3', () => { + // Score/distance / maxLength + // 3 (3 characters need to be added to make str1 = str2) / 5 (Test123 is the larger string) + // ~ 0.42857142857142855 + expect(normalizedLevenshteinEq('Test123', 'Test')).lessThanOrEqual(0.42857142857142855); + }); + }); + + describe('levenshteinEq', () => { + it('should return return a score of 3', () => { + expect(levenshteinEq('Test123', 'Test')).to.equal(3); + }); + }); + + describe('requestSiblings', () => { + it('should return results filtered by parent and contact type', async function () { + query.resolves({ + offset: 0, + rows: [ + { id: 'sib1', doc: { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' } }, + { id: 'sib2', doc: { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' }}, + ], + total_rows: 6 + }); + const siblings = await requestSiblings(dbService, 'parent1', 'some_type'); + expect(siblings.length).to.equal(2); + expect(siblings).to.deep.equal([ + { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' }, + { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' }, + ]); + }); + }); + + describe('extractExpression', () => { + it('should return a default expression when none is provided', () => { + expect(extractExpression(undefined)).to.equal(DEFAULT_CONTACT_DUPLICATE_EXPRESSION); + }); + }); + + describe('getDuplicates', () => { + let pipesService; + let parseProvider; + beforeEach(() => { + pipesService = { + getPipeNameVsIsPureMap: sinon.stub().returns(new Map([['date', { pure: true }]])), + meta: sinon.stub(), + getInstance: sinon.stub(), + }; + parseProvider = new ParseProvider(pipesService); + }); + + it('should return duplicates based on default matching', () => { + const doc = { + _id: 'new', + name: 'Test', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }; + const siblings = [ + { + _id: 'sib1', + name: 'Test1', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Test2', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib3', + name: 'Test the things', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib4', + name: 'Testimony', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + ]; + const results = getDuplicates( + doc, + siblings, + { + expression: DEFAULT_CONTACT_DUPLICATE_EXPRESSION, + parseProvider, + xmlFormsContextUtilsService: new XmlFormsContextUtilsService() + } + ); + expect(results.length).equal(2); + expect(results).to.deep.equal([ + { + _id: 'sib1', + name: 'Test1', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Test2', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + ]); + }); + }); +}); From 872daebed660a44a321baabb6cf33c8e81cd08c5 Mon Sep 17 00:00:00 2001 From: Anro Date: Wed, 29 Jan 2025 10:17:50 +0200 Subject: [PATCH 2/4] fix: round 1 review changes --- webapp/package-lock.json | 59 +++++++----- webapp/package.json | 3 +- .../contacts/contacts-edit.component.ts | 10 +- webapp/src/ts/services/deduplicate.service.ts | 68 ++++++++++++++ webapp/src/ts/services/form.service.ts | 62 ++++++------ webapp/src/ts/services/utils/deduplicate.ts | 94 ------------------- .../xml-forms-context-utils.service.ts | 20 ++-- .../contacts/contacts-edit.component.spec.ts | 6 +- ...te.spec.ts => deduplicate.service.spec.ts} | 70 +++++--------- .../karma/ts/services/form.service.spec.ts | 2 +- .../xml-forms-context-utils.service.spec.ts | 17 ++++ 11 files changed, 193 insertions(+), 218 deletions(-) create mode 100644 webapp/src/ts/services/deduplicate.service.ts delete mode 100644 webapp/src/ts/services/utils/deduplicate.ts rename webapp/tests/karma/ts/services/{utils/deduplicate.spec.ts => deduplicate.service.spec.ts} (66%) diff --git a/webapp/package-lock.json b/webapp/package-lock.json index c1602a78e1f..ba44b990558 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -25,7 +25,6 @@ "@ngrx/effects": "^17.1.1", "@ngrx/store": "^17.1.1", "@ngx-translate/core": "^16.0.3", - "@types/levenshtein": "1.0.4", "@webcomponents/webcomponentsjs": "^2.8.0", "bikram-sambat-bootstrap": "^1.6.0", "bootstrap": "^3.4.1", @@ -33,9 +32,9 @@ "core-js": "^3.30.2", "enketo-core": "^7.2.5", "eurodigit": "^3.1.3", + "fastest-levenshtein": "1.0.5", "font-awesome": "^4.7.0", "jquery": "3.5.1", - "levenshtein": "1.0.5", "lodash-es": "^4.17.21", "moment-locales-webpack-plugin": "^1.2.0", "ngrx-store-logger": "^0.2.4", @@ -4810,12 +4809,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/levenshtein": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz", - "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA==", - "license": "MIT" - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -7306,6 +7299,15 @@ "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==", "dev": true }, + "node_modules/fastest-levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.5.tgz", + "integrity": "sha512-vqVqAjWp4Vhn2rQzhG4SzAvAv2967qn3opdJkYqkSyQ3ojZp+4OnQkdRQWAYdmjt291MAUI2kmfQGdVQHdM0/A==", + "license": "MIT", + "dependencies": { + "levenshtein-edit-distance": "^2.0.5" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -8695,14 +8697,18 @@ "node": ">=0.10.0" } }, - "node_modules/levenshtein": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz", - "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA==", - "engines": [ - "node >=0.2.0" - ], - "license": "Public Domain" + "node_modules/levenshtein-edit-distance": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/levenshtein-edit-distance/-/levenshtein-edit-distance-2.0.5.tgz", + "integrity": "sha512-Yuraz7QnMX/JENJU1HA6UtdsbhRzoSFnGpVGVryjQgHtl2s/YmVgmNYkVs5yzVZ9aAvQR9wPBUH3lG755ylxGA==", + "license": "MIT", + "bin": { + "levenshtein-edit-distance": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, "node_modules/license-webpack-plugin": { "version": "4.0.2", @@ -16856,11 +16862,6 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, - "@types/levenshtein": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz", - "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA==" - }, "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -18708,6 +18709,14 @@ "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==", "dev": true }, + "fastest-levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.5.tgz", + "integrity": "sha512-vqVqAjWp4Vhn2rQzhG4SzAvAv2967qn3opdJkYqkSyQ3ojZp+4OnQkdRQWAYdmjt291MAUI2kmfQGdVQHdM0/A==", + "requires": { + "levenshtein-edit-distance": "^2.0.5" + } + }, "fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -19670,10 +19679,10 @@ "integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==", "dev": true }, - "levenshtein": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz", - "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA==" + "levenshtein-edit-distance": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/levenshtein-edit-distance/-/levenshtein-edit-distance-2.0.5.tgz", + "integrity": "sha512-Yuraz7QnMX/JENJU1HA6UtdsbhRzoSFnGpVGVryjQgHtl2s/YmVgmNYkVs5yzVZ9aAvQR9wPBUH3lG755ylxGA==" }, "license-webpack-plugin": { "version": "4.0.2", diff --git a/webapp/package.json b/webapp/package.json index 00660774e8c..0a376962c09 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -62,8 +62,7 @@ "signature_pad": "2.3.x", "tslib": "^2.5.3", "zone.js": "^0.14.4", - "levenshtein": "1.0.5", - "@types/levenshtein": "1.0.4" + "fastest-levenshtein": "1.0.5" }, "overrides": { "minimist": ">=1.2.6" diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 8e3cb82ee24..a4cc9602d84 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -62,7 +62,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { } onNavigateToDuplicate(_id: string){ - this.router.navigate(['/contacts', _id, 'edit']); + this.router.navigate(['/contacts', _id]); } duplicates: Duplicate[] = []; @@ -165,10 +165,6 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.contentError = false; this.errorTranslationKey = false; - // Reset when when navigated to duplicate - this.duplicates = []; - this.acknowledged = false; - try { const contact = await this.getContact(); const contactTypeId = this.contactTypesService.getTypeId(contact) || this.routeSnapshot.params?.type; @@ -288,7 +284,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private async renderForm(formId: string, titleKey: string) { const formDoc = await this.dbService.get().get(formId); this.xmlVersion = formDoc.xmlVersion; - this.duplicateCheck = formDoc.context?.duplicate_check; + this.duplicateCheck = formDoc.duplicate_check; this.globalActions.setEnketoEditedStatus(false); @@ -345,7 +341,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { return this.formService .saveContact({ form, docId, type: this.enketoContact.type, xmlVersion: this.xmlVersion - }, this.duplicateCheck, this.acknowledged) + }, this.acknowledged, this.duplicateCheck) .then((result) => { console.debug('saved contact', result); diff --git a/webapp/src/ts/services/deduplicate.service.ts b/webapp/src/ts/services/deduplicate.service.ts new file mode 100644 index 00000000000..1706dd0176d --- /dev/null +++ b/webapp/src/ts/services/deduplicate.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { DbService } from '@mm-services/db.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; + +export type Doc = { _id: string; name: string; reported_date: number;[key: string]: any }; +export const DEFAULT_CONTACT_DUPLICATE_EXPRESSION = 'levenshteinEq(current.name, existing.name, 3)'; +export type DuplicateCheck = { expression?: string; disabled?: boolean }; +@Injectable({ + providedIn: 'root' +}) +export class DeduplicateService { + constructor( + private dbService: DbService, + private readonly parseProvider: ParseProvider, + private readonly xmlFormsContextUtilsService: XmlFormsContextUtilsService, + ) {} + + async requestSiblings (parentId: string, contactType: string) { + const siblings: Doc[] = []; + // Generally, Only reason why we won't have a "parent_id" is if we're creating/editing a top-level place. + const results = contactType && (parentId ? await this.dbService.get().query('medic-client/contacts_by_parent', { + startkey: [parentId, contactType], + endkey: [parentId, contactType, {}], + include_docs: true + }) : await this.dbService.get().query('medic-client/contacts_by_type', { + startkey: [contactType], + endkey: [contactType, {}], + include_docs: true + })); + + if (results) { + siblings.push(...results.rows.map((row: { doc: Doc }) => row.doc)); + } + return siblings; + } + + + extractExpression (duplicateCheck?: DuplicateCheck) { + if (duplicateCheck) { + if (typeof duplicateCheck.expression === 'string') { + return duplicateCheck.expression as string; + } else if (duplicateCheck.disabled === true) { + return null; // No duplicate check should be performed + } + } + + return DEFAULT_CONTACT_DUPLICATE_EXPRESSION; + } + + getDuplicates ( + doc: Doc, + siblings: Array, + expression: string + ) { + const _siblings: Doc[] = siblings.filter(({ _id }) => _id !== doc._id); + // Remove the currently edited doc from the sibling list + + return _siblings.filter((sibling) => { + const parsed = this.parseProvider.parse(expression); + return parsed(this.xmlFormsContextUtilsService, { + current: doc, + existing: sibling, + }); + }).sort((a: Doc, b: Doc) => (b.reported_date || 0) - (a.reported_date || 0)); + // Desc order - reverse order by switching props + } +} diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index 65225f3f3d0..cf175152ca1 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -26,9 +26,7 @@ import { reduce as _reduce } from 'lodash-es'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; -import { ParseProvider } from '@mm-providers/parse.provider'; -import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; -import { extractExpression, requestSiblings, getDuplicates, Doc, DuplicateCheck } from './utils/deduplicate'; +import { DeduplicateService, Doc, DuplicateCheck } from '@mm-services/deduplicate.service'; /** * Service for interacting with forms. This is the primary entry-point for CHT code to render forms and save the @@ -42,15 +40,15 @@ import { extractExpression, requestSiblings, getDuplicates, Doc, DuplicateCheck export class FormService { constructor( private store: Store, - private contactSaveService:ContactSaveService, + private contactSaveService: ContactSaveService, private contactSummaryService: ContactSummaryService, - private contactTypesService:ContactTypesService, + private contactTypesService: ContactTypesService, private dbService: DbService, private fileReaderService: FileReaderService, private lineageModelGeneratorService: LineageModelGeneratorService, private submitFormBySmsService: SubmitFormBySmsService, private userContactService: UserContactService, - private userSettingsService:UserSettingsService, + private userSettingsService: UserSettingsService, private xmlFormsService: XmlFormsService, private zScoreService: ZScoreService, private trainingCardsService: TrainingCardsService, @@ -61,8 +59,7 @@ export class FormService { private enketoService: EnketoService, private targetAggregatesService: TargetAggregatesService, private contactViewModelGeneratorService: ContactViewModelGeneratorService, - private readonly parseProvider: ParseProvider, - private readonly xmlFormsDuplicateUtilsService: XmlFormsContextUtilsService, + private deduplicateService: DeduplicateService ) { this.inited = this.init(); this.globalActions = new GlobalActions(store); @@ -170,7 +167,7 @@ export class FormService { try { this.unload(this.enketoService.getCurrentForm()); - const [ doc, userSettings ] = await Promise.all([ + const [doc, userSettings] = await Promise.all([ this.transformXml(formDoc), this.userSettingsService.getWithLanguage() ]); @@ -220,7 +217,7 @@ export class FormService { }); } - private async getUserContact(requiresContact:boolean) { + private async getUserContact(requiresContact: boolean) { const contact = await this.userContactService.get(); if (requiresContact && !contact) { const err: any = new Error('Your user does not have an associated contact, or does not have access to the ' + @@ -331,37 +328,33 @@ export class FormService { }, null); } - async checkForDuplicates(doc, duplicateCheck, acknowledged) { + async checkForDuplicates(doc, acknowledged: boolean, duplicateCheck?: DuplicateCheck): Promise> { const parentId = doc ? doc.parent?._id : undefined; const contactType = doc ? doc.contact_type ?? doc.type : undefined; - const siblings = await requestSiblings(this.dbService, parentId, contactType); - const expression = extractExpression(duplicateCheck); + const expression = this.deduplicateService.extractExpression(duplicateCheck); const isCanonical = doc.is_canonical ? doc.is_canonical === 'true' : false; acknowledged = acknowledged ?? false; - if (!isCanonical && expression && !acknowledged){ - const duplicates = getDuplicates( - doc, - siblings, - { - expression, - parseProvider: this.parseProvider, - xmlFormsContextUtilsService: this.xmlFormsDuplicateUtilsService - } - ); - return duplicates; + if (isCanonical || !expression || acknowledged) { + return []; } + + return this.deduplicateService.getDuplicates( + doc, + await this.deduplicateService.requestSiblings(parentId, contactType), + expression + ); } async saveContact( - contactInfo: { - form: any; - docId: string| undefined; + contactInfo: { + form: any; + docId: string | undefined; type: string | undefined; xmlVersion: string | undefined; - }, - duplicateCheck: DuplicateCheck, - acknowledged: boolean + }, + acknowledged: boolean, + duplicateCheck?: DuplicateCheck ) { const { form, docId, type, xmlVersion } = contactInfo; const typeFields = this.contactTypesService.isHardcodedType(type) @@ -374,11 +367,12 @@ export class FormService { const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type); const duplicates = await this.checkForDuplicates( - primaryDoc || preparedDocs.preparedDocs[0], - duplicateCheck, - acknowledged + primaryDoc || preparedDocs.preparedDocs[0], + acknowledged, + duplicateCheck ); - if (duplicates && duplicates.length > 0){ + + if (duplicates.length) { throw new DuplicatesFoundError('Duplicates found', duplicates); } diff --git a/webapp/src/ts/services/utils/deduplicate.ts b/webapp/src/ts/services/utils/deduplicate.ts deleted file mode 100644 index 2aa7a3f6233..00000000000 --- a/webapp/src/ts/services/utils/deduplicate.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as Levenshtein from 'levenshtein'; -import { DbService } from '@mm-services/db.service'; -import { ParseProvider } from '@mm-providers/parse.provider'; -import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; - - -export type Doc = { _id: string; name: string; reported_date: number;[key: string]: any }; - -const DEFAULT_CONTACT_DUPLICATE_EXPRESSION = 'levenshteinEq(3, current.name, existing.name)'; - -// Normalize the distance by dividing by the length of the longer string. -// This can make the metric more adaptable across different string lengths -const normalizedLevenshteinEq = function (str1: string, str2: string) { - const distance = levenshteinEq(str1, str2); - const maxLen = Math.max(str1.length, str2.length); - return (maxLen === 0) ? 0 : (distance / maxLen); -}; - -// The Levenshtein distance is a measure of the number of edits (insertions, deletions, and substitutions) -// required to change one string into another. -const levenshteinEq = function (str1: string, str2: string): number { - return new Levenshtein(str1, str2).distance; -}; - - -const requestSiblings = async function (dbService: DbService, parentId: string, contactType: string) { - const siblings: Doc[] = []; - const results = parentId && contactType && await dbService.get().query('medic-client/contacts_by_parent', { - startkey: [parentId, contactType], - endkey: [parentId, contactType, {}], - include_docs: true - }); - - if (results) { - // Desc order - reverse order by switching props - siblings.push(...results.rows.map((row: { doc: Doc }) => row.doc) - .sort((a: Doc, b: Doc) => (b.reported_date || 0) - (a.reported_date || 0))); - } - return siblings; -}; - -export type DuplicateCheck = { expression?: string; disabled?: boolean } | undefined; -const extractExpression = function (duplicateCheck: DuplicateCheck) { - // eslint-disable-next-line eqeqeq - if (duplicateCheck != null) { - if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'expression')) { - return duplicateCheck.expression as string; - } else if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'disabled') && duplicateCheck.disabled) { - return null; // No duplicate check should be performed - } - } - - return DEFAULT_CONTACT_DUPLICATE_EXPRESSION; -}; - -const getDuplicates = function ( - doc: Doc, - siblings: Array, - config: { - expression: string; - parseProvider: ParseProvider; - xmlFormsContextUtilsService: XmlFormsContextUtilsService; - } -) { - const { expression, parseProvider, xmlFormsContextUtilsService } = config; - // eslint-disable-next-line eqeqeq - const _siblings: Doc[] = siblings.filter((s) => !((doc._id != null && s._id === doc._id))); - // Remove the currently edited doc from the sibling list - - const duplicates: Array = []; - for (const sibling of _siblings) { - const parsed = parseProvider.parse(expression); - const test = parsed(xmlFormsContextUtilsService, { - current: doc, - existing: sibling, - }); - if (test) { - duplicates.push(sibling); - } - } - - return duplicates; -}; - -export { - normalizedLevenshteinEq, - levenshteinEq, - - requestSiblings, - extractExpression, - getDuplicates, - - DEFAULT_CONTACT_DUPLICATE_EXPRESSION -}; diff --git a/webapp/src/ts/services/xml-forms-context-utils.service.ts b/webapp/src/ts/services/xml-forms-context-utils.service.ts index 74e8f887946..3a10c4d7344 100644 --- a/webapp/src/ts/services/xml-forms-context-utils.service.ts +++ b/webapp/src/ts/services/xml-forms-context-utils.service.ts @@ -1,6 +1,6 @@ import * as moment from 'moment'; +const { distance } = require('fastest-levenshtein'); import { Injectable } from '@angular/core'; -import { normalizedLevenshteinEq, levenshteinEq } from './utils/deduplicate'; /** * Util functions available to a form doc's `.context` function for checking if @@ -32,12 +32,20 @@ export class XmlFormsContextUtilsService { return this.getDateDiff(contact, 'years'); } - levenshteinEq(threshold: number, current: string, existing: string){ - return current && existing ? levenshteinEq(current, existing) < threshold : false; + // The Levenshtein distance is a measure of the number of edits (insertions, deletions, and substitutions) + // required to change one string into another. + levenshteinEq(current: string, existing: string, threshold: number = 3){ + return current && existing ? distance(current, existing) <= threshold : false; } - normalizedLevenshteinEq(threshold: number, current: string, existing: string){ - return current && existing ? normalizedLevenshteinEq(current, existing) < threshold : false; - } + private _normalizedLevenshteinEq = (str1: string, str2: string) :number => { + const maxLen = Math.max(str1.length, str2.length); + return (maxLen === 0) ? 0 : (distance(str1, str2) / maxLen); + }; + // Normalize the distance by dividing by the length of the longer string. + // This can make the metric more adaptable across different string lengths + normalizedLevenshteinEq(current: string, existing: string, threshold: number = 3){ + return current && existing ? this._normalizedLevenshteinEq(current, existing) <= threshold : false; + } } diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts index cf43470bc9e..a5cb4fdcd79 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts @@ -710,7 +710,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); expect(formService.saveContact.args[0]).to.deep.equal([ - { form, docId: null, type: 'clinic', xmlVersion: undefined }, undefined, false + { form, docId: null, type: 'clinic', xmlVersion: undefined }, false, undefined ]); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'new_clinic_id']]); @@ -747,7 +747,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); expect(formService.saveContact.args[0]).to.deep.equal( - [ {form, docId: 'the_person', type: 'person', xmlVersion: undefined}, undefined, false ] + [ {form, docId: 'the_person', type: 'person', xmlVersion: undefined}, false, undefined ] ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_person']]); @@ -797,7 +797,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); expect(formService.saveContact.args[0]).to.deep.equal( - [ { form, docId: 'the_patient', type: 'patient', xmlVersion: undefined }, undefined, false ] + [ { form, docId: 'the_patient', type: 'patient', xmlVersion: undefined }, false, undefined ] ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_patient']]); diff --git a/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts b/webapp/tests/karma/ts/services/deduplicate.service.spec.ts similarity index 66% rename from webapp/tests/karma/ts/services/utils/deduplicate.spec.ts rename to webapp/tests/karma/ts/services/deduplicate.service.spec.ts index be5a6e89d3a..d5dbdeb8026 100644 --- a/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts +++ b/webapp/tests/karma/ts/services/deduplicate.service.spec.ts @@ -5,51 +5,44 @@ import { expect } from 'chai'; import { DbService } from '@mm-services/db.service'; import { ParseProvider } from '@mm-providers/parse.provider'; import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; -import { - normalizedLevenshteinEq, - levenshteinEq, - requestSiblings, - extractExpression, - DEFAULT_CONTACT_DUPLICATE_EXPRESSION, - getDuplicates, -} from '../../../../../src/ts/services/utils/deduplicate'; +import { + DeduplicateService, + DEFAULT_CONTACT_DUPLICATE_EXPRESSION +} from '@mm-services/deduplicate.service'; describe('Deduplicate', () => { - let dbService; let query; + let service; - beforeEach(() => { + beforeEach(async () => { query = sinon.stub(); - dbService = { + const dbService = { get: () => ({ query }) }; + const pipesService: any = { + getPipeNameVsIsPureMap: sinon.stub().returns(new Map([['date', { pure: true }]])), + meta: sinon.stub(), + getInstance: sinon.stub(), + }; + const parserProvider = new ParseProvider(pipesService); + const xmlFormsContextUtilsService = new XmlFormsContextUtilsService(); + TestBed.configureTestingModule({ providers: [ - { provide: DbService, useValue: dbService } + { provide: DbService, useValue: dbService }, + { provide: ParseProvider, useValue: parserProvider}, + { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService } ] }); + + service = TestBed.inject(DeduplicateService); }); afterEach(() => { sinon.restore(); }); - describe('normalizedLevenshteinEq', () => { - it('should return return a score of 3', () => { - // Score/distance / maxLength - // 3 (3 characters need to be added to make str1 = str2) / 5 (Test123 is the larger string) - // ~ 0.42857142857142855 - expect(normalizedLevenshteinEq('Test123', 'Test')).lessThanOrEqual(0.42857142857142855); - }); - }); - - describe('levenshteinEq', () => { - it('should return return a score of 3', () => { - expect(levenshteinEq('Test123', 'Test')).to.equal(3); - }); - }); - describe('requestSiblings', () => { it('should return results filtered by parent and contact type', async function () { query.resolves({ @@ -60,7 +53,7 @@ describe('Deduplicate', () => { ], total_rows: 6 }); - const siblings = await requestSiblings(dbService, 'parent1', 'some_type'); + const siblings = await service.requestSiblings('parent1', 'some_type'); expect(siblings.length).to.equal(2); expect(siblings).to.deep.equal([ { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' }, @@ -71,22 +64,11 @@ describe('Deduplicate', () => { describe('extractExpression', () => { it('should return a default expression when none is provided', () => { - expect(extractExpression(undefined)).to.equal(DEFAULT_CONTACT_DUPLICATE_EXPRESSION); + expect(service.extractExpression(undefined)).to.equal(DEFAULT_CONTACT_DUPLICATE_EXPRESSION); }); }); describe('getDuplicates', () => { - let pipesService; - let parseProvider; - beforeEach(() => { - pipesService = { - getPipeNameVsIsPureMap: sinon.stub().returns(new Map([['date', { pure: true }]])), - meta: sinon.stub(), - getInstance: sinon.stub(), - }; - parseProvider = new ParseProvider(pipesService); - }); - it('should return duplicates based on default matching', () => { const doc = { _id: 'new', @@ -125,14 +107,10 @@ describe('Deduplicate', () => { reported_date: 1736845534000 }, ]; - const results = getDuplicates( + const results = service.getDuplicates( doc, siblings, - { - expression: DEFAULT_CONTACT_DUPLICATE_EXPRESSION, - parseProvider, - xmlFormsContextUtilsService: new XmlFormsContextUtilsService() - } + DEFAULT_CONTACT_DUPLICATE_EXPRESSION, ); expect(results.length).equal(2); expect(results).to.deep.equal([ diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index eec8501a382..35916814d36 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -1711,7 +1711,7 @@ describe('Form service', () => { dbBulkDocs.resolves([]); clock = sinon.useFakeTimers(1000); - await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, true); + await service.saveContact({form, docId, type, xmlVersion: undefined}, true, undefined); assert.equal(transitionsService.applyTransitions.callCount, 1); assert.deepEqual(transitionsService.applyTransitions.args[0], [[ { diff --git a/webapp/tests/karma/ts/services/xml-forms-context-utils.service.spec.ts b/webapp/tests/karma/ts/services/xml-forms-context-utils.service.spec.ts index 0a21ca9dd74..cd9c88484e9 100644 --- a/webapp/tests/karma/ts/services/xml-forms-context-utils.service.spec.ts +++ b/webapp/tests/karma/ts/services/xml-forms-context-utils.service.spec.ts @@ -120,4 +120,21 @@ describe('XmlFormsContextUtils service', () => { }); }); + + describe('Levenshtein', () => { + describe('normalizedLevenshteinEq', () => { + it('should return true for a threshold of 0.4285', () => { + // Score/distance / maxLength + // 3 (3 characters need to be added to make str1 = str2) / 5 (Test123 is the larger string) + // ~ 0.42857142857142855 + expect(service.normalizedLevenshteinEq('Test123', 'Test', 0.42857142857142855)).to.equal(true); + }); + }); + + describe('levenshteinEq', () => { + it('should return true for a threshold of 3', () => { + expect(service.levenshteinEq('Test123', 'Test', 3)).to.equal(true); + }); + }); + }); }); From 27b509d52b379f539bd378b72c1e31d415179dce Mon Sep 17 00:00:00 2001 From: Anro Date: Wed, 29 Jan 2025 14:32:49 +0200 Subject: [PATCH 3/4] fix: sonar cloud changes --- webapp/src/ts/services/deduplicate.service.ts | 6 +++--- webapp/src/ts/services/form.service.ts | 2 +- webapp/src/ts/services/xml-forms-context-utils.service.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/webapp/src/ts/services/deduplicate.service.ts b/webapp/src/ts/services/deduplicate.service.ts index 1706dd0176d..35983f5feb3 100644 --- a/webapp/src/ts/services/deduplicate.service.ts +++ b/webapp/src/ts/services/deduplicate.service.ts @@ -11,7 +11,7 @@ export type DuplicateCheck = { expression?: string; disabled?: boolean }; }) export class DeduplicateService { constructor( - private dbService: DbService, + private readonly dbService: DbService, private readonly parseProvider: ParseProvider, private readonly xmlFormsContextUtilsService: XmlFormsContextUtilsService, ) {} @@ -28,7 +28,7 @@ export class DeduplicateService { endkey: [contactType, {}], include_docs: true })); - + if (results) { siblings.push(...results.rows.map((row: { doc: Doc }) => row.doc)); } @@ -39,7 +39,7 @@ export class DeduplicateService { extractExpression (duplicateCheck?: DuplicateCheck) { if (duplicateCheck) { if (typeof duplicateCheck.expression === 'string') { - return duplicateCheck.expression as string; + return duplicateCheck.expression; } else if (duplicateCheck.disabled === true) { return null; // No duplicate check should be performed } diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index cf175152ca1..67c1d036692 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -59,7 +59,7 @@ export class FormService { private enketoService: EnketoService, private targetAggregatesService: TargetAggregatesService, private contactViewModelGeneratorService: ContactViewModelGeneratorService, - private deduplicateService: DeduplicateService + private readonly deduplicateService: DeduplicateService ) { this.inited = this.init(); this.globalActions = new GlobalActions(store); diff --git a/webapp/src/ts/services/xml-forms-context-utils.service.ts b/webapp/src/ts/services/xml-forms-context-utils.service.ts index 3a10c4d7344..2718bfcf5ac 100644 --- a/webapp/src/ts/services/xml-forms-context-utils.service.ts +++ b/webapp/src/ts/services/xml-forms-context-utils.service.ts @@ -38,7 +38,7 @@ export class XmlFormsContextUtilsService { return current && existing ? distance(current, existing) <= threshold : false; } - private _normalizedLevenshteinEq = (str1: string, str2: string) :number => { + private readonly _normalizedLevenshteinEq = (str1: string, str2: string) :number => { const maxLen = Math.max(str1.length, str2.length); return (maxLen === 0) ? 0 : (distance(str1, str2) / maxLen); }; From a9836f9ef4ea24fb0b70b437a235a65c84a863df Mon Sep 17 00:00:00 2001 From: Anro Date: Fri, 31 Jan 2025 16:45:16 +0200 Subject: [PATCH 4/4] fix: round 2 review changes --- .../duplicate-info.component.ts | 2 +- .../contacts/contacts-edit.component.ts | 5 +- webapp/src/ts/services/deduplicate.service.ts | 13 +- webapp/src/ts/services/form.service.ts | 1 + .../contacts/contacts-edit.component.spec.ts | 126 ++++++++-- .../ts/services/deduplicate.service.spec.ts | 29 ++- .../karma/ts/services/form.service.spec.ts | 229 +++++++++--------- 7 files changed, 253 insertions(+), 152 deletions(-) diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts index 844088f9226..dbf9771f263 100644 --- a/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts +++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts @@ -8,7 +8,7 @@ export class DuplicateInfoComponent { @Input() acknowledged: boolean = false; @Output() acknowledgedChange = new EventEmitter(); @Output() navigateToDuplicate = new EventEmitter(); - @Input() duplicates: { _id: string; name: string; reported_date: string | Date; [key: string]: string | Date }[] = []; + @Input() duplicates: { _id: string; name: string; reported_date: number; [key: string]: string | number }[] = []; toggleAcknowledged() { this.acknowledged = !this.acknowledged; diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index a4cc9602d84..ee1f30b0d02 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -5,7 +5,7 @@ import { isEqual as _isEqual } from 'lodash-es'; import { ActivatedRoute, Router } from '@angular/router'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { FormService, DuplicatesFoundError, Duplicate } from '@mm-services/form.service'; +import { FormService, DuplicatesFoundError, Duplicate, DuplicatesCheck } from '@mm-services/form.service'; import { EnketoFormContext } from '@mm-services/enketo.service'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { DbService } from '@mm-services/db.service'; @@ -55,7 +55,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private trackSave; private trackMetadata = { action: '', form: '' }; - private duplicateCheck; + private duplicateCheck?: DuplicatesCheck; acknowledged = false; onAcknowledgeChange(value: boolean) { this.acknowledged = value; @@ -362,7 +362,6 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { .catch((err) => { if (err instanceof DuplicatesFoundError){ this.duplicates = err.duplicates; - err = Error(err.message); } console.error('Error submitting form data', err); diff --git a/webapp/src/ts/services/deduplicate.service.ts b/webapp/src/ts/services/deduplicate.service.ts index 35983f5feb3..96856128cad 100644 --- a/webapp/src/ts/services/deduplicate.service.ts +++ b/webapp/src/ts/services/deduplicate.service.ts @@ -3,8 +3,8 @@ import { DbService } from '@mm-services/db.service'; import { ParseProvider } from '@mm-providers/parse.provider'; import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; -export type Doc = { _id: string; name: string; reported_date: number;[key: string]: any }; -export const DEFAULT_CONTACT_DUPLICATE_EXPRESSION = 'levenshteinEq(current.name, existing.name, 3)'; +export type Doc = { _id: string; name: string; reported_date: number; [key: string]: any }; +const DEFAULT_CONTACT_DUPLICATE_EXPRESSION = 'levenshteinEq(current.name, existing.name, 3)'; export type DuplicateCheck = { expression?: string; disabled?: boolean }; @Injectable({ providedIn: 'root' @@ -35,14 +35,13 @@ export class DeduplicateService { return siblings; } - extractExpression (duplicateCheck?: DuplicateCheck) { if (duplicateCheck) { - if (typeof duplicateCheck.expression === 'string') { - return duplicateCheck.expression; - } else if (duplicateCheck.disabled === true) { + if (duplicateCheck.disabled === true) { return null; // No duplicate check should be performed - } + } else if (typeof duplicateCheck.expression === 'string') { + return duplicateCheck.expression; + } } return DEFAULT_CONTACT_DUPLICATE_EXPRESSION; diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index 67c1d036692..a25abfbecb0 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -402,3 +402,4 @@ export class DuplicatesFoundError extends Error { } } export type Duplicate = Doc; +export type DuplicatesCheck = DuplicateCheck; diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts index a5cb4fdcd79..cd372fc0c75 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts @@ -16,7 +16,7 @@ import { PerformanceService } from '@mm-services/performance.service'; import { DbService } from '@mm-services/db.service'; import { Selectors } from '@mm-selectors/index'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { FormService } from '@mm-services/form.service'; +import { FormService, DuplicatesFoundError } from '@mm-services/form.service'; import { GlobalActions } from '@mm-actions/global'; @@ -59,12 +59,12 @@ describe('ContactsEdit component', () => { }; stopPerformanceTrackStub = sinon.stub(); performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) }; - lineageModelGeneratorService = { contact: sinon.stub().resolves({ doc: { } }) }; + lineageModelGeneratorService = { contact: sinon.stub().resolves({ doc: {} }) }; sinon.stub(console, 'error'); const mockedSelectors = [ - { selector: Selectors.getEnketoStatus, value: { } }, + { selector: Selectors.getEnketoStatus, value: {} }, { selector: Selectors.getEnketoSavingStatus, value: false }, { selector: Selectors.getEnketoEditedStatus, value: false }, { selector: Selectors.getEnketoError, value: false }, @@ -82,12 +82,12 @@ describe('ContactsEdit component', () => { provideMockStore({ selectors: mockedSelectors }), { provide: TranslateService, useValue: translateService }, { provide: DbService, useValue: { get: () => ({ get: dbGet }) } }, - { provide: Router, useValue: router }, + { provide: Router, useValue: router }, { provide: ActivatedRoute, useValue: route }, { provide: LineageModelGeneratorService, useValue: lineageModelGeneratorService }, { provide: FormService, useValue: formService }, { provide: ContactTypesService, useValue: contactTypesService }, - { provide: PerformanceService, useValue: performanceService}, + { provide: PerformanceService, useValue: performanceService }, ], declarations: [ EnketoComponent, @@ -118,7 +118,7 @@ describe('ContactsEdit component', () => { cancelCallback(); expect(router.navigate.callCount).to.equal(1); - expect(router.navigate.args[0]).to.deep.equal([[ '/contacts' ]]); + expect(router.navigate.args[0]).to.deep.equal([['/contacts']]); }); it('cancelling falls back to parent contact if new contact and query `from` param is not equal to `list`', @@ -134,7 +134,7 @@ describe('ContactsEdit component', () => { cancelCallback(); expect(router.navigate.callCount).to.equal(1); - expect(router.navigate.args[0]).to.deep.equal([[ '/contacts', 'parent_id' ]]); + expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'parent_id']]); }); it('cancelling falls back to parent contact if new contact and query does not have `from` param', async () => { @@ -148,7 +148,7 @@ describe('ContactsEdit component', () => { cancelCallback(); expect(router.navigate.callCount).to.equal(1); - expect(router.navigate.args[0]).to.deep.equal([[ '/contacts', 'parent_id' ]]); + expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'parent_id']]); }); it('cancelling falls back to contact if edit contact', async () => { @@ -162,7 +162,7 @@ describe('ContactsEdit component', () => { cancelCallback(); expect(router.navigate.callCount).to.equal(1); - expect(router.navigate.args[0]).to.deep.equal([[ '/contacts', 'id' ]]); + expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'id']]); }); }); @@ -628,7 +628,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(0); }); - it('should not save when invalid', async() => { + it('should not save when invalid', async () => { await createComponent(); await fixture.whenStable(); @@ -709,8 +709,8 @@ describe('ContactsEdit component', () => { expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); - expect(formService.saveContact.args[0]).to.deep.equal([ - { form, docId: null, type: 'clinic', xmlVersion: undefined }, false, undefined + expect(formService.saveContact.args[0]).to.deep.equal([ + { form, docId: null, type: 'clinic', xmlVersion: undefined }, false, undefined ]); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'new_clinic_id']]); @@ -747,7 +747,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); expect(formService.saveContact.args[0]).to.deep.equal( - [ {form, docId: 'the_person', type: 'person', xmlVersion: undefined}, false, undefined ] + [{ form, docId: 'the_person', type: 'person', xmlVersion: undefined }, false, undefined] ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_person']]); @@ -797,7 +797,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); expect(formService.saveContact.args[0]).to.deep.equal( - [ { form, docId: 'the_patient', type: 'patient', xmlVersion: undefined }, false, undefined ] + [{ form, docId: 'the_patient', type: 'patient', xmlVersion: undefined }, false, undefined] ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_patient']]); @@ -815,5 +815,103 @@ describe('ContactsEdit component', () => { recordApdex: true, }); }); + + it('should catch duplicate siblings', async () => { + routeSnapshot.params = { type: 'clinic', parent_id: 'the_district' }; + contactTypesService.getChildren.resolves([{ id: 'clinic' }]); + contactTypesService.get.resolves({ + create_form: 'clinic_create_form_id', + create_key: 'clinic_create_key', + }); + dbGet + .withArgs('the_district') + .resolves({ _id: 'the_district', type: 'clinic' }); + dbGet.resolves({ _id: 'clinic_create_form_id', the: 'form' }); + const form = { + validate: sinon.stub().resolves(true), + }; + formService.render.resolves(form); + + await createComponent(); + await fixture.whenStable(); + + formService.saveContact.rejects(new DuplicatesFoundError('Duplicates found', [ + { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type: 'the_district', + reported_date: 1736845534000 + } + ])); + + await component.save(); + + expect(setEnketoSavingStatus.callCount).to.equal(2); + expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); + expect(component.enketoContact.formInstance.validate.callCount).to.equal(1); + expect(formService.saveContact.callCount).to.equal(1); + expect(setEnketoError.callCount).to.equal(2); + expect(component.duplicates.length).to.equal(1); + }); + }); + + describe('onNavigateDuplicate', () => { + it('should navigate to the duplicate item', async () => { + routeSnapshot.params = { id: 'the_person' }; + lineageModelGeneratorService.contact.resolves({ + doc: { + _id: 'the_person', + type: 'person', + } + }); + contactTypesService.get.resolves({ + create_form: 'person_create_form_id', + edit_form: 'person_edit_form_id', + create_key: 'person_create_key', + }); + dbGet.resolves({ _id: 'person_edit_form_id', the: 'form' }); + const form = { + validate: sinon.stub().resolves(true), + }; + formService.render.resolves(form); + + await createComponent(); + await fixture.whenStable(); + + component.onNavigateToDuplicate('my_duplicate_id'); + + expect(router.navigate.callCount).to.equal(1); + expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'my_duplicate_id']]); + }); + }); + + describe('onAcknowledgeChange', () => { + it('should set acknowledge to true', async () => { + routeSnapshot.params = { type: 'clinic', parent_id: 'the_district' }; + contactTypesService.getChildren.resolves([{ id: 'clinic' }]); + contactTypesService.get.resolves({ + create_form: 'clinic_create_form_id', + create_key: 'clinic_create_key', + }); + dbGet + .withArgs('the_district') + .resolves({ _id: 'the_district', type: 'clinic' }); + dbGet.resolves({ _id: 'clinic_create_form_id', the: 'form' }); + const form = { + validate: sinon.stub().resolves(true), + }; + formService.render.resolves(form); + + await createComponent(); + await fixture.whenStable(); + + component.onAcknowledgeChange(true); + formService.saveContact.resolves({ docId: 'new_clinic_id' }); + await component.save(); + expect(formService.saveContact.args).to.deep.equal( + [ [ { form, docId: null, type: 'clinic', xmlVersion: undefined }, true, undefined ] ] + ); + }); }); }); diff --git a/webapp/tests/karma/ts/services/deduplicate.service.spec.ts b/webapp/tests/karma/ts/services/deduplicate.service.spec.ts index d5dbdeb8026..5be14446ed8 100644 --- a/webapp/tests/karma/ts/services/deduplicate.service.spec.ts +++ b/webapp/tests/karma/ts/services/deduplicate.service.spec.ts @@ -5,10 +5,7 @@ import { expect } from 'chai'; import { DbService } from '@mm-services/db.service'; import { ParseProvider } from '@mm-providers/parse.provider'; import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; -import { - DeduplicateService, - DEFAULT_CONTACT_DUPLICATE_EXPRESSION -} from '@mm-services/deduplicate.service'; +import { DeduplicateService } from '@mm-services/deduplicate.service'; describe('Deduplicate', () => { let query; @@ -63,8 +60,26 @@ describe('Deduplicate', () => { }); describe('extractExpression', () => { - it('should return a default expression when none is provided', () => { - expect(service.extractExpression(undefined)).to.equal(DEFAULT_CONTACT_DUPLICATE_EXPRESSION); + it('should return a default expression when no object is provided', () => { + expect(service.extractExpression(undefined)).to.equal('levenshteinEq(current.name, existing.name, 3)'); + }); + + it('should return the "user defined" expression', () => { + const expression = 'levenshtein("current.phone_number", "existing.phone_number")'; + expect(service.extractExpression({ + expression + })).to.equal(expression); + }); + + it('should return null when a object with the expression and disabled properties is provided', () => { + expect(service.extractExpression({ + expression: 'This should not be returned', + disabled: true, + })).to.equal(null); + }); + + it('should return a default expression when the object has no expression or disable property defined', () => { + expect(service.extractExpression({})).to.equal('levenshteinEq(current.name, existing.name, 3)'); }); }); @@ -110,7 +125,7 @@ describe('Deduplicate', () => { const results = service.getDuplicates( doc, siblings, - DEFAULT_CONTACT_DUPLICATE_EXPRESSION, + 'levenshteinEq(current.name, existing.name, 3)', ); expect(results.length).equal(2); expect(results).to.deep.equal([ diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index 35916814d36..98a2a227ad4 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -36,9 +36,7 @@ import { EnketoTranslationService } from '@mm-services/enketo-translation.servic import * as FileManager from '../../../../src/js/enketo/file-manager.js'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; - -import { ParseProvider } from '@mm-providers/parse.provider'; -import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; +import { DeduplicateService } from '@mm-services/deduplicate.service'; describe('Form service', () => { // return a mock form ready for putting in #dbContent @@ -98,8 +96,8 @@ describe('Form service', () => { let extractLineageService; let targetAggregatesService; let contactViewModelGeneratorService; - let parserProvider; - let xmlFormsContextUtilsService; + let deduplicateService; + let getDuplicates; beforeEach(() => { enketoInit = sinon.stub(); @@ -170,8 +168,10 @@ describe('Form service', () => { targetAggregatesService = { getTargetDocs: sinon.stub() }; contactViewModelGeneratorService = { loadReports: sinon.stub() }; - parserProvider = sinon.stub(); - xmlFormsContextUtilsService = sinon.stub(); + const requestSiblings = sinon.stub(); + const extractExpression = sinon.stub(); + getDuplicates = sinon.stub(); + deduplicateService = { requestSiblings, extractExpression, getDuplicates }; TestBed.configureTestingModule({ providers: [ @@ -203,8 +203,7 @@ describe('Form service', () => { { provide: ExtractLineageService, useValue: extractLineageService }, { provide: TargetAggregatesService, useValue: targetAggregatesService }, { provide: ContactViewModelGeneratorService, useValue: contactViewModelGeneratorService }, - { provide: ParseProvider, useValue: parserProvider}, - { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService } + { provide: DeduplicateService, useValue: deduplicateService } ], }); @@ -254,7 +253,7 @@ describe('Form service', () => { it('renders error when user does not have associated contact', () => { UserContact.resolves(); return service - .render(new EnketoFormContext('#', 'report', { })) + .render(new EnketoFormContext('#', 'report', {})) .then(() => { expect.fail('Should throw error'); }) @@ -278,11 +277,11 @@ describe('Form service', () => { ContactSummary.resolves({ context: { pregnant: false } }); Search.resolves([{ _id: 'some_report' }]); LineageModelGenerator.contact.resolves({ lineage: [{ _id: 'some_parent' }] }); - const instanceData = { contact: { _id: '123-patient-contact'} }; + const instanceData = { contact: { _id: '123-patient-contact' } }; EnketoPrepopulationData.resolves(''); const expectedErrorTitle = `Failed during the form "myform" rendering : `; - const expectedErrorDetail = [ 'nope', 'still nope' ]; + const expectedErrorDetail = ['nope', 'still nope']; const expectedErrorMessage = expectedErrorTitle + JSON.stringify(expectedErrorDetail); enketoInit.returns(expectedErrorDetail); @@ -401,7 +400,7 @@ describe('Form service', () => { expect(xmlStr).to.equal('true'); expect(contactViewModelGeneratorService.loadReports.callCount).to.equal(1); expect(contactViewModelGeneratorService.loadReports.args[0]).to.deep.equal( - [ { doc: instanceData.contact }, [] ] + [{ doc: instanceData.contact }, []] ); expect(LineageModelGenerator.contact.callCount).to.equal(1); expect(LineageModelGenerator.contact.args[0][0]).to.equal('fffff'); @@ -524,7 +523,7 @@ describe('Form service', () => { }; ContactSummary.resolves({ context: { pregnant: true } }); Search.resolves([{ _id: 'somereport' }]); - const formContext = new EnketoFormContext('div', 'report', mockEnketoDoc('myform'), instanceData); + const formContext = new EnketoFormContext('div', 'report', mockEnketoDoc('myform'), instanceData); return service.render(formContext).then(() => { expect(LineageModelGenerator.contact.callCount).to.equal(1); expect(LineageModelGenerator.contact.args[0][0]).to.equal('fffff'); @@ -545,7 +544,7 @@ describe('Form service', () => { .onFirstCall().resolves('
first form
') .onSecondCall().resolves(VISIT_MODEL); - await service.render(new EnketoFormContext('#div', 'report', mockEnketoDoc('firstForm'))); + await service.render(new EnketoFormContext('#div', 'report', mockEnketoDoc('firstForm'))); expect(form.resetView.notCalled).to.be.true; expect(UserContact.calledOnce).to.be.true; expect(EnketoPrepopulationData.calledOnce).to.be.true; @@ -555,8 +554,8 @@ describe('Form service', () => { expect(enketoInit.calledOnce).to.be.true; expect(form.editStatus).to.be.false; expect(dbGetAttachment.calledTwice).to.be.true; - expect(dbGetAttachment.args[0]).to.have.members([ 'form:firstForm', 'form.html' ]); - expect(dbGetAttachment.args[1]).to.have.members([ 'form:firstForm', 'model.xml' ]); + expect(dbGetAttachment.args[0]).to.have.members(['form:firstForm', 'form.html']); + expect(dbGetAttachment.args[1]).to.have.members(['form:firstForm', 'model.xml']); sinon.resetHistory(); dbGetAttachment @@ -573,8 +572,8 @@ describe('Form service', () => { expect(enketoInit.calledOnce).to.be.true; expect(form.editStatus).to.be.false; expect(dbGetAttachment.calledTwice).to.be.true; - expect(dbGetAttachment.args[0]).to.have.members([ 'form:secondForm', 'form.html' ]); - expect(dbGetAttachment.args[1]).to.have.members([ 'form:secondForm', 'model.xml' ]); + expect(dbGetAttachment.args[0]).to.have.members(['form:secondForm', 'form.html']); + expect(dbGetAttachment.args[1]).to.have.members(['form:secondForm', 'model.xml']); }); it('should throw exception if fails to get user settings', fakeAsync(async () => { @@ -687,7 +686,7 @@ describe('Form service', () => { trainingCardsService.isTrainingCardForm.returns(true); trainingCardsService.getTrainingCardDocId.returns('training:user-jim:'); form.validate.resolves(true); - dbBulkDocs.callsFake(docs => Promise.resolve([ { ok: true, id: docs[0]._id, rev: '1-abc' } ])); + dbBulkDocs.callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); UserContact.resolves({ _id: '123', phone: '555' }); return service @@ -1081,7 +1080,7 @@ describe('Form service', () => { describe('Saving attachments', () => { it('should save attachments', async () => { - const file = { name: 'my_file', type: 'image', foo: 'bar' }; + const file = { name: 'my_file', type: 'image', foo: 'bar' }; sinon .stub(FileManager, 'getCurrentFiles') .returns([file]); @@ -1311,16 +1310,15 @@ describe('Form service', () => { let extractLineageService; let enketoTranslationService; - let parse; - beforeEach(() => { extractLineageService = { extract: sinon.stub() }; enketoTranslationService = { contactRecordToJs: sinon.stub(), }; - parse = sinon.stub(); - parserProvider = { parse }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-undef + getDuplicates = (doc, siblings, expression) => siblings; + deduplicateService = { ...deduplicateService, getDuplicates }; TestBed.configureTestingModule({ providers: [ @@ -1351,8 +1349,7 @@ describe('Form service', () => { { provide: TranslateService, useValue: translateService }, { provide: TrainingCardsService, useValue: trainingCardsService }, { provide: FeedbackService, useValue: feedbackService }, - { provide: ParseProvider, useValue: parserProvider}, - { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService }, + { provide: DeduplicateService, useValue: deduplicateService }, ], }); @@ -1376,7 +1373,7 @@ describe('Form service', () => { extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1410,7 +1407,7 @@ describe('Form service', () => { extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1437,12 +1434,12 @@ describe('Form service', () => { const type = 'some-contact-type'; enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: 'NEW'}, + doc: { _id: 'main1', type: 'main', contact: 'NEW' }, siblings: { contact: { _id: 'sis1', type: 'sister', parent: 'PARENT', }, }, repeats: { - child_data: [ { _id: 'kid1', type: 'child', parent: 'PARENT', } ], + child_data: [{ _id: 'kid1', type: 'child', parent: 'PARENT', }], }, }); @@ -1454,7 +1451,7 @@ describe('Form service', () => { dbBulkDocs.resolves([]); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.isTrue(dbBulkDocs.calledOnce); @@ -1513,7 +1510,7 @@ describe('Form service', () => { clock = sinon.useFakeTimers(5000); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.equal(dbGet.callCount, 2); assert.deepEqual(dbGet.args[0], ['main1']); @@ -1562,7 +1559,7 @@ describe('Form service', () => { clock = sinon.useFakeTimers(1000); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1605,80 +1602,70 @@ describe('Form service', () => { const docId = null; const type = 'some-contact-type'; - dbGet.resolves({ }); + dbGet.resolves({}); enketoTranslationService.contactRecordToJs.returns({ doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } }); - extractLineageService.extract.returns({ _id: 'parent1'}); + extractLineageService.extract.returns({ _id: 'parent1' }); transitionsService.applyTransitions.callsFake((docs) => { docs[0].transitioned = true; return Promise.resolve(docs); }); - dbQuery.resolves({ - offset: 0, - rows: [ - { id: 'sib1', - doc: { - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - } - }, - { - id: 'sib2', - doc: { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - } - }, - ], - total_rows: 2 - }); dbBulkDocs.resolves([]); clock = sinon.useFakeTimers(1000); - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-undef - parse.callsFake(() => (XmlFormsContextUtilsService, ctx) => true); + + deduplicateService.requestSiblings.resolves([{ + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + },]); + deduplicateService.extractExpression.returns('levenshteinEq(current.name, existing.name, 3)'); + try { - await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false); + await service.saveContact({ form, docId, type, xmlVersion: undefined }, false, undefined); // Fail the test if no error is thrown throw new Error('Expected saveContact to throw an error, but it did not.'); } catch (e) { expect(e.message).to.include('Duplicates found'); expect(e.duplicates).to.have.lengthOf(2); expect(e.duplicates).to.deep.equal([ - { - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 + { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 }, - { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 + { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 } ]); } }); - + it('should pass duplicate check when duplicates are acknowledged', async function () { const form = { getDataStr: () => '' }; const docId = null; const type = 'some-contact-type'; - dbGet.resolves({ }); + dbGet.resolves({}); enketoTranslationService.contactRecordToJs.returns({ doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } }); - extractLineageService.extract.returns({ _id: 'parent1'}); + extractLineageService.extract.returns({ _id: 'parent1' }); transitionsService.applyTransitions.callsFake((docs) => { docs[0].transitioned = true; return Promise.resolve(docs); @@ -1686,23 +1673,24 @@ describe('Form service', () => { dbQuery.resolves({ offset: 0, rows: [ - { id: 'sib1', - doc: { - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - } + { + id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } }, - { - id: 'sib2', - doc: { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 } }, ], @@ -1711,32 +1699,32 @@ describe('Form service', () => { dbBulkDocs.resolves([]); clock = sinon.useFakeTimers(1000); - await service.saveContact({form, docId, type, xmlVersion: undefined}, true, undefined); + await service.saveContact({ form, docId, type, xmlVersion: undefined }, true, undefined); assert.equal(transitionsService.applyTransitions.callCount, 1); assert.deepEqual(transitionsService.applyTransitions.args[0], [[ - { + { _id: 'main1', name: 'Main', type: 'contact', contact_type: type, parent: { _id: 'parent1' }, reported_date: 1000, - contact: undefined, + contact: undefined, transitioned: true } ]]); }); - + it('should pass duplicate check when record is marked as canonical', async function () { const form = { getDataStr: () => '' }; const docId = null; const type = 'some-contact-type'; - dbGet.resolves({ }); + dbGet.resolves({}); enketoTranslationService.contactRecordToJs.returns({ doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' }, is_canonical: 'true' } }); - extractLineageService.extract.returns({ _id: 'parent1'}); + extractLineageService.extract.returns({ _id: 'parent1' }); transitionsService.applyTransitions.callsFake((docs) => { docs[0].transitioned = true; return Promise.resolve(docs); @@ -1744,23 +1732,24 @@ describe('Form service', () => { dbQuery.resolves({ offset: 0, rows: [ - { id: 'sib1', - doc: { - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - } + { + id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } }, - { - id: 'sib2', - doc: { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 } }, ], @@ -1769,17 +1758,17 @@ describe('Form service', () => { dbBulkDocs.resolves([]); clock = sinon.useFakeTimers(1000); - await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false); + await service.saveContact({ form, docId, type, xmlVersion: undefined }, false, undefined); assert.equal(transitionsService.applyTransitions.callCount, 1); assert.deepEqual(transitionsService.applyTransitions.args[0], [[ - { + { _id: 'main1', name: 'Main', type: 'contact', contact_type: type, parent: { _id: 'parent1' }, reported_date: 1000, - contact: undefined, + contact: undefined, transitioned: true, is_canonical: 'true' }