From 90ef80d2874e9d9a96ee2ee9836d68973c034866 Mon Sep 17 00:00:00 2001 From: Kenn Sippell Date: Fri, 13 Dec 2024 10:16:29 -0800 Subject: [PATCH] Foundations for Warnings System (#161) Currently, a staged place stores only the "original values" which come from the user. When code wants the corresponding "formatted value" for that propertly, it uses the validation library to format it on demand. When importing large CSV lists of CHUs/CHPs (like the Narok dataset), this results in >300,000 calls of Validation.formatSingle. This isn't that slow; but it slowed down a lot when we started autogenerating properties #47. The work happening in #20 requires more formatted of property data. Although the performance gains from this change aren't huge (below), I view these changes as a required foundation for that work lest things get even slower. This change therefore validates and formats the inputs once. The result is persisted as an IPropertyValue which contains .formatted and .original values. This adds complexity in the place datamodel, but simplifies upstream classes like RemotePlaceResolver. The interface to the Validation library has changed significantly. New interface is to create a new ValidatedPropertyValue(). Remote Place Caching Currently, the ChtApi.getPlacesWithType downloads all documents of a particular type and caches a subset of the data (id, name, lineage) as a RemotePlace. The warning system is going to need more data about these places so we can check for uniqueness (eg. unique facility codes). The doc's name information should also now be stored as "Property Value" and so this resulted in a reduced role of ChtApi (just fetches docs now), an increased role for RemotePlaceCache (it maps the doc down to a reduced set for caching), and a bit more complexity in Config. Performance Measures For Narok upload performance set (1640 CHPs) Before: 1170ms After: 601ms --- docker-local-setup.sh | 2 +- package-lock.json | 4 +- package.json | 2 +- .../create-user-managers.ts | 12 +- .../create-user-managers/ke_user_manager.json | 10 +- src/config/config-factory.ts | 2 +- src/config/index.ts | 37 ++- src/index.ts | 9 +- src/lib/cht-api.ts | 39 +-- src/lib/cht-session.ts | 2 +- src/lib/credentials-file.ts | 52 +++ src/lib/move.ts | 4 +- src/lib/remote-place-cache.ts | 59 +++- src/lib/remote-place-resolver.ts | 73 ++--- src/lib/search.ts | 17 +- src/lib/validation.ts | 205 ------------ src/liquid/place/create_form.html | 2 +- src/property-value/index.ts | 68 ++++ src/property-value/name-property-value.ts | 22 ++ .../unvalidated-property-value.ts | 20 ++ .../validated-property-values.ts | 74 +++++ src/routes/files.ts | 41 +-- src/routes/search.ts | 3 +- src/services/contact.ts | 7 +- src/services/place-factory.ts | 42 ++- src/services/place.ts | 120 +++---- src/services/upload-manager.ts | 6 +- src/services/user-payload.ts | 2 +- src/validation/index.ts | 15 + src/validation/validation.ts | 145 ++++++++ src/{lib => validation}/validator-dob.ts | 2 +- .../validator-generated.ts | 17 +- src/{lib => validation}/validator-name.ts | 10 +- src/{lib => validation}/validator-phone.ts | 2 +- src/{lib => validation}/validator-regex.ts | 6 +- .../validator-select-multiple.ts | 2 +- .../validator-select-one.ts | 2 +- src/{lib => validation}/validator-skip.ts | 2 +- src/{lib => validation}/validator-string.ts | 2 +- test/config.spec.ts | 62 ++++ test/create-user-managers.spec.ts | 18 +- test/lib/cht-session.spec.ts | 2 +- test/lib/credentials-file.spec.ts | 68 ++++ test/lib/move.spec.ts | 28 +- test/lib/remote-place-cache.spec.ts | 76 ++++- test/lib/search.spec.ts | 53 ++- test/lib/validation.spec.ts | 217 ------------ test/mocks.ts | 74 +++-- test/property-value.spec.ts | 58 ++++ test/services/place-factory.spec.ts | 309 +++++++++--------- test/services/place.spec.ts | 69 ++-- test/services/upload-manager.spec.ts | 119 +++---- test/single.csv | 2 +- test/validation.spec.ts | 206 ++++++++++++ 54 files changed, 1480 insertions(+), 1022 deletions(-) create mode 100644 src/lib/credentials-file.ts delete mode 100644 src/lib/validation.ts create mode 100644 src/property-value/index.ts create mode 100644 src/property-value/name-property-value.ts create mode 100644 src/property-value/unvalidated-property-value.ts create mode 100644 src/property-value/validated-property-values.ts create mode 100644 src/validation/index.ts create mode 100644 src/validation/validation.ts rename src/{lib => validation}/validator-dob.ts (96%) rename src/{lib => validation}/validator-generated.ts (71%) rename src/{lib => validation}/validator-name.ts (87%) rename src/{lib => validation}/validator-phone.ts (96%) rename src/{lib => validation}/validator-regex.ts (87%) rename src/{lib => validation}/validator-select-multiple.ts (97%) rename src/{lib => validation}/validator-select-one.ts (95%) rename src/{lib => validation}/validator-skip.ts (84%) rename src/{lib => validation}/validator-string.ts (89%) create mode 100644 test/config.spec.ts create mode 100644 test/lib/credentials-file.spec.ts delete mode 100644 test/lib/validation.spec.ts create mode 100644 test/property-value.spec.ts create mode 100644 test/validation.spec.ts diff --git a/docker-local-setup.sh b/docker-local-setup.sh index ca5bba0c..5fffceca 100755 --- a/docker-local-setup.sh +++ b/docker-local-setup.sh @@ -22,6 +22,6 @@ to build missing images";echo; fi echo;echo "Starting Docker Compose...";echo -CHT_USER_MANAGEMENT_IMAGE=cht-user-management:local CHT_USER_MANAGEMENT_WORKER_IMAGE=cht-user-management-worker:local docker compose up -d +CHT_USER_MANAGEMENT_IMAGE=cht-user-management:local CHT_USER_MANAGEMENT_WORKER_IMAGE=cht-user-management-worker:local docker compose up echo;echo "Server is now running at http://127.0.0.1:$EXTERNAL_PORT/login";echo diff --git a/package-lock.json b/package-lock.json index 7ff335fb..f4ab4563 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cht-user-management", - "version": "1.4.2", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cht-user-management", - "version": "1.4.2", + "version": "1.5.0", "license": "ISC", "dependencies": { "@bull-board/api": "^5.17.0", diff --git a/package.json b/package.json index 413728f8..d5fdb44d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cht-user-management", - "version": "1.4.2", + "version": "1.5.0", "main": "dist/index.js", "dependencies": { "@bull-board/api": "^5.17.0", diff --git a/scripts/create-user-managers/create-user-managers.ts b/scripts/create-user-managers/create-user-managers.ts index e9231d74..43f1c6ae 100644 --- a/scripts/create-user-managers/create-user-managers.ts +++ b/scripts/create-user-managers/create-user-managers.ts @@ -3,8 +3,10 @@ import { Command } from 'commander'; import { AuthenticationInfo, ContactType } from '../../src/config'; import { createUserWithRetries } from '../../src/lib/retry-logic'; import Place from '../../src/services/place'; -import { UserPayload } from '../../src/services/user-payload'; +import RemotePlaceCache, { RemotePlace } from '../../src/lib/remote-place-cache'; +import { PropertyValues, UnvalidatedPropertyValue } from '../../src/property-value'; import UserManager from './ke_user_manager.json'; +import { UserPayload } from '../../src/services/user-payload'; const { ChtApi } = require('../../src/lib/cht-api'); // require is needed for rewire const ChtSession = require('../../src/lib/cht-session').default; // require is needed for rewire @@ -53,8 +55,8 @@ export default async function createUserManagers(argv: string[]) { async function createUserManager(username: string, placeDocId: string, chtApi: typeof ChtApi, adminUsername: string, passwordOverride?: string) { const place = new Place(UserManagerContactType); - place.contact.properties.name = `${username} (User Manager)`; - place.userRoleProperties.role = UserManagerContactType.user_role.join(' '); + place.contact.properties.name = new UnvalidatedPropertyValue(`${username} (User Manager)`, 'name'); + place.userRoleProperties.role = new UnvalidatedPropertyValue(UserManagerContactType.user_role.join(' '), 'role'); const chtPayload = place.asChtPayload(adminUsername); chtPayload.contact.role = 'user_manager'; @@ -96,8 +98,8 @@ function parseCommandlineArguments(argv: string[]): CommandLineArgs { } async function getPlaceDocId(county: string | undefined, chtApi: typeof ChtApi) { - const counties = await chtApi.getPlacesWithType('a_county'); - const countyMatches = counties.filter((c: any) => !county || c.name === county.toLowerCase()); + const counties = await RemotePlaceCache.getPlacesWithType(chtApi, UserManagerContactType, UserManagerContactType.hierarchy[0]); + const countyMatches = counties.filter((c: RemotePlace) => !county || PropertyValues.isMatch(county, c.name)); if (countyMatches.length < 1) { throw Error(`Could not find county "${county}"`); } diff --git a/scripts/create-user-managers/ke_user_manager.json b/scripts/create-user-managers/ke_user_manager.json index 3fe7cf67..4c1b3615 100644 --- a/scripts/create-user-managers/ke_user_manager.json +++ b/scripts/create-user-managers/ke_user_manager.json @@ -4,7 +4,15 @@ "contact_type": "person", "user_role": ["user_manager", "mm-online"], "username_from_place": false, - "hierarchy": [], + "hierarchy": [{ + "type": "name", + "friendly_name": "County", + "property_name": "name", + "required": false, + "parameter": ["\\sCounty"], + "contact_type": "a_county", + "level": 0 + }], "deactivate_users_on_replace": false, "replacement_property": { "friendly_name": "Unused", diff --git a/src/config/config-factory.ts b/src/config/config-factory.ts index dc6a7169..243b865b 100644 --- a/src/config/config-factory.ts +++ b/src/config/config-factory.ts @@ -4,7 +4,7 @@ import kenyaConfig from './chis-ke'; import togoConfig from './chis-tg'; import civConfig from './chis-civ'; -const CONFIG_MAP: { [key: string]: PartnerConfig } = { +export const CONFIG_MAP: { [key: string]: PartnerConfig } = { 'CHIS-KE': kenyaConfig, 'CHIS-UG': ugandaConfig, 'CHIS-TG': togoConfig, diff --git a/src/config/index.ts b/src/config/index.ts index 69b32141..70f4df25 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,6 +1,7 @@ import _ from 'lodash'; import { ChtApi, PlacePayload } from '../lib/cht-api'; import getConfigByKey from './config-factory'; +import Validation from '../validation'; export type ConfigSystem = { domains: AuthenticationInfo[]; @@ -28,10 +29,13 @@ export type ContactType = { hint?: string; }; +const KnownContactPropertyTypes = [...Validation.getKnownContactPropertyTypes()] as const; +export type ContactPropertyType = typeof KnownContactPropertyTypes[number]; + export type HierarchyConstraint = { friendly_name: string; property_name: string; - type: string; + type: ContactPropertyType; required: boolean; parameter? : string | string[] | object; errorDescription? : string; @@ -43,7 +47,7 @@ export type HierarchyConstraint = { export type ContactProperty = { friendly_name: string; property_name: string; - type: string; + type: ContactPropertyType; required: boolean; parameter? : string | string[] | object; errorDescription? : string; @@ -55,6 +59,7 @@ export type AuthenticationInfo = { useHttp?: boolean; }; + const { CONFIG_NAME, NODE_ENV, @@ -187,6 +192,33 @@ export class Config { return _.sortBy(domains, 'friendly'); } + // TODO: Joi? Chai? + public static assertValid({ config }: PartnerConfig = partnerConfig) { + for (const contactType of config.contact_types) { + const allHierarchyProperties = [...contactType.hierarchy, contactType.replacement_property]; + const allProperties = [ + ...contactType.place_properties, + ...contactType.contact_properties, + ...allHierarchyProperties, + Config.getUserRoleConfig(contactType), + ]; + + Config.getPropertyWithName(contactType.place_properties, 'name'); + Config.getPropertyWithName(contactType.contact_properties, 'name'); + + allProperties.forEach(property => { + if (!KnownContactPropertyTypes.includes(property.type)) { + throw Error(`Unknown property type "${property.type}"`); + } + }); + + const generatedHierarchyProperties = allHierarchyProperties.filter(hierarchy => hierarchy.type === 'generated'); + if (generatedHierarchyProperties.length) { + throw Error('Hierarchy properties cannot be of type "generated"'); + } + } + } + public static getCsvTemplateColumns(placeType: string) { const placeTypeConfig = Config.getContactType(placeType); const hierarchy = Config.getHierarchyWithReplacement(placeTypeConfig); @@ -205,3 +237,4 @@ export class Config { return columns; } } + diff --git a/src/index.ts b/src/index.ts index 95a599b7..e78be9f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ require('dotenv').config(); +import { Config } from './config'; import build from './server'; import { env } from 'process'; const { @@ -6,15 +7,11 @@ const { } = process.env; const port: number = env.PORT ? parseInt(env.PORT) : 3500; +Config.assertValid(); (async () => { - const loggerConfig = { - transport: { - target: 'pino-pretty', - }, - }; const server = build({ - logger: loggerConfig, + logger: true, }); // in 1.1.0 we allowed INTERFACE to be declared in .env, but let's be diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index eb183b8f..eabafc64 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -1,8 +1,8 @@ import _ from 'lodash'; +import { AxiosInstance } from 'axios'; import ChtSession from './cht-session'; import { Config, ContactType } from '../config'; import { UserPayload } from '../services/user-payload'; -import { AxiosInstance } from 'axios'; export type PlacePayload = { name: string; @@ -18,17 +18,6 @@ export type PlacePayload = { [key: string]: any; }; -export type RemotePlace = { - id: string; - name: string; - lineage: string[]; - ambiguities?: RemotePlace[]; - - // sadly, sometimes invalid or uncreated objects "pretend" to be remote - // should reconsider this naming - type: 'remote' | 'local' | 'invalid'; -}; - export class ChtApi { public readonly chtSession: ChtSession; private axiosInstance: AxiosInstance; @@ -174,26 +163,15 @@ export class ChtApi { }; getPlacesWithType = async (placeType: string) - : Promise => { - const url = `medic/_design/medic-client/_view/contacts_by_type_freetext`; + : Promise => { + const url = `medic/_design/medic-client/_view/contacts_by_type`; const params = { - startkey: JSON.stringify([ placeType, 'name:']), - endkey: JSON.stringify([ placeType, 'name:\ufff0']), + key: JSON.stringify([placeType]), include_docs: true, }; console.log('axios.get', url, params); const resp = await this.axiosInstance.get(url, { params }); - - return resp.data.rows - .map((row: any): RemotePlace => { - const nameData = row.key[1]; - return { - id: row.id, - name: nameData.substring('name:'.length), - lineage: extractLineage(row.doc), - type: 'remote', - }; - }); + return resp.data.rows.map((row: any) => row.doc); }; getDoc = async (id: string): Promise => { @@ -228,10 +206,3 @@ function minify(doc: any): any { }; } -function extractLineage(doc: any): string[] { - if (doc?.parent?._id) { - return [doc.parent._id, ...extractLineage(doc.parent)]; - } - - return []; -} diff --git a/src/lib/cht-session.ts b/src/lib/cht-session.ts index 586ff7f1..0c604792 100644 --- a/src/lib/cht-session.ts +++ b/src/lib/cht-session.ts @@ -5,7 +5,7 @@ import { AuthenticationInfo } from '../config'; import { AxiosHeaders, AxiosInstance } from 'axios'; import axiosRetry from 'axios-retry'; import { axiosRetryConfig } from './retry-logic'; -import { RemotePlace } from './cht-api'; +import { RemotePlace } from './remote-place-cache'; const COUCH_AUTH_COOKIE_NAME = 'AuthSession='; const ADMIN_FACILITY_ID = '*'; diff --git a/src/lib/credentials-file.ts b/src/lib/credentials-file.ts new file mode 100644 index 00000000..815282bd --- /dev/null +++ b/src/lib/credentials-file.ts @@ -0,0 +1,52 @@ +import { Config, ContactType } from '../config'; +import SessionCache from '../services/session-cache'; +import { stringify } from 'csv/sync'; + +type File = { + filename: string; + content: string; +}; + +export default function getCredentialsFiles(sessionCache: SessionCache, contactTypes: ContactType[]): File[] { + const files: File[] = []; + for (const contactType of contactTypes) { + const places = sessionCache.getPlaces({ type: contactType.name }); + if (!places.length) { + continue; + } + + const rows = places.map((place) => [ + ...Object.values(place.hierarchyProperties).map(prop => prop.formatted), + place.name, + place.contact.properties.name?.formatted, + place.contact.properties.phone?.formatted, + place.userRoles.join(' '), + place.creationDetails.username, + place.creationDetails.password, + ]); + + const constraints = Config.getHierarchyWithReplacement(contactType); + const props = Object.keys(places[0].hierarchyProperties) + .map(prop => constraints.find(c => c.property_name === prop)!.friendly_name); + const columns = [ + ...props, + contactType.friendly, + 'name', + 'phone', + 'role', + 'username', + 'password', + ]; + + const content = stringify(rows, { + columns, + header: true, + }); + files.push({ + filename: `${contactType.name}.csv`, + content, + }); + } + + return files; +} diff --git a/src/lib/move.ts b/src/lib/move.ts index 4613e457..3e19db84 100644 --- a/src/lib/move.ts +++ b/src/lib/move.ts @@ -24,7 +24,7 @@ export default class MoveLib { } if (toId === fromLineage[1]?.id) { - throw Error(`Place "${fromLineage[0]?.name}" already has "${toLineage[1]?.name}" as parent`); + throw Error(`Place "${fromLineage[0]?.name.original}" already has "${toLineage[1]?.name.original}" as parent`); } const jobName = this.getJobName(fromLineage, toLineage); @@ -63,7 +63,7 @@ async function resolve(prefix: string, formData: any, contactType: ContactType, await RemotePlaceResolver.resolveOne(place, sessionCache, chtApi, { fuzz: true }); place.validate(); - const validationError = place.validationErrors && Object.keys(place.validationErrors).find(err => err.startsWith('hierarchy_')); + const validationError = place.validationErrors && Object.keys(place.validationErrors).find(err => err.startsWith(prefix)); if (validationError) { throw Error(place.validationErrors?.[validationError]); } diff --git a/src/lib/remote-place-cache.ts b/src/lib/remote-place-cache.ts index deb11f35..3bffc991 100644 --- a/src/lib/remote-place-cache.ts +++ b/src/lib/remote-place-cache.ts @@ -1,5 +1,8 @@ import Place from '../services/place'; -import { ChtApi, RemotePlace } from './cht-api'; +import { ChtApi } from './cht-api'; +import { IPropertyValue } from '../property-value'; +import { ContactType, HierarchyConstraint } from '../config'; +import { NamePropertyValue } from '../property-value/name-property-value'; type RemotePlacesByType = { [key: string]: RemotePlace[]; @@ -9,18 +12,36 @@ type RemotePlaceDatastore = { [key: string]: RemotePlacesByType; }; +export type RemotePlace = { + id: string; + name: IPropertyValue; + lineage: string[]; + ambiguities?: RemotePlace[]; + + // sadly, sometimes invalid or uncreated objects "pretend" to be remote + // should reconsider this naming + type: 'remote' | 'local' | 'invalid'; +}; + export default class RemotePlaceCache { private static cache: RemotePlaceDatastore = {}; - public static async getPlacesWithType(chtApi: ChtApi, placeType: string) + public static async getPlacesWithType(chtApi: ChtApi, contactType: ContactType, hierarchyLevel: HierarchyConstraint) : Promise { - const domainStore = await RemotePlaceCache.getDomainStore(chtApi, placeType); + const domainStore = await RemotePlaceCache.getDomainStore(chtApi, contactType, hierarchyLevel); return domainStore; } - public static async add(place: Place, chtApi: ChtApi): Promise { - const domainStore = await RemotePlaceCache.getDomainStore(chtApi, place.type.name); - domainStore.push(place.asRemotePlace()); + public static add(place: Place, chtApi: ChtApi): void { + const { domain } = chtApi.chtSession.authInfo; + const placeType = place.type.name; + + const places = RemotePlaceCache.cache[domain]?.[placeType]; + // if there is no cache existing, discard the value + // it will be fetched if needed when the cache is built + if (places) { + places.push(place.asRemotePlace()); + } } public static clear(chtApi: ChtApi, contactTypeName?: string): void { @@ -29,19 +50,19 @@ export default class RemotePlaceCache { RemotePlaceCache.cache = {}; } else if (!contactTypeName) { delete RemotePlaceCache.cache[domain]; - } else { + } else if (RemotePlaceCache.cache[domain]) { delete RemotePlaceCache.cache[domain][contactTypeName]; } } - private static async getDomainStore(chtApi: ChtApi, placeType: string) + private static async getDomainStore(chtApi: ChtApi, contactType: ContactType, hierarchyLevel: HierarchyConstraint) : Promise { const { domain } = chtApi.chtSession.authInfo; + const placeType = hierarchyLevel.contact_type; const { cache: domainCache } = RemotePlaceCache; - const places = domainCache[domain]?.[placeType]; if (!places) { - const fetchPlacesWithType = chtApi.getPlacesWithType(placeType); + const fetchPlacesWithType = RemotePlaceCache.fetchRemotePlaces(chtApi, contactType, hierarchyLevel); domainCache[domain] = { ...domainCache[domain], [placeType]: await fetchPlacesWithType, @@ -50,4 +71,22 @@ export default class RemotePlaceCache { return domainCache[domain][placeType]; } + + private static async fetchRemotePlaces(chtApi: ChtApi, contactType: ContactType, hierarchyLevel: HierarchyConstraint): Promise { + function extractLineage(doc: any): string[] { + if (doc?.parent) { + return [doc.parent._id, ...extractLineage(doc.parent)]; + } + + return []; + } + + const docs = await chtApi.getPlacesWithType(hierarchyLevel.contact_type); + return docs.map((doc: any): RemotePlace => ({ + id: doc._id, + name: new NamePropertyValue(doc.name, hierarchyLevel), + lineage: extractLineage(doc), + type: 'remote', + })); + } } diff --git a/src/lib/remote-place-resolver.ts b/src/lib/remote-place-resolver.ts index e7d10a57..ce383d56 100644 --- a/src/lib/remote-place-resolver.ts +++ b/src/lib/remote-place-resolver.ts @@ -1,10 +1,11 @@ import _ from 'lodash'; import Place from '../services/place'; +import { IPropertyValue } from '../property-value'; import SessionCache from '../services/session-cache'; -import { RemotePlace, ChtApi } from './cht-api'; -import { Config, ContactType, HierarchyConstraint } from '../config'; -import { Validation } from './validation'; -import RemotePlaceCache from './remote-place-cache'; +import { ChtApi } from './cht-api'; +import { Config, HierarchyConstraint } from '../config'; +import RemotePlaceCache, { RemotePlace } from './remote-place-cache'; +import { UnvalidatedPropertyValue } from '../property-value'; type RemotePlaceMap = { [key: string]: RemotePlace }; @@ -13,8 +14,11 @@ export type PlaceResolverOptions = { }; export default class RemotePlaceResolver { - public static readonly NoResult: RemotePlace = { id: 'na', name: 'Place Not Found', type: 'invalid', lineage: [] }; - public static readonly Multiple: RemotePlace = { id: 'multiple', name: 'multiple places', type: 'invalid', lineage: [] }; + public static readonly NoResult: RemotePlace = + { id: 'na', name: new UnvalidatedPropertyValue('Place Not Found'), type: 'invalid', lineage: [] }; + + public static readonly Multiple: RemotePlace = + { id: 'multiple', name: new UnvalidatedPropertyValue('multiple places'), type: 'invalid', lineage: [] }; public static resolve = async ( places: Place[], @@ -38,36 +42,34 @@ export default class RemotePlaceResolver { // #91 - for editing: forget previous resolution delete place.resolvedHierarchy[hierarchyLevel.level]; - if (!place.hierarchyProperties[hierarchyLevel.property_name]) { + if (!place.hierarchyProperties[hierarchyLevel.property_name]?.original) { continue; } - const fuzzFunction = getFuzzFunction(place, hierarchyLevel, place.type); const mapIdToDetails = {}; if (hierarchyLevel.level > 0) { // no replacing local places - const searchKeys = getSearchKeys(place, hierarchyLevel.property_name, fuzzFunction, false); + const searchKeys = getSearchKeys(place, hierarchyLevel.property_name); for (const key of searchKeys) { - const localResult = findLocalPlaces(key, hierarchyLevel.contact_type, sessionCache, options, fuzzFunction); + const localResult = findLocalPlaces(key, hierarchyLevel.contact_type, sessionCache, options); if (localResult) { - addKeyToMap(mapIdToDetails, key, localResult); + addKeyToMap(mapIdToDetails, key.original, localResult); } } } const placesFoundRemote = await findRemotePlacesInHierarchy(place, hierarchyLevel, chtApi); placesFoundRemote.forEach(remotePlace => { - addKeyToMap(mapIdToDetails, remotePlace.name, remotePlace); + addKeyToMap(mapIdToDetails, remotePlace.name.original, remotePlace); if (options?.fuzz) { - const alteredName = fuzzFunction(remotePlace.name); - if (remotePlace.name !== alteredName) { - addKeyToMap(mapIdToDetails, alteredName, remotePlace); + if (remotePlace.name.original !== remotePlace.name.formatted) { + addKeyToMap(mapIdToDetails, remotePlace.name.formatted, remotePlace); } } }); const placeName = place.hierarchyProperties[hierarchyLevel.property_name]; - place.resolvedHierarchy[hierarchyLevel.level] = pickFromMapOptimistic(mapIdToDetails, placeName, fuzzFunction, !!options?.fuzz); + place.resolvedHierarchy[hierarchyLevel.level] = pickFromMapOptimistic(mapIdToDetails, placeName, !!options?.fuzz); } await RemotePlaceResolver.resolveAmbiguousParent(place); @@ -109,21 +111,12 @@ export default class RemotePlaceResolver { }; } -function getFuzzFunction(place: Place, hierarchyLevel: HierarchyConstraint, contactType: ContactType) { - const fuzzingProperty = hierarchyLevel.level === 0 ? contactType.replacement_property : hierarchyLevel; - if (fuzzingProperty.type === 'generated') { - throw Error(`Invalid configuration: hierarchy properties cannot be of type "generated".`); - } - - return (val: string) => Validation.formatSingle(place, fuzzingProperty, val); -} - async function findRemotePlacesInHierarchy( place: Place, hierarchyLevel: HierarchyConstraint, chtApi: ChtApi ) : Promise { - let searchPool = await RemotePlaceCache.getPlacesWithType(chtApi, hierarchyLevel.contact_type); + let searchPool = await RemotePlaceCache.getPlacesWithType(chtApi, place.type, hierarchyLevel); searchPool = searchPool.filter(remotePlace => chtApi.chtSession.isPlaceAuthorized(remotePlace)); const topDownHierarchy = Config.getHierarchyWithReplacement(place.type, 'desc'); @@ -152,49 +145,43 @@ async function findRemotePlacesInHierarchy( return searchPool; } -function getSearchKeys(place: Place, searchPropertyName: string, fuzzFunction: (key: string) => string, fuzz: boolean) - : string[] { +function getSearchKeys(place: Place, searchPropertyName: string) + : IPropertyValue[] { const keys = []; const key = place.hierarchyProperties[searchPropertyName]; if (key) { keys.push(key); } - - if (fuzz) { - keys.push(fuzzFunction(key)); - } - - return _.uniq(keys); + + return _.uniqBy(keys, 'formatted'); } -function pickFromMapOptimistic(map: RemotePlaceMap, placeName: string, fuzzFunction: (key: string) => string, fuzz: boolean) +function pickFromMapOptimistic(map: RemotePlaceMap, placeName: IPropertyValue, fuzz: boolean) : RemotePlace | undefined { if (!placeName) { return; } - const result = map[placeName.toLowerCase()]; + const result = map[placeName.original.toLowerCase()]; if (!fuzz) { return result; } - const fuzzyName = fuzzFunction(placeName); - const fuzzyResult = map[fuzzyName.toLowerCase()]; + const fuzzyResult = map[placeName.formatted.toLowerCase()]; const [optimisticResult] = [result, fuzzyResult].filter(r => r && r.type !== 'invalid'); return optimisticResult || result || fuzzyResult || RemotePlaceResolver.NoResult; } function findLocalPlaces( - name: string, + name: IPropertyValue, type: string, sessionCache: SessionCache, - options: PlaceResolverOptions | undefined, - fuzzFunction: (key: string) => string + options: PlaceResolverOptions | undefined ): RemotePlace | undefined { - let places = sessionCache.getPlaces({ type, nameExact: name }); + let places = sessionCache.getPlaces({ type, nameExact: name.original }); if (options?.fuzz && !places.length) { - places = sessionCache.getPlaces({ type, nameExact: fuzzFunction(name) }); + places = sessionCache.getPlaces({ type, nameExact: name.formatted }); } if (places.length > 1) { diff --git a/src/lib/search.ts b/src/lib/search.ts index dee99799..14c2963c 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -1,7 +1,8 @@ import _ from 'lodash'; import SessionCache from '../services/session-cache'; -import { ChtApi, RemotePlace } from './cht-api'; -import RemotePlaceCache from './remote-place-cache'; +import { ChtApi } from './cht-api'; +import { PropertyValues } from '../property-value'; +import RemotePlaceCache, { RemotePlace } from './remote-place-cache'; import RemotePlaceResolver from './remote-place-resolver'; import { Config, ContactType, HierarchyConstraint } from '../config'; import Place from '../services/place'; @@ -23,7 +24,7 @@ export default class SearchLib { const searchResults: RemotePlace[] = _.uniqWith([ ...localResults.map(r => r.asRemotePlace()), ...remoteResults, - ], (placeA: RemotePlace, placeB: RemotePlace) => placeA.name === placeB.name && placeA.type === placeB.type); + ], (placeA: RemotePlace, placeB: RemotePlace) => placeA.name.formatted === placeB.name.formatted && placeA.type === placeB.type); if (searchResults.length === 0) { searchResults.push(RemotePlaceResolver.NoResult); @@ -56,9 +57,9 @@ async function getRemoteResults( chtApi: ChtApi, dataPrefix: string ) : Promise { - let remoteResults = (await RemotePlaceCache.getPlacesWithType(chtApi, hierarchyLevel.contact_type)) + let remoteResults = (await RemotePlaceCache.getPlacesWithType(chtApi, contactType, hierarchyLevel)) .filter(remotePlace => chtApi.chtSession.isPlaceAuthorized(remotePlace)) - .filter(place => place.name.includes(searchString)); + .filter(place => PropertyValues.includes(place.name, searchString)); const topDownHierarchy = Config.getHierarchyWithReplacement(contactType, 'desc'); for (const constrainingHierarchy of topDownHierarchy) { @@ -66,14 +67,14 @@ async function getRemoteResults( break; } - const searchStringAtLevel = formData[`${dataPrefix}${constrainingHierarchy.property_name}`]?.toLowerCase(); + const searchStringAtLevel = formData[`${dataPrefix}${constrainingHierarchy.property_name}`]; if (!searchStringAtLevel) { continue; } - const placesAtLevel = await RemotePlaceCache.getPlacesWithType(chtApi, constrainingHierarchy.contact_type); + const placesAtLevel = await RemotePlaceCache.getPlacesWithType(chtApi, contactType, constrainingHierarchy); const relevantPlaceIds = placesAtLevel - .filter(remotePlace => remotePlace.name.includes(searchStringAtLevel)) + .filter(remotePlace => PropertyValues.includes(remotePlace.name, searchStringAtLevel)) .map(remotePlace => remotePlace.id); const hierarchyIndex = constrainingHierarchy.level - hierarchyLevel.level - 1; remoteResults = remoteResults.filter(result => relevantPlaceIds.includes(result.lineage[hierarchyIndex])); diff --git a/src/lib/validation.ts b/src/lib/validation.ts deleted file mode 100644 index 031a630f..00000000 --- a/src/lib/validation.ts +++ /dev/null @@ -1,205 +0,0 @@ -import _ from 'lodash'; -import { Config, ContactProperty } from '../config'; -import Place from '../services/place'; -import RemotePlaceResolver from './remote-place-resolver'; -import { RemotePlace } from './cht-api'; - -import ValidatorDateOfBirth from './validator-dob'; -import ValidatorGenerated from './validator-generated'; -import ValidatorName from './validator-name'; -import ValidatorPhone from './validator-phone'; -import ValidatorRegex from './validator-regex'; -import ValidatorSelectMultiple from './validator-select-multiple'; -import ValidatorSelectOne from './validator-select-one'; -import ValidatorSkip from './validator-skip'; -import ValidatorString from './validator-string'; - -export type ValidationError = { - property_name: string; - description: string; -}; - -export interface IValidator { - isValid(input: string, property? : ContactProperty) : boolean | string; - format(input : string, property? : ContactProperty) : string; - get defaultError(): string; -} - -type ValidatorMap = { - [key: string]: IValidator; -}; - -const TypeValidatorMap: ValidatorMap = { - dob: new ValidatorDateOfBirth(), - generated: new ValidatorGenerated(), - name: new ValidatorName(), - none: new ValidatorSkip(), - phone: new ValidatorPhone(), - regex: new ValidatorRegex(), - string: new ValidatorString(), - select_one: new ValidatorSelectOne(), - select_multiple: new ValidatorSelectMultiple(), -}; - -export class Validation { - public static getValidationErrors(place: Place) : ValidationError[] { - const requiredColumns = Config.getRequiredColumns(place.type, place.isReplacement); - const result = [ - ...Validation.validateHierarchy(place), - ...Validation.validateProperties(place.properties, place.type.place_properties, requiredColumns, 'place_'), - ...Validation.validateProperties(place.contact.properties, place.type.contact_properties, requiredColumns, 'contact_'), - ...Validation.validateProperties(place.userRoleProperties, [Config.getUserRoleConfig(place.type)], requiredColumns, 'user_') - ]; - - return result; - } - - public static format(place: Place): void { - const doFormatting = (withGenerators: boolean) => { - const isGenerator = (property: ContactProperty) => property.type === 'generated'; - const alterAllProperties = (propertiesToAlter: ContactProperty[], objectToAlter: any) => { - for (const property of propertiesToAlter) { - if (isGenerator(property) === withGenerators) { - this.alterProperty(place, property, objectToAlter); - } - } - }; - - alterAllProperties(place.type.contact_properties, place.contact.properties); - alterAllProperties(place.type.place_properties, place.properties); - for (const hierarchy of Config.getHierarchyWithReplacement(place.type)) { - this.alterProperty(place, hierarchy, place.hierarchyProperties); - } - }; - - doFormatting(false); - doFormatting(true); - } - - public static formatSingle(place: Place, propertyMatch: ContactProperty, val: string): string { - const object = { [propertyMatch.property_name]: val }; - Validation.alterProperty(place, propertyMatch, object); - return object[propertyMatch.property_name]; - } - - private static validateHierarchy(place: Place): ValidationError[] { - const result: ValidationError[] = []; - - const hierarchy = Config.getHierarchyWithReplacement(place.type); - hierarchy.forEach((hierarchyLevel, index) => { - const data = place.hierarchyProperties[hierarchyLevel.property_name]; - - if (hierarchyLevel.level !== 0 || data) { - const isExpected = hierarchyLevel.required; - const resolution = place.resolvedHierarchy[hierarchyLevel.level]; - const isValid = resolution?.type !== 'invalid' && ( - !isExpected || - resolution?.type === 'remote' || - resolution?.type === 'local' - ); - if (!isValid) { - const levelUp = hierarchy[index + 1]?.property_name; - result.push({ - property_name: `hierarchy_${hierarchyLevel.property_name}`, - description: this.describeInvalidRemotePlace( - resolution, - hierarchyLevel.contact_type, - data, - place.hierarchyProperties[levelUp] - ), - }); - } - } - }); - - return result; - } - - private static validateProperties( - obj : any, - properties : ContactProperty[], - requiredProperties: ContactProperty[], - prefix: string - ) : ValidationError[] { - const invalid: ValidationError[] = []; - - for (const property of properties) { - const value = obj[property.property_name]; - - const isRequired = requiredProperties.some((prop) => _.isEqual(prop, property)); - const errorPropertyName = `${prefix}${property.property_name}`; - if (value === undefined && isRequired) { - invalid.push({ - property_name: errorPropertyName, - description: 'Is Required', - }); - - continue; - } - - if (value || isRequired) { - const isValid = Validation.isValid(property, value); - if (isValid === false || typeof isValid === 'string') { - invalid.push({ - property_name: errorPropertyName, - description: isValid === false ? 'Value is invalid' : isValid as string, - }); - } - } - } - - return invalid; - } - - private static isValid(property : ContactProperty, value: string) : boolean | string { - const validator = this.getValidator(property); - try { - const isValid = validator.isValid(value, property); - return isValid === false ? property.errorDescription || validator.defaultError : isValid; - } catch (e) { - const error = `Error in isValid for '${property.type}': ${e}`; - console.log(error); - return error; - } - } - - private static alterProperty(place: Place, property : ContactProperty, obj: any) { - const value = obj[property.property_name]; - const validator = this.getValidator(property); - if (validator instanceof ValidatorGenerated) { - const altered = validator.format(place, property); - obj[property.property_name] = altered; - } else if (value) { - const altered = validator.format(value, property); - obj[property.property_name] = altered; - } - } - - private static getValidator(property: ContactProperty) : IValidator { - const validator = TypeValidatorMap[property.type]; - if (!validator) { - throw Error(`unvalidatable type: '${property.friendly_name}' has type '${property.type}'`); - } - - return validator; - } - - private static describeInvalidRemotePlace( - remotePlace: RemotePlace | undefined, - friendlyType: string, - searchStr?: string, - requiredParent?: string - ): string { - if (!searchStr) { - return `Cannot find ${friendlyType} because the search string is empty`; - } - - const requiredParentSuffix = requiredParent ? ` under '${requiredParent}'` : ''; - if (RemotePlaceResolver.Multiple.id === remotePlace?.id) { - const ambiguityDetails = JSON.stringify(remotePlace.ambiguities?.map(a => a.id)); - return `Found multiple ${friendlyType}s matching '${searchStr}'${requiredParentSuffix} ${ambiguityDetails}`; - } - - return `Cannot find '${friendlyType}' matching '${searchStr}'${requiredParentSuffix}`; - } -} diff --git a/src/liquid/place/create_form.html b/src/liquid/place/create_form.html index 71a9bcad..23fa16dd 100644 --- a/src/liquid/place/create_form.html +++ b/src/liquid/place/create_form.html @@ -5,7 +5,7 @@
{% for hierarchy in hierarchy %} - {% if hierarchy.level != 0 or op == 'replace' or place.hierarchyProperties.replacement %} + {% if hierarchy.level != 0 or op == 'replace' or place.hierarchyProperties.replacement.original %} {% include "components/search_input.html" type=contactType.name diff --git a/src/property-value/index.ts b/src/property-value/index.ts new file mode 100644 index 00000000..0f92a247 --- /dev/null +++ b/src/property-value/index.ts @@ -0,0 +1,68 @@ +import { HierarchyPropertyValue, ContactPropertyValue } from './validated-property-values'; +import { NamePropertyValue } from './name-property-value'; +import UnvalidatedPropertyValue from './unvalidated-property-value'; + +export class PropertyValues { + public static includes(searchWithin?: string | IPropertyValue, searchFor?: string | IPropertyValue): boolean { + const insensitiveMatch = (within: string, toFind: string) => within.includes(toFind); + return PropertyValues.doIt(insensitiveMatch, searchWithin, searchFor); + } + + public static isMatch(searchWithin?: string | IPropertyValue, searchFor?: string | IPropertyValue): boolean { + const insensitiveMatch = (within: string, toFind: string) => within === toFind; + return PropertyValues.doIt(insensitiveMatch, searchWithin, searchFor); + } + + private static doIt( + comparator: (a: string, b: string) => boolean, + a?: string | IPropertyValue, + b?: string | IPropertyValue, + ): boolean { + if (a === undefined || b === undefined) { + return false; + } + + const normalize = (str: string) => str.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase(); + const valueAsArray = (val: string | IPropertyValue): string[] => { + const values = typeof val === 'string' ? [val] : [val.formatted, val.original]; + return values.map(normalize); + }; + + const withinArray: string[] = valueAsArray(a); + const forArray: string[] = valueAsArray(b); + + + return withinArray.some(within => forArray.some(forX => comparator(within, forX))); + } +} + +export interface IPropertyValue { + get original(): string; + get formatted(): string; + get propertyNameWithPrefix(): string; + + validationError?: string; + + validate(): void; + toString(): string; +} + +/** + * For validating levels of the hierarchy +*/ +export { HierarchyPropertyValue }; + +/** + * For validating ContactProperty values + */ +export { ContactPropertyValue }; + +/** + * When storing a Name, and don't need access to an underlying Place + */ +export { NamePropertyValue }; + +/** + * When storing something that doesn't need validation + */ +export { UnvalidatedPropertyValue }; diff --git a/src/property-value/name-property-value.ts b/src/property-value/name-property-value.ts new file mode 100644 index 00000000..c9890865 --- /dev/null +++ b/src/property-value/name-property-value.ts @@ -0,0 +1,22 @@ +import { ContactProperty } from '../config'; +import { IPropertyValue } from '.'; +import Validation from '../validation'; + +export class NamePropertyValue implements IPropertyValue { + public original: string; + public formatted: string; + public propertyNameWithPrefix: string; + public validationError?: string; + + constructor(value: string, nameContactProperty: ContactProperty) { + this.original = value; + this.propertyNameWithPrefix = `place_name`; + this.formatted = Validation.formatDuringInitialization(nameContactProperty, value); + } + + public validate(): void {} + + public toString(): string { + return this.formatted; + } +} diff --git a/src/property-value/unvalidated-property-value.ts b/src/property-value/unvalidated-property-value.ts new file mode 100644 index 00000000..fca4739b --- /dev/null +++ b/src/property-value/unvalidated-property-value.ts @@ -0,0 +1,20 @@ +import { IPropertyValue } from '.'; + +export default class UnvalidatedPropertyValue implements IPropertyValue { + public original: string; + public formatted: string; + public propertyNameWithPrefix: string; + public validationError?: string; + + constructor(value: string, propertyNameWithPrefix: string = value) { + this.original = value; + this.formatted = value; + this.propertyNameWithPrefix = propertyNameWithPrefix; + } + + public validate(): void {} + + public toString(): string { + return this.formatted; + } +} diff --git a/src/property-value/validated-property-values.ts b/src/property-value/validated-property-values.ts new file mode 100644 index 00000000..49dd7957 --- /dev/null +++ b/src/property-value/validated-property-values.ts @@ -0,0 +1,74 @@ +import { Config, ContactProperty, HierarchyConstraint } from '../config'; +import { IPropertyValue } from '.'; +import Place from '../services/place'; +import Validation from '../validation'; + +abstract class AbstractPropertyValue implements IPropertyValue { + public readonly original: string; + protected readonly place: Place; + protected readonly property: ContactProperty; + private readonly propertyPrefix: string; + + protected formattedValue: string; + private validationErrorValue?: string; + + constructor(place: Place, property: ContactProperty, prefix: string, value: string) { + this.original = value; + this.place = place; + this.property = property; + this.propertyPrefix = prefix; + this.formattedValue = Validation.formatDuringInitialization(this.property, value); + } + + public validate(): void { + this.validationErrorValue = this.doValidation(); + } + + public get propertyNameWithPrefix(): string { + return this.propertyPrefix + this.property.property_name; + } + + public get formatted(): string { + return this.formattedValue; + } + + public get validationError(): string | undefined { + return this.validationErrorValue; + } + + public toString(): string { + return this.formatted; + } + + protected abstract doValidation(): string | undefined; +} + +export class ContactPropertyValue extends AbstractPropertyValue { + constructor(place: Place, property: ContactProperty, prefix: string, value: string) { + super(place, property, prefix, value); + } + + protected override doValidation(): string | undefined { + const requiredProperties = Config.getRequiredColumns(this.place.type, this.place.isReplacement); + const hasGeneratedProperty = this.property.type === 'generated'; + + let valueToValidate = this.original; + if (hasGeneratedProperty) { + this.formattedValue = Validation.generateAfterInitialization(this.place, this.property) || ''; + valueToValidate = this.formattedValue; + } + + return Validation.validateProperty(valueToValidate, this.property, requiredProperties); + } +} + +export class HierarchyPropertyValue extends AbstractPropertyValue { + constructor(place: Place, property: HierarchyConstraint, prefix: string, value: string) { + super(place, property, prefix, value); + } + + protected override doValidation(): string | undefined { + return Validation.validateHierarchyLevel(this.place, this.property as HierarchyConstraint); + } +} + diff --git a/src/routes/files.ts b/src/routes/files.ts index 86b3236a..44c16f6e 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -1,8 +1,10 @@ import { FastifyInstance } from 'fastify'; +import JSZip from 'jszip'; import { stringify } from 'csv/sync'; + import { Config } from '../config'; +import getCredentialsFiles from '../lib/credentials-file'; import SessionCache from '../services/session-cache'; -import JSZip from 'jszip'; export default async function files(fastify: FastifyInstance) { fastify.get('/files/template/:placeType', async (req) => { @@ -14,40 +16,13 @@ export default async function files(fastify: FastifyInstance) { fastify.get('/files/credentials', async (req, reply) => { const sessionCache: SessionCache = req.sessionCache; + const zip = new JSZip(); - for (const contactType of Config.contactTypes()) { - const places = sessionCache.getPlaces({ type: contactType.name }); - if (!places.length) { - continue; - } - const rows = places.map((place) => [ - ...Object.values(place.hierarchyProperties), - place.name, - place.contact.properties.name, - place.contact.properties.phone, - place.userRoles.join(' '), - place.creationDetails.username, - place.creationDetails.password, - ]); - const constraints = Config.getHierarchyWithReplacement(contactType); - const props = Object.keys(places[0].hierarchyProperties).map(prop => constraints.find(c => c.property_name === prop)!.friendly_name); - const columns = [ - ...props, - contactType.friendly, - 'name', - 'phone', - 'role', - 'username', - 'password', - ]; - zip.file( - `${contactType.name}.csv`, - stringify(rows, { - columns, - header: true, - }) - ); + const files = getCredentialsFiles(sessionCache, Config.contactTypes()); + for (const file of files) { + zip.file(file.filename, file.content); } + reply.header('Content-Disposition', `attachment; filename="${Date.now()}_${req.chtSession.authInfo.friendly}_users.zip"`); return zip.generateNodeStream(); }); diff --git a/src/routes/search.ts b/src/routes/search.ts index 2ad7a57e..3c37eefc 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -1,7 +1,8 @@ import { FastifyInstance } from 'fastify'; import { Config } from '../config'; -import { ChtApi, RemotePlace } from '../lib/cht-api'; +import { ChtApi } from '../lib/cht-api'; +import { RemotePlace } from '../lib/remote-place-cache'; import SessionCache from '../services/session-cache'; import SearchLib from '../lib/search'; diff --git a/src/services/contact.ts b/src/services/contact.ts index 437cd5ea..04d923e0 100644 --- a/src/services/contact.ts +++ b/src/services/contact.ts @@ -1,12 +1,11 @@ import { v4 as uuidv4 } from 'uuid'; import { Config, ContactType } from '../config'; +import { FormattedPropertyCollection } from './place'; export default class Contact { public id: string; public type: ContactType; - public properties: { - [key: string]: any; - }; + public properties: FormattedPropertyCollection; constructor(type: ContactType) { this.id = uuidv4(); @@ -16,6 +15,6 @@ export default class Contact { public get name() : string { const nameProperty = Config.getPropertyWithName(this.type.contact_properties, 'name'); - return this.properties[nameProperty.property_name]; + return this.properties[nameProperty.property_name]?.formatted; } } diff --git a/src/services/place-factory.ts b/src/services/place-factory.ts index 74f76c82..9dd975cd 100644 --- a/src/services/place-factory.ts +++ b/src/services/place-factory.ts @@ -1,19 +1,18 @@ import { parse } from 'csv'; import { ChtApi } from '../lib/cht-api'; -import { Config, ContactType } from '../config'; -import Place from './place'; +import { Config, ContactProperty, ContactType } from '../config'; +import Place, { FormattedPropertyCollection } from './place'; import SessionCache from './session-cache'; import RemotePlaceResolver from '../lib/remote-place-resolver'; +import { HierarchyPropertyValue, ContactPropertyValue, IPropertyValue } from '../property-value'; export default class PlaceFactory { public static async createFromCsv(csvBuffer: Buffer, contactType: ContactType, sessionCache: SessionCache, chtApi: ChtApi) : Promise { const places = await PlaceFactory.loadPlacesFromCsv(csvBuffer, contactType); - const validateAll = () => places.forEach(p => p.validate()); - await RemotePlaceResolver.resolve(places, sessionCache, chtApi, { fuzz: true }); - validateAll(); + places.forEach(place => place.validate()); sessionCache.savePlaces(...places); return places; } @@ -22,7 +21,6 @@ export default class PlaceFactory { : Promise => { const place = new Place(contactType); place.setPropertiesFromFormData(formData, 'hierarchy_'); - await RemotePlaceResolver.resolveOne(place, sessionCache, chtApi, { fuzz: true }); place.validate(); sessionCache.savePlaces(place); @@ -58,24 +56,36 @@ export default class PlaceFactory { csvColumns.push(...row); } else { const place = new Place(contactType); - for (const placeProperty of contactType.place_properties) { - place.properties[placeProperty.property_name] = row[csvColumns.indexOf(placeProperty.friendly_name)]; + const lookupPropertyAndCreateValue = ( + writeTo: FormattedPropertyCollection, + contactProperty: ContactProperty, + createFromValue: (value: string) => IPropertyValue + ) => { + const value = row[csvColumns.indexOf(contactProperty.friendly_name)] || ''; + const validatedProperty = createFromValue(value); + writeTo[contactProperty.property_name] = validatedProperty; + }; + + for (const hierarchyConstraint of Config.getHierarchyWithReplacement(contactType)) { + const createFromValue = (value: string) => new HierarchyPropertyValue(place, hierarchyConstraint, 'hierarchy_', value); + lookupPropertyAndCreateValue(place.hierarchyProperties, hierarchyConstraint, createFromValue); } - for (const contactProperty of contactType.contact_properties) { - place.contact.properties[contactProperty.property_name] = row[csvColumns.indexOf(contactProperty.friendly_name)]; + // place properties must be read after hierarchy constraints since validation logic is dependent on isReplacement + for (const placeProperty of contactType.place_properties) { + const createFromValue = (value: string) => new ContactPropertyValue(place, placeProperty, 'place_', value); + lookupPropertyAndCreateValue(place.properties, placeProperty, createFromValue); } - for (const hierarchyConstraint of Config.getHierarchyWithReplacement(contactType)) { - const columnIndex = csvColumns.indexOf(hierarchyConstraint.friendly_name); - place.hierarchyProperties[hierarchyConstraint.property_name] = row[columnIndex]; + for (const contactProperty of contactType.contact_properties) { + const createFromValue = (value: string) => new ContactPropertyValue(place, contactProperty, 'contact_', value); + lookupPropertyAndCreateValue(place.contact.properties, contactProperty, createFromValue); } if (Config.hasMultipleRoles(contactType)) { const userRoleProperty = Config.getUserRoleConfig(contactType); - place.userRoleProperties[userRoleProperty.property_name] = row[ - csvColumns.indexOf(userRoleProperty.friendly_name) - ]; + const createFromValue = (value: string) => new ContactPropertyValue(place, userRoleProperty, 'user_', value); + lookupPropertyAndCreateValue(place.userRoleProperties, userRoleProperty, createFromValue); } places.push(place); diff --git a/src/services/place.ts b/src/services/place.ts index b4de42f2..ebf9d1be 100644 --- a/src/services/place.ts +++ b/src/services/place.ts @@ -1,13 +1,19 @@ -import _ from 'lodash'; import Contact from './contact'; import { v4 as uuidv4 } from 'uuid'; import { Config, ContactProperty, ContactType } from '../config'; -import { PlacePayload, RemotePlace } from '../lib/cht-api'; -import { Validation } from '../lib/validation'; +import { IPropertyValue } from '../property-value'; +import { PlacePayload } from '../lib/cht-api'; // can't use package.json because of rootDir in ts import { version as appVersion } from '../package.json'; import RemotePlaceResolver from '../lib/remote-place-resolver'; +import { HierarchyPropertyValue, ContactPropertyValue } from '../property-value'; +import { RemotePlace } from '../lib/remote-place-cache'; +import { NamePropertyValue } from '../property-value/name-property-value'; + +export type FormattedPropertyCollection = { + [key: string]: IPropertyValue; +}; export type UserCreationDetails = { username?: string; @@ -36,20 +42,9 @@ export default class Place { public readonly creationDetails : UserCreationDetails = {}; public readonly resolvedHierarchy: (RemotePlace | undefined)[]; - public properties: { - name?: string; - [key: string]: any; - }; - - public hierarchyProperties: { - PARENT?: string; - replacement?: string; - [key: string]: any; - }; - - public userRoleProperties: { - [key: string]: any; - }; + public properties: FormattedPropertyCollection; + public hierarchyProperties: FormattedPropertyCollection; + public userRoleProperties: FormattedPropertyCollection; public state : PlaceUploadState; @@ -72,40 +67,43 @@ export default class Place { FormData for a place has the expected format `place_${property_name}`. */ public setPropertiesFromFormData(formData: any, hierarchyPrefix: string): void { - const getPropertySetWithPrefix = (expectedProperties: ContactProperty[], prefix: string): any => { - const propertiesInDataFormat = expectedProperties.map(p => prefix + p.property_name); - const relevantData = _.pick(formData, propertiesInDataFormat); - return Object.keys(relevantData).reduce((agg, key) => { - const keyWithoutPrefix = key.substring(prefix.length); - return { ...agg, [keyWithoutPrefix]: relevantData[key] }; - }, {}); + const getPropertySetWithPrefix = (expectedProperties: ContactProperty[], prefix: string): FormattedPropertyCollection => { + const result: FormattedPropertyCollection = {}; + for (const property of expectedProperties) { + const dataFormat = prefix + property.property_name; + result[property.property_name] = new ContactPropertyValue(this, property, prefix, formData[dataFormat]); + } + return result; }; + for (const hierarchyLevel of Config.getHierarchyWithReplacement(this.type)) { + const propertyName = hierarchyLevel.property_name; + const hierarchyValue = formData[`${hierarchyPrefix}${propertyName}`] ?? ''; + + // validation of hierachies requires RemotePlaceResolver to do its thing + // at this point; these may report errors but that's ok as long as hierarchy properties are revalidated later + this.hierarchyProperties[propertyName] = new HierarchyPropertyValue(this, hierarchyLevel, hierarchyPrefix, hierarchyValue); + } + + // place properties must be set after hierarchy constraints since validation logic is dependent on isReplacement this.properties = { ...this.properties, ...getPropertySetWithPrefix(this.type.place_properties, PLACE_PREFIX), }; + this.contact.properties = { ...this.contact.properties, ...getPropertySetWithPrefix(this.type.contact_properties, CONTACT_PREFIX), }; - for (const hierarchyLevel of Config.getHierarchyWithReplacement(this.type)) { - const propertyName = hierarchyLevel.property_name; - this.hierarchyProperties[propertyName] = formData[`${hierarchyPrefix}${propertyName}`] ?? ''; - } - if (Config.hasMultipleRoles(this.type)) { const userRoleConfig = Config.getUserRoleConfig(this.type); const propertyName = userRoleConfig.property_name; const roleFormData = formData[`${USER_PREFIX}${propertyName}`]; // When multiple are selected, the form data is an array - if (Array.isArray(roleFormData)) { - this.userRoleProperties[propertyName] = roleFormData.join(' '); - } else { - this.userRoleProperties[propertyName] = roleFormData; - } + const userRoleValue = Array.isArray(roleFormData) ? roleFormData.join(' ') : roleFormData; + this.userRoleProperties[propertyName] = new ContactPropertyValue(this, userRoleConfig, USER_PREFIX, userRoleValue); } } @@ -115,11 +113,11 @@ export default class Place { * To keep views simple and provide default values when editing, we can express a form in its form data */ public asFormData(hierarchyPrefix: string): any { - const addPrefixToPropertySet = (properties: any, prefix: string): any => { + const addPrefixToPropertySet = (properties: FormattedPropertyCollection, prefix: string): any => { const result: any = {}; for (const key of Object.keys(properties)) { const keyWithPrefix: string = prefix + key; - result[keyWithPrefix] = properties[key]; + result[keyWithPrefix] = properties[key].original; } return result; @@ -138,16 +136,12 @@ export default class Place { tool: `cht-user-management-${appVersion}`, username: creator, created_time: Date.now(), - replacement: this.resolvedHierarchy[0], + replacement: this.resolvedHierarchy[0]?.name.formatted, }; - const filteredProperties = (properties: any) => { - if (!this.isReplacement) { - return properties; - } - + const filteredProperties = (properties: FormattedPropertyCollection) => { return Object.keys(properties).reduce((agg: any, key: string) => { - const value = properties[key]; + const value = properties[key]?.formatted; if (value !== undefined && value !== '') { agg[key] = value; } @@ -183,11 +177,6 @@ export default class Place { } public asRemotePlace() : RemotePlace { - const isHierarchyValid = !this.resolvedHierarchy.find(h => h?.type === 'invalid'); - if (!isHierarchyValid) { - throw Error('Cannot call asRemotePlace on place with invalid hierarchy'); - } - let lastKnownHierarchy = this.resolvedHierarchy.find(h => h) || RemotePlaceResolver.NoResult; let lastKnownIndex = 0; @@ -203,27 +192,42 @@ export default class Place { } } + const nameProperty = Config.getPropertyWithName(this.type.place_properties, 'name'); return { id: this.id, - name: this.name, + name: new NamePropertyValue(this.name, nameProperty), type: this.isCreated ? 'remote' : 'local', lineage, }; } public validate(): void { - Validation.format(this); - - const errors = Validation.getValidationErrors(this); + const validateCollection = (collection: FormattedPropertyCollection) => Object.values(collection).forEach(prop => prop.validate()); + // hierarchy properties need to revalidation after resolution + validateCollection(this.hierarchyProperties); + // contact properties need to be revalidated after generation + validateCollection(this.properties); + validateCollection(this.contact.properties); + validateCollection(this.userRoleProperties); + + const extractErrorsFromCollection = (properties: FormattedPropertyCollection) => Object.values(properties).filter(prop => prop.validationError); + const propertiesWithErrors: IPropertyValue[] = [ + ...extractErrorsFromCollection(this.properties), + ...extractErrorsFromCollection(this.contact.properties), + ...extractErrorsFromCollection(this.userRoleProperties), + ...extractErrorsFromCollection(this.hierarchyProperties), + ]; + this.validationErrors = {}; - for (const error of errors) { - this.validationErrors[error.property_name] = error.description; + for (const property of propertiesWithErrors) { + this.validationErrors[property.propertyNameWithPrefix] = property.validationError as string; } } public generateUsername(): string { const propertySource = this.type.username_from_place ? this.properties : this.contact.properties; - let username = propertySource.name || this.hierarchyProperties.replacement; // if name is not present, it must be a replacement + // if name is not present, it must be a replacement + let username = propertySource.name?.formatted || this.hierarchyProperties.replacement?.formatted; username = username ?.replace(/[ ]/g, '_') ?.replace(/[^a-zA-Z0-9_]/g, '') @@ -248,7 +252,7 @@ export default class Place { throw Error(`Place role data is required when multiple roles are available.`); } - return roles.split(' ').map((role: string) => role.trim()).filter(Boolean); + return roles.formatted.split(' ').map((role: string) => role.trim()).filter(Boolean); } public get hasValidationErrors() : boolean { @@ -261,11 +265,11 @@ export default class Place { public get name() : string { const nameProperty = Config.getPropertyWithName(this.type.place_properties, 'name'); - return this.properties[nameProperty.property_name]; + return this.properties[nameProperty.property_name]?.formatted; } public get isReplacement(): boolean { - return !!this.hierarchyProperties.replacement; + return !!this.hierarchyProperties.replacement?.original; } public get isCreated(): boolean { diff --git a/src/services/upload-manager.ts b/src/services/upload-manager.ts index 01c760ed..c07bb701 100644 --- a/src/services/upload-manager.ts +++ b/src/services/upload-manager.ts @@ -43,7 +43,7 @@ export class UploadManager extends EventEmitter { try { const uploader: Uploader = pickUploader(place, chtApi); const payload = place.asChtPayload(chtApi.chtSession.username); - await Config.mutate(payload, chtApi, !!place.properties.replacement); + await Config.mutate(payload, chtApi, place.isReplacement); if (!place.creationDetails.contactId) { const contactId = await uploader.handleContact(payload); @@ -69,7 +69,7 @@ export class UploadManager extends EventEmitter { place.creationDetails.password = password; } - await RemotePlaceCache.add(place, chtApi); + RemotePlaceCache.add(place, chtApi); delete place.uploadError; console.log(`successfully created ${JSON.stringify(place.creationDetails)}`); @@ -110,7 +110,7 @@ function getErrorDetails(err: any) { } function pickUploader(place: Place, chtApi: ChtApi): Uploader { - if (!place.hierarchyProperties.replacement) { + if (!place.hierarchyProperties.replacement.original) { return new UploadNewPlace(chtApi); } diff --git a/src/services/user-payload.ts b/src/services/user-payload.ts index fb525cdf..19a73163 100644 --- a/src/services/user-payload.ts +++ b/src/services/user-payload.ts @@ -17,7 +17,7 @@ export class UserPayload { this.place = placeId; this.contact = contactId; this.fullname = place.contact.name; - this.phone = place.contact.properties.phone; // best guess + this.phone = place.contact.properties.phone?.formatted; // best guess } public regeneratePassword(): void { diff --git a/src/validation/index.ts b/src/validation/index.ts new file mode 100644 index 00000000..69d0c658 --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,15 @@ +import { ContactProperty } from '../config'; +import { Validation } from './validation'; + +export type ValidationError = { + property_name: string; + description: string; +}; + +export interface IValidator { + isValid(input: string, property? : ContactProperty) : boolean | string; + format(input : string, property? : ContactProperty) : string; + get defaultError(): string; +} + +export default Validation; diff --git a/src/validation/validation.ts b/src/validation/validation.ts new file mode 100644 index 00000000..ccea98a8 --- /dev/null +++ b/src/validation/validation.ts @@ -0,0 +1,145 @@ +import _ from 'lodash'; +import { Config, ContactProperty, HierarchyConstraint } from '../config'; +import { IValidator } from '.'; +import Place from '../services/place'; +import RemotePlaceResolver from '../lib/remote-place-resolver'; + +import ValidatorDateOfBirth from './validator-dob'; +import ValidatorGenerated from './validator-generated'; +import ValidatorName from './validator-name'; +import ValidatorPhone from './validator-phone'; +import ValidatorRegex from './validator-regex'; +import ValidatorSelectMultiple from './validator-select-multiple'; +import ValidatorSelectOne from './validator-select-one'; +import ValidatorSkip from './validator-skip'; +import ValidatorString from './validator-string'; +import { RemotePlace } from '../lib/remote-place-cache'; + +type ValidatorMap = { + [key: string]: IValidator; +}; + +const TypeValidatorMap: ValidatorMap = { + dob: new ValidatorDateOfBirth(), + generated: new ValidatorGenerated(), + name: new ValidatorName(), + none: new ValidatorSkip(), + phone: new ValidatorPhone(), + regex: new ValidatorRegex(), + string: new ValidatorString(), + select_one: new ValidatorSelectOne(), + select_multiple: new ValidatorSelectMultiple(), +}; + +export class Validation { + public static validateProperty( + value: string, + property : ContactProperty, + requiredProperties: ContactProperty[] + ) : string | undefined { + const isRequired = requiredProperties.some((prop) => _.isEqual(prop, property)); + if (!value && isRequired) { + return 'Is Required'; + } + + if (value || isRequired) { + const isValid = Validation.isValid(property, value); + if (isValid === false || typeof isValid === 'string') { + return isValid === false ? 'Value is invalid' : isValid as string; + } + } + } + + public static formatDuringInitialization(property: ContactProperty, value: string): string { + const validator = this.getValidator(property); + if (!(validator instanceof ValidatorGenerated) && value) { + return validator.format(value, property); + } + + return value; + } + + public static generateAfterInitialization(place: Place, property: ContactProperty): string | undefined { + const validator = this.getValidator(property); + if (validator instanceof ValidatorGenerated) { + return validator.format(place, property); + } + + return; + } + + public static validateHierarchyLevel(place: Place, hierarchyLevel: HierarchyConstraint): string | undefined { + const hierarchy = Config.getHierarchyWithReplacement(place.type); + const data = place.hierarchyProperties[hierarchyLevel.property_name]; + + if (hierarchyLevel.level !== 0 || data?.formatted) { + const isExpected = hierarchyLevel.required; + const resolution = place.resolvedHierarchy[hierarchyLevel.level]; + const isValid = resolution?.type !== 'invalid' && ( + !isExpected || + resolution?.type === 'remote' || + resolution?.type === 'local' + ); + if (!isValid) { + const index = hierarchy.findIndex(h => h.level === hierarchyLevel.level); + if (index < 0) { + throw Error('Failed to find hierachy level'); + } + + const levelUp = hierarchy[index + 1]?.property_name; + const error = this.describeInvalidRemotePlace( + resolution, + hierarchyLevel.contact_type, + data?.original, + place.hierarchyProperties[levelUp]?.original + ); + + return error; + } + } + } + + public static getKnownContactPropertyTypes(): string[] { + return Object.keys(TypeValidatorMap); + } + + private static isValid(property : ContactProperty, value: string) : boolean | string { + const validator = this.getValidator(property); + try { + const isValid = validator.isValid(value, property); + return isValid === false ? property.errorDescription || validator.defaultError : isValid; + } catch (e) { + const error = `Error in isValid for '${property.type}': ${e}`; + console.log(error); + return error; + } + } + + private static getValidator(property: ContactProperty) : IValidator { + const validator = TypeValidatorMap[property.type]; + if (!validator) { + throw Error(`unvalidatable type: '${property.friendly_name}' has type '${property.type}'`); + } + + return validator; + } + + private static describeInvalidRemotePlace( + remotePlace: RemotePlace | undefined, + friendlyType: string, + searchStr?: string, + requiredParent?: string + ): string { + if (!searchStr) { + return `Cannot find ${friendlyType} because the search string is empty`; + } + + const requiredParentSuffix = requiredParent ? ` under '${requiredParent}'` : ''; + if (RemotePlaceResolver.Multiple.id === remotePlace?.id) { + const ambiguityDetails = JSON.stringify(remotePlace.ambiguities?.map(a => a.id)); + return `Found multiple ${friendlyType}s matching '${searchStr}'${requiredParentSuffix} ${ambiguityDetails}`; + } + + return `Cannot find '${friendlyType}' matching '${searchStr}'${requiredParentSuffix}`; + } +} diff --git a/src/lib/validator-dob.ts b/src/validation/validator-dob.ts similarity index 96% rename from src/lib/validator-dob.ts rename to src/validation/validator-dob.ts index 48a4cbf4..278c9c05 100644 --- a/src/lib/validator-dob.ts +++ b/src/validation/validator-dob.ts @@ -1,5 +1,5 @@ import { DateTime } from 'luxon'; -import { IValidator } from './validation'; +import { IValidator } from '.'; export default class ValidatorDateOfBirth implements IValidator { isValid(input: string) : boolean { diff --git a/src/lib/validator-generated.ts b/src/validation/validator-generated.ts similarity index 71% rename from src/lib/validator-generated.ts rename to src/validation/validator-generated.ts index 32663680..b70bd83b 100644 --- a/src/lib/validator-generated.ts +++ b/src/validation/validator-generated.ts @@ -1,7 +1,7 @@ import { Liquid } from 'liquidjs'; -import { IValidator } from './validation'; +import { IValidator } from '.'; import { ContactProperty } from '../config'; -import Place from '../services/place'; +import Place, { FormattedPropertyCollection } from '../services/place'; const engine = new Liquid({ strictVariables: false, @@ -25,10 +25,17 @@ export default class ValidatorGenerated implements IValidator { } const place:Place = input; + const mapToFormatted = (collection: FormattedPropertyCollection) => { + return Object.keys(collection).reduce((agg: any, key: string) => { + agg[key] = collection[key].formatted; + return agg; + }, {}); + }; + const generationScope: GeneratorScope = { - place: place.properties, - contact: place.contact.properties, - lineage: place.hierarchyProperties, + place: mapToFormatted(place.properties), + contact: mapToFormatted(place.contact.properties), + lineage: mapToFormatted(place.hierarchyProperties), }; const parameter = this.getParameter(property); diff --git a/src/lib/validator-name.ts b/src/validation/validator-name.ts similarity index 87% rename from src/lib/validator-name.ts rename to src/validation/validator-name.ts index 5293a8d8..b518d197 100644 --- a/src/lib/validator-name.ts +++ b/src/validation/validator-name.ts @@ -1,5 +1,5 @@ import { ContactProperty } from '../config'; -import { IValidator } from './validation'; +import { IValidator } from '.'; import ValidatorString from './validator-string'; export default class ValidatorName implements IValidator { @@ -15,20 +15,20 @@ export default class ValidatorName implements IValidator { format(input : string, property : ContactProperty) : string { input = input.replace(/\./g, ' '); input = input.replace(/\//g, ' / '); - let toAlter = input; + let toFormat = input; if (property.parameter) { if (!Array.isArray(property.parameter)) { throw Error(`property with type "name": parameter should be an array`); } - toAlter = property.parameter.reduce((agg, toRemove) => { + toFormat = property.parameter.reduce((agg, toRemove) => { const regex = new RegExp(toRemove, 'ig'); return agg.replace(regex, ''); - }, toAlter); + }, toFormat); } const validatorStr = new ValidatorString(); - return this.titleCase(validatorStr.format(toAlter)); + return this.titleCase(validatorStr.format(toFormat)); } get defaultError(): string { diff --git a/src/lib/validator-phone.ts b/src/validation/validator-phone.ts similarity index 96% rename from src/lib/validator-phone.ts rename to src/validation/validator-phone.ts index 40e2896a..a0a9eb1c 100644 --- a/src/lib/validator-phone.ts +++ b/src/validation/validator-phone.ts @@ -1,7 +1,7 @@ import { CountryCode, parsePhoneNumber, isValidNumberForRegion } from 'libphonenumber-js'; import { ContactProperty } from '../config'; -import { IValidator } from './validation'; +import { IValidator } from '.'; export default class ValidatorPhone implements IValidator { isValid(input: string, property : ContactProperty) : boolean | string { diff --git a/src/lib/validator-regex.ts b/src/validation/validator-regex.ts similarity index 87% rename from src/lib/validator-regex.ts rename to src/validation/validator-regex.ts index be49e29e..4c8a0ee5 100644 --- a/src/lib/validator-regex.ts +++ b/src/validation/validator-regex.ts @@ -1,5 +1,5 @@ import { ContactProperty } from '../config'; -import { IValidator } from './validation'; +import { IValidator } from '.'; import ValidatorString from './validator-string'; @@ -15,8 +15,8 @@ export default class ValidatorRegex implements IValidator { const regex = new RegExp(property.parameter.toString()); const validatorStr = new ValidatorString(); - const altered = validatorStr.format(input); - const match = altered.match(regex); + const formatted = validatorStr.format(input); + const match = formatted.match(regex); return !!match && match.length > 0; } diff --git a/src/lib/validator-select-multiple.ts b/src/validation/validator-select-multiple.ts similarity index 97% rename from src/lib/validator-select-multiple.ts rename to src/validation/validator-select-multiple.ts index a423c240..aea069d8 100644 --- a/src/lib/validator-select-multiple.ts +++ b/src/validation/validator-select-multiple.ts @@ -1,5 +1,5 @@ import {ContactProperty} from '../config'; -import {IValidator} from './validation'; +import { IValidator } from '.'; import ValidatorString from './validator-string'; import ValidatorSelectOne from './validator-select-one'; diff --git a/src/lib/validator-select-one.ts b/src/validation/validator-select-one.ts similarity index 95% rename from src/lib/validator-select-one.ts rename to src/validation/validator-select-one.ts index 69d943d9..55b1aa3b 100644 --- a/src/lib/validator-select-one.ts +++ b/src/validation/validator-select-one.ts @@ -1,5 +1,5 @@ import {ContactProperty} from '../config'; -import {IValidator} from './validation'; +import { IValidator } from '.'; import ValidatorString from './validator-string'; export default class ValidatorSelectOne implements IValidator { diff --git a/src/lib/validator-skip.ts b/src/validation/validator-skip.ts similarity index 84% rename from src/lib/validator-skip.ts rename to src/validation/validator-skip.ts index eee1b311..f309f50c 100644 --- a/src/lib/validator-skip.ts +++ b/src/validation/validator-skip.ts @@ -1,4 +1,4 @@ -import { IValidator } from './validation'; +import { IValidator } from '.'; export default class ValidatorSkip implements IValidator { isValid() : boolean | string { diff --git a/src/lib/validator-string.ts b/src/validation/validator-string.ts similarity index 89% rename from src/lib/validator-string.ts rename to src/validation/validator-string.ts index 7e4dd251..60f5d773 100644 --- a/src/lib/validator-string.ts +++ b/src/validation/validator-string.ts @@ -1,4 +1,4 @@ -import { IValidator } from './validation'; +import { IValidator } from '.'; export default class ValidatorString implements IValidator { isValid(input: string) : boolean | string { diff --git a/test/config.spec.ts b/test/config.spec.ts new file mode 100644 index 00000000..81a8ba35 --- /dev/null +++ b/test/config.spec.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; + +import { Config, PartnerConfig } from '../src/config'; +import { CONFIG_MAP } from '../src/config/config-factory'; +import { mockSimpleContactType } from './mocks'; + +const mockPartnerConfig = (): PartnerConfig => ({ + config: { + domains: [], + contact_types: [mockSimpleContactType('string')], + logoBase64: '', + } +}); + +describe('config', () => { + it('mock partner config is valid', () => { + const mockConfig = mockPartnerConfig(); + Config.assertValid(mockConfig); + }); + + it('assert on unknown property type', () => { + const mockConfig = mockPartnerConfig(); + mockConfig.config.contact_types[0].hierarchy[0].type = 'unknown'; + const assertion = () => Config.assertValid(mockConfig); + expect(assertion).to.throw('type "unknown"'); + }); + + it('place name is always required', () => { + const mockConfig = mockPartnerConfig(); + mockConfig.config.contact_types[0].place_properties.shift(); + const assertion = () => Config.assertValid(mockConfig); + expect(assertion).to.throw('"name"'); + }); + + it('contact name is always required', () => { + const mockConfig = mockPartnerConfig(); + mockConfig.config.contact_types[0].contact_properties.shift(); + const assertion = () => Config.assertValid(mockConfig); + expect(assertion).to.throw('"name"'); + }); + + it('#124 - cannot have generated property in hierarchy', () => { + const mockConfig = mockPartnerConfig(); + mockConfig.config.contact_types[0].hierarchy[0].type = 'generated'; + const assertion = () => Config.assertValid(mockConfig); + expect(assertion).to.throw('cannot be of type "generated"'); + }); + + it('#124 - cannot have generated property as replacement_property', () => { + const mockConfig = mockPartnerConfig(); + mockConfig.config.contact_types[0].replacement_property.type = 'generated'; + const assertion = () => Config.assertValid(mockConfig); + expect(assertion).to.throw('cannot be of type "generated"'); + }); + + const configs = Object.entries(CONFIG_MAP); + for (const [configName, partnerConfig] of configs) { + it(`config ${configName} is valid`, () => { + Config.assertValid(partnerConfig); + }); + } +}); diff --git a/test/create-user-managers.spec.ts b/test/create-user-managers.spec.ts index c2a55ede..930ae578 100644 --- a/test/create-user-managers.spec.ts +++ b/test/create-user-managers.spec.ts @@ -6,6 +6,7 @@ import { mockChtSession } from './mocks'; const createUserManagers = rewire('../scripts/create-user-managers/create-user-managers'); import chaiAsPromised from 'chai-as-promised'; +import RemotePlaceCache from '../src/lib/remote-place-cache'; Chai.use(chaiAsPromised); const { expect } = Chai; @@ -18,16 +19,17 @@ const StandardArgv = [ let fakeGetPlacesWithType; describe('scripts/create-user-managers.ts', () => { beforeEach(() => { + RemotePlaceCache.clear(); + const session = mockChtSession('abc'); const mockSession = { - create: sinon.stub().resolves(mockChtSession('abc')), + create: sinon.stub().resolves(session), }; fakeGetPlacesWithType = sinon.stub().resolves([{ - id: 'county_id', + _id: 'county_id', name: 'vihiga', - lineage: [], - type: 'remote', }]); const mockChtApi = class MockChtApi { + public chtSession = session; public getPlacesWithType = fakeGetPlacesWithType; public createContact = sinon.stub().resolves({}); @@ -74,16 +76,12 @@ describe('scripts/create-user-managers.ts', () => { fakeGetPlacesWithType.resolves([ { - id: 'county_id', + _id: 'county_id', name: 'vihiga', - lineage: [], - type: 'remote', }, { - id: 'county_id2', + _id: 'county_id2', name: 'kakamega', - lineage: [], - type: 'remote', } ]); diff --git a/test/lib/cht-session.spec.ts b/test/lib/cht-session.spec.ts index f0e30cbf..9180279e 100644 --- a/test/lib/cht-session.spec.ts +++ b/test/lib/cht-session.spec.ts @@ -3,7 +3,7 @@ import rewire from 'rewire'; import sinon from 'sinon'; import { AuthenticationInfo } from '../../src/config'; -import { RemotePlace } from '../../src/lib/cht-api'; +import { RemotePlace } from '../../src/lib/remote-place-cache'; const ChtSession = rewire('../../src/lib/cht-session'); import chaiAsPromised from 'chai-as-promised'; diff --git a/test/lib/credentials-file.spec.ts b/test/lib/credentials-file.spec.ts new file mode 100644 index 00000000..b8329a85 --- /dev/null +++ b/test/lib/credentials-file.spec.ts @@ -0,0 +1,68 @@ +import SessionCache from '../../src/services/session-cache'; +import getCredentialsFiles from '../../src/lib/credentials-file'; +import PlaceFactory from '../../src/services/place-factory'; +import { ChtDoc, mockChtApi, mockValidContactType } from '../mocks'; +import { expect } from 'chai'; + +describe('lib/credentials-file.ts', () => { + it('one csv file per contact type', async () => { + const sessionCache = new SessionCache(); + const subcounty: ChtDoc = { + _id: 'parent-id', + name: 'parent-name', + }; + const fakeFormData: any = { + place_name: 'place', + place_prop: 'foo', + hierarchy_PARENT: subcounty.name, + contact_name: 'contact', + contact_phone: '0712344321', + }; + const chtApi = mockChtApi([subcounty]); + const contactType = mockValidContactType('string', undefined); + contactType.contact_properties.push({ + friendly_name: 'CHP Phone', + property_name: 'phone', + parameter: 'KE', + type: 'phone', + required: true + }); + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + + sessionCache.savePlaces(place); + const actual = getCredentialsFiles(sessionCache, [contactType]); + expect(actual).to.deep.eq([{ + filename: 'contacttype-name.csv', + content: `friendly replacement,friendly PARENT,friendly GRANDPARENT,friendly,name,phone,role,username,password +,Parent-name,,Place,contact,0712 344321,role,, +` + }]); + }); + + it('contact without phone number', async () => { + const sessionCache = new SessionCache(); + const subcounty: ChtDoc = { + _id: 'parent-id', + name: 'parent-name', + }; + const fakeFormData: any = { + place_name: 'place', + place_prop: 'foo', + hierarchy_PARENT: subcounty.name, + contact_name: 'contact', + }; + const chtApi = mockChtApi([subcounty]); + const contactType = mockValidContactType('string', undefined); + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + + sessionCache.savePlaces(place); + const actual = getCredentialsFiles(sessionCache, [contactType]); + expect(actual).to.deep.eq([{ + filename: 'contacttype-name.csv', + content: `friendly replacement,friendly PARENT,friendly GRANDPARENT,friendly,name,phone,role,username,password +,Parent-name,,Place,contact,,role,, +` + }]); + }); +}); diff --git a/test/lib/move.spec.ts b/test/lib/move.spec.ts index 8c314a73..e0e1fa45 100644 --- a/test/lib/move.spec.ts +++ b/test/lib/move.spec.ts @@ -7,6 +7,7 @@ import SessionCache from '../../src/services/session-cache'; import { mockChtApi } from '../mocks'; import chaiAsPromised from 'chai-as-promised'; +import RemotePlaceCache from '../../src/lib/remote-place-cache'; import Auth from '../../src/lib/authentication'; import { BullQueue } from '../../src/lib/queues'; Chai.use(chaiAsPromised); @@ -14,25 +15,30 @@ Chai.use(chaiAsPromised); const { expect } = Chai; describe('lib/move.ts', () => { + beforeEach(() => { + }); + + const childDocs = [ + { _id: 'from-sub', name: 'From Sub' }, + { _id: 'to-sub', name: 'To Sub' } + ]; + const subcountyDocs = [ + { _id: 'chu-id', name: 'c-h-u', parent: { _id: 'from-sub' } }, + ]; + + const chtApi = () => mockChtApi(childDocs, subcountyDocs); let moveContactQueue: any; beforeEach(() => { moveContactQueue = sinon.createStubInstance(BullQueue); sinon.stub(Auth, 'encodeTokenForWorker').returns('encoded-token'); + RemotePlaceCache.clear({}); }); afterEach(() => { sinon.restore(); }); - const chtApi = () => mockChtApi( - [ - { id: 'from-sub', name: 'From Sub', lineage: [], type: 'remote' }, - { id: 'to-sub', name: 'To Sub', lineage: [], type: 'remote' } - ], - [{ id: 'chu-id', name: 'c-h-u', lineage: ['from-sub'], type: 'remote' }], - ); - it('move CHU: success', async () => { const formData = { from_replacement: 'c-h-u', @@ -50,7 +56,7 @@ describe('lib/move.ts', () => { expect(moveContactQueue.add.calledOnce).to.be.true; const jobParams = moveContactQueue.add.getCall(0).args[0]; - expect(jobParams).to.have.property('jobName').that.equals('move_[c-h-u]_from_[From Sub]_to_[To Sub]'); + expect(jobParams).to.have.property('jobName').that.equals('move_[C-h-u]_from_[From Sub]_to_[To Sub]'); expect(jobParams).to.have.property('jobData').that.deep.include({ contactId: 'chu-id', parentId: 'to-sub', @@ -67,7 +73,7 @@ describe('lib/move.ts', () => { const contactType = Config.getContactType('c_community_health_unit'); const sessionCache = new SessionCache(); - const actual = MoveLib.move(formData, contactType, sessionCache, chtApi(), moveContactQueue); + const actual = MoveLib.move(formData, contactType, sessionCache, mockChtApi(subcountyDocs), moveContactQueue); await expect(actual).to.eventually.be.rejectedWith('search string is empty'); }); @@ -94,7 +100,7 @@ describe('lib/move.ts', () => { const sessionCache = new SessionCache(); const actual = MoveLib.move(formData, contactType, sessionCache, chtApi(), moveContactQueue); - await expect(actual).to.eventually.be.rejectedWith('Cannot find \'b_sub_county\' matching \'Invalid Sub\''); + await expect(actual).to.eventually.be.rejectedWith('Cannot find \'b_sub_county\' matching \'invalid sub\''); }); }); diff --git a/test/lib/remote-place-cache.spec.ts b/test/lib/remote-place-cache.spec.ts index ae205267..76f86642 100644 --- a/test/lib/remote-place-cache.spec.ts +++ b/test/lib/remote-place-cache.spec.ts @@ -1,46 +1,88 @@ import { expect } from 'chai'; -import { RemotePlace } from '../../src/lib/cht-api'; +import { ChtDoc, mockChtApi, mockPlace, mockSimpleContactType } from '../mocks'; +import { HierarchyConstraint } from '../../src/config'; import RemotePlaceCache from '../../src/lib/remote-place-cache'; -import { mockChtApi, mockPlace, mockSimpleContactType } from '../mocks'; describe('lib/remote-place-cache.ts', () => { beforeEach(() => { RemotePlaceCache.clear({}); }); - const remotePlace: RemotePlace = { - id: 'parent-id', + const doc: ChtDoc = { + _id: 'parent-id', name: 'parent', + }; + + const docAsRemotePlace = { + id: doc._id, + 'name.original': doc.name, type: 'remote', lineage: [], }; + const contactType = mockSimpleContactType('string', undefined); + const hierarchyLevel = contactType.hierarchy[0]; + it('cache miss', async () => { - const chtApi = mockChtApi([remotePlace]); - const actual = await RemotePlaceCache.getPlacesWithType(chtApi, 'type'); - expect(actual).to.deep.eq([remotePlace]); + const chtApi = mockChtApi([doc]); + const actual = await RemotePlaceCache.getPlacesWithType(chtApi, contactType, hierarchyLevel); + expect(actual).to.have.property('length', 1); + expect(actual[0]).to.deep.nested.include(docAsRemotePlace); expect(chtApi.getPlacesWithType.calledOnce).to.be.true; }); it('cache hit', async () => { - const chtApi = mockChtApi([remotePlace]); - await RemotePlaceCache.getPlacesWithType(chtApi, 'type'); - const second = await RemotePlaceCache.getPlacesWithType(chtApi, 'type'); - expect(second).to.deep.eq([remotePlace]); + const chtApi = mockChtApi([doc]); + + await RemotePlaceCache.getPlacesWithType(chtApi, contactType, hierarchyLevel); + const second = await RemotePlaceCache.getPlacesWithType(chtApi, contactType, hierarchyLevel); + expect(second).to.have.property('length', 1); + expect(second[0]).to.deep.nested.include(docAsRemotePlace); expect(chtApi.getPlacesWithType.calledOnce).to.be.true; }); it('add', async () => { - const contactType = mockSimpleContactType('unknown`', undefined); + const contactType = mockSimpleContactType('string', undefined); const place = mockPlace(contactType, 'prop'); + const chtApi = mockChtApi([doc]); + + const contactTypeAsHierarchyLevel: HierarchyConstraint = { + contact_type: contactType.name, + property_name: 'level', + friendly_name: 'pretend another ContactType needs this', + type: 'name', + required: true, + level: 0, + }; + await RemotePlaceCache.getPlacesWithType(chtApi, contactType, contactTypeAsHierarchyLevel); + RemotePlaceCache.add(place, chtApi); - const chtApi = mockChtApi([remotePlace]); - await RemotePlaceCache.add(place, chtApi); - - const second = await RemotePlaceCache.getPlacesWithType(chtApi, contactType.name); - expect(second).to.deep.eq([remotePlace, place.asRemotePlace()]); + const second = await RemotePlaceCache.getPlacesWithType(chtApi, contactType, contactTypeAsHierarchyLevel); + expect(second).to.have.property('length', 2); + expect(second[0]).to.deep.nested.include(docAsRemotePlace); + expect(second[1].id).to.eq(place.asRemotePlace().id); expect(chtApi.getPlacesWithType.calledOnce).to.be.true; }); + + it('clear', async () => { + const contactType = mockSimpleContactType('string', undefined); + const place = mockPlace(contactType, 'prop'); + const chtApi = mockChtApi([doc]); + + const contactTypeAsHierarchyLevel: HierarchyConstraint = { + contact_type: contactType.name, + property_name: 'level', + friendly_name: 'pretend another ContactType needs this', + type: 'name', + required: true, + level: 0, + }; + await RemotePlaceCache.getPlacesWithType(chtApi, contactType, contactTypeAsHierarchyLevel); + RemotePlaceCache.add(place, chtApi); + + chtApi.chtSession.authInfo.domain = 'http://other'; + RemotePlaceCache.clear(chtApi, 'other'); + }); }); diff --git a/test/lib/search.spec.ts b/test/lib/search.spec.ts index 2e8895fb..92d294d1 100644 --- a/test/lib/search.spec.ts +++ b/test/lib/search.spec.ts @@ -1,9 +1,8 @@ import { expect } from 'chai'; -import { RemotePlace } from '../../src/lib/cht-api'; -import RemotePlaceCache from '../../src/lib/remote-place-cache'; +import RemotePlaceCache, { RemotePlace } from '../../src/lib/remote-place-cache'; import SearchLib from '../../src/lib/search'; -import { mockChtApi, mockChtSession, mockValidContactType } from '../mocks'; +import { ChtDoc, mockChtApi, mockChtSession, mockValidContactType } from '../mocks'; import SessionCache from '../../src/services/session-cache'; import { Config } from '../../src/config'; import RemotePlaceResolver from '../../src/lib/remote-place-resolver'; @@ -13,18 +12,16 @@ describe('lib/remote-place-cache.ts', () => { RemotePlaceCache.clear({}); }); - const parentPlace: RemotePlace = { - id: 'parent-id', + const parentPlace: ChtDoc = { + _id: 'parent-id', name: 'parent', - type: 'remote', - lineage: ['grandparent-id'], + parent: { _id: 'grandparent-id' }, }; - const toReplacePlace: RemotePlace = { - id: 'to-replace', + const toReplacePlace: ChtDoc = { + _id: 'to-replace', name: 'replace me', - type: 'remote', - lineage: [parentPlace.id, ...parentPlace.lineage], + parent: { _id: parentPlace._id, parent: parentPlace.parent }, }; it('simple search', async () => { @@ -39,7 +36,7 @@ describe('lib/remote-place-cache.ts', () => { const [replacementLevel] = Config.getHierarchyWithReplacement(contactType); const actual = await SearchLib.search(contactType, formData, 'hierarchy_', replacementLevel, chtApi, sessionCache); - expect(actual).to.deep.eq([toReplacePlace]); + assertPlaceMatchesDoc(actual, [toReplacePlace]); }); it('data prefix', async () => { @@ -54,16 +51,15 @@ describe('lib/remote-place-cache.ts', () => { const [replacementLevel] = Config.getHierarchyWithReplacement(contactType); const actual = await SearchLib.search(contactType, formData, 'prefix_', replacementLevel, chtApi, sessionCache); - expect(actual).to.deep.eq([toReplacePlace]); + assertPlaceMatchesDoc(actual, [toReplacePlace]); }); it('search constrained by parent', async () => { const sessionCache = new SessionCache(); - const ambiguity: RemotePlace = { - id: 'ambiguous', + const ambiguity: ChtDoc = { + _id: 'ambiguous', name: 'me ambiguous', - type: 'remote', - lineage: ['other-parent', ...parentPlace.lineage], + parent: { _id: 'other-parent', parent: parentPlace.parent }, }; const contactType = mockValidContactType('string', undefined); @@ -77,7 +73,22 @@ describe('lib/remote-place-cache.ts', () => { const [replacementLevel] = Config.getHierarchyWithReplacement(contactType); const actual = await SearchLib.search(contactType, formData, 'hierarchy_', replacementLevel, chtApi, sessionCache); - expect(actual).to.deep.eq([toReplacePlace]); + assertPlaceMatchesDoc(actual, [toReplacePlace]); + }); + + it('ignores accents', async () => { + const sessionCache = new SessionCache(); + const contactType = mockValidContactType('string', undefined); + const formData = { + hierarchy_replacement: 'plÀce', + }; + const chtApi = mockChtApi(); + chtApi.getPlacesWithType.resolves([toReplacePlace]) + .onSecondCall().resolves([parentPlace]); + + const [replacementLevel] = Config.getHierarchyWithReplacement(contactType); + const actual = await SearchLib.search(contactType, formData, 'hierarchy_', replacementLevel, chtApi, sessionCache); + assertPlaceMatchesDoc(actual, [toReplacePlace]); }); it('search unsuccessful when result is not child of user facility', async () => { @@ -97,3 +108,9 @@ describe('lib/remote-place-cache.ts', () => { }); }); +function assertPlaceMatchesDoc(remotePlace: RemotePlace[], docs: ChtDoc[]) { + const remotePlaceIds = remotePlace.map(a => a.id); + const docIds = docs.map(doc => doc._id); + expect(remotePlaceIds).to.deep.eq(docIds); +} + diff --git a/test/lib/validation.spec.ts b/test/lib/validation.spec.ts deleted file mode 100644 index aa0861ca..00000000 --- a/test/lib/validation.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { DateTime } from 'luxon'; -import { expect } from 'chai'; - -import { Validation } from '../../src/lib/validation'; -import { mockSimpleContactType, mockPlace } from '../mocks'; -import RemotePlaceResolver from '../../src/lib/remote-place-resolver'; - -type Scenario = { - type: string; - prop?: string; - isValid: boolean; - propertyParameter?: string | string[] | object; - altered?: string; - propertyErrorDescription?: string; - error?: string; -}; - -const EMAIL_REGEX = '^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$'; -const GENDER_OPTIONS = { male: 'Male', female: 'Female' }; -const CANDIES_OPTIONS = { chocolate: 'Chocolate', strawberry: 'Strawberry' }; - -const scenarios: Scenario[] = [ - { type: 'string', prop: undefined, isValid: false, error: 'Required' }, - { type: 'string', prop: 'abc', isValid: true }, - { type: 'string', prop: ' ab\nc', isValid: true, altered: 'abc' }, - { type: 'string', prop: 'Mr. Sand(m-a-n)', isValid: true, altered: 'Mr. Sand(m-a-n)' }, - { type: 'string', prop: 'Université ', isValid: true, altered: 'Université' }, - { type: 'string', prop: `Infirmière d'Etat`, isValid: true, altered: `Infirmière d'Etat` }, - { type: 'string', prop: '', isValid: false, altered: '', error: 'Required' }, - - { type: 'phone', prop: undefined, isValid: false, error: 'Required' }, - { type: 'phone', prop: '+254712345678', isValid: true, altered: '0712 345678', propertyParameter: 'KE' }, - { type: 'phone', prop: '712345678', isValid: true, altered: '0712 345678', propertyParameter: 'KE' }, - { type: 'phone', prop: '+254712345678', isValid: false, altered: '0712 345678', propertyParameter: 'UG', error: 'Not a valid' }, - { type: 'phone', prop: '+17058772274', isValid: false, altered: '(705) 877-2274', propertyParameter: 'KE', error: 'KE' }, - - { type: 'regex', prop: undefined, isValid: false, error: 'Required' }, - { type: 'regex', propertyParameter: '^\\d{6}$', prop: '123456', isValid: true }, - { type: 'regex', propertyParameter: '^\\d{6}$', prop: ' 123456 *&%', isValid: true, altered: '123456' }, - { type: 'regex', propertyParameter: '^\\d{6}$', prop: '1234567', isValid: false, error: 'six digit', propertyErrorDescription: 'six digit number' }, - { type: 'regex', propertyParameter: EMAIL_REGEX, prop: 'email@address.com', isValid: true, altered: 'email@address.com' }, - { type: 'regex', propertyParameter: EMAIL_REGEX, prop: '.com', isValid: false, propertyErrorDescription: 'valid email address', error: 'email' }, - { type: 'regex', propertyParameter: undefined, prop: 'abc', isValid: false, error: 'missing parameter' }, - - { type: 'name', prop: undefined, isValid: false, error: 'Required' }, - { type: 'name', prop: 'abc', isValid: true, altered: 'Abc' }, - { type: 'name', prop: 'a b c', isValid: true, altered: 'A B C' }, - { type: 'name', prop: 'Mr. Sand(m-a-n)', isValid: true, altered: 'Mr Sand(m-a-n)' }, - { type: 'name', prop: 'WELDON KO(E)CH \n', isValid: true, altered: 'Weldon Ko(e)ch' }, - { type: 'name', prop: 'S \'am \'s', isValid: true, altered: 'S\'am\'s' }, - { type: 'name', prop: 'KYAMBOO/KALILUNI', isValid: true, altered: 'Kyamboo / Kaliluni' }, - { type: 'name', prop: 'NZATANI / ILALAMBYU', isValid: true, altered: 'Nzatani / Ilalambyu' }, - { type: 'name', prop: 'Sam\'s CHU', propertyParameter: ['CHU', 'Comm Unit'], isValid: true, altered: 'Sam\'s' }, - { type: 'name', prop: 'Jonathan M.Barasa', isValid: true, altered: 'Jonathan M Barasa' }, - { type: 'name', prop: 'Robert xiv', isValid: true, altered: 'Robert XIV' }, - { type: 'name', prop: ' ', isValid: true, altered: '' }, - - { type: 'dob', prop: undefined, isValid: false, error: 'Required' }, - { type: 'dob', prop: '', isValid: false }, - { type: 'dob', prop: '2016/05/25', isValid: false }, - { type: 'dob', prop: 'May 25, 2016', isValid: false }, - { type: 'dob', prop: '2030-05-25', isValid: false }, - { type: 'dob', prop: '2016-05-25', isValid: true, altered: '2016-05-25' }, - { type: 'dob', prop: ' 20 16- 05- 25 ', isValid: true, altered: '2016-05-25' }, - { type: 'dob', prop: '20', isValid: true, altered: DateTime.now().minus({ years: 20 }).toISODate() }, - { type: 'dob', prop: ' 20 ', isValid: true, altered: DateTime.now().minus({ years: 20 }).toISODate() }, - { type: 'dob', prop: 'abc', isValid: false, altered: 'abc' }, - { type: 'dob', prop: ' 1 0 0 ', isValid: true, altered: DateTime.now().minus({ years: 100 }).toISODate() }, - { type: 'dob', prop: '-1', isValid: false, altered: '-1' }, - { type: 'dob', prop: '15/2/1985', isValid: true, altered: '1985-02-15' }, - { type: 'dob', prop: '1/2/1 985', isValid: true, altered: '1985-02-01' }, - { type: 'dob', prop: '1/13/1985', isValid: false }, - - { type: 'select_one', prop: undefined, isValid: false, error: 'Required' }, - { type: 'select_one', prop: ' male', isValid: true, propertyParameter: GENDER_OPTIONS }, - { type: 'select_one', prop: 'female ', isValid: true, propertyParameter: GENDER_OPTIONS }, - { type: 'select_one', prop: 'FeMale ', isValid: false, propertyParameter: GENDER_OPTIONS }, - { type: 'select_one', prop: 'f', isValid: false, propertyParameter: GENDER_OPTIONS }, - { type: 'select_one', prop: '', isValid: false, propertyParameter: GENDER_OPTIONS }, - - { type: 'select_multiple', prop: undefined, isValid: false, error: 'Required' }, - { type: 'select_multiple', prop: 'chocolate', isValid: true, propertyParameter: CANDIES_OPTIONS }, - { type: 'select_multiple', prop: 'chocolate strawberry', isValid: true, propertyParameter: CANDIES_OPTIONS }, - { type: 'select_multiple', prop: ' chocolate strawberry', isValid: true, propertyParameter: CANDIES_OPTIONS }, - { type: 'select_multiple', prop: 'c,s', isValid: false, propertyParameter: CANDIES_OPTIONS, error: 'Invalid values' }, - { type: 'select_multiple', prop: '', isValid: false, propertyParameter: CANDIES_OPTIONS, error: 'required' }, - - { type: 'generated', prop: 'b', propertyParameter: 'a {{ place.prop }} c', isValid: true, altered: 'a b c' }, - { type: 'generated', prop: 'b', propertyParameter: '{{ contact.name }} ({{ lineage.PARENT }})', isValid: true, altered: 'contact (Parent)' }, - { type: 'generated', prop: 'b', propertyParameter: 'x {{ contact.dne }}', isValid: true, altered: 'x ' }, -]; - -describe('lib/validation.ts', () => { - for (const scenario of scenarios) { - it(`scenario: ${JSON.stringify(scenario)}`, () => { - const contactType = mockSimpleContactType(scenario.type, scenario.propertyParameter, scenario.propertyErrorDescription); - const place = mockPlace(contactType, scenario.prop); - - const actualValidity = Validation.getValidationErrors(place); - expect(actualValidity.map(a => a.property_name)).to.deep.eq(scenario.isValid ? [] : ['place_prop']); - - if (scenario.error) { - expect(actualValidity?.[0].description).to.include(scenario.error); - } - - Validation.format(place); - expect(place.properties.prop).to.eq(scenario.altered ?? scenario.prop); - }); - } - - it('unknown property type throws', () => { - const contactType = mockSimpleContactType('unknown`', undefined); - const place = mockPlace(contactType, 'prop'); - - expect(() => Validation.getValidationErrors(place)).to.throw('unvalidatable'); - }); - - it('property with required:false can be empty', () => { - const contactType = mockSimpleContactType('string', undefined); - contactType.place_properties[contactType.place_properties.length-1].required = false; - - const place = mockPlace(contactType, undefined); - place.properties = { name: 'foo' }; - place.hierarchyProperties = { PARENT: 'parent' }; - - expect(Validation.getValidationErrors(place)).to.be.empty; - }); - - it('#91 - parent is invalid when required:false but resolution is NoResult', () => { - const contactType = mockSimpleContactType('string', undefined); - contactType.hierarchy[0].required = false; - - const place = mockPlace(contactType, 'prop'); - place.resolvedHierarchy[1] = RemotePlaceResolver.NoResult; - - console.log('Validation.getValidationErrors(place)', Validation.getValidationErrors(place)); - expect(Validation.getValidationErrors(place)).to.deep.eq([{ - property_name: 'hierarchy_PARENT', - description: `Cannot find 'parent' matching 'parent'`, - }]); - }); - - it('parent is invalid when missing but expected', () => { - const contactType = mockSimpleContactType('string', undefined); - const place = mockPlace(contactType, 'prop'); - delete place.resolvedHierarchy[1]; - - expect(Validation.getValidationErrors(place)).to.deep.eq([{ - property_name: 'hierarchy_PARENT', - description: `Cannot find 'parent' matching 'parent'`, - }]); - }); - - it('parent is valid when missing and not expected', () => { - const contactType = mockSimpleContactType('string', undefined); - contactType.hierarchy[0].required = false; - - const place = mockPlace(contactType, 'prop'); - delete place.resolvedHierarchy[1]; - - expect(Validation.getValidationErrors(place)).to.be.empty; - }); - - it('replacement property is validated and altered as property_name:name', () => { - const contactType = mockSimpleContactType('string', undefined); - - const place = mockPlace(contactType, 'foo'); - place.hierarchyProperties.replacement = 'sin bad'; - - Validation.format(place); - expect(place.hierarchyProperties.replacement).to.eq('Sin Bad'); - - const validationErrors = Validation.getValidationErrors(place); - expect(validationErrors).to.deep.eq([{ - property_name: 'hierarchy_replacement', - description: `Cannot find 'contacttype-name' matching 'Sin Bad' under 'Parent'`, - }]); - }); - - it('user_role property empty throws', () => { - const contactType = mockSimpleContactType('string', undefined); - contactType.user_role = []; - - const place = mockPlace(contactType, 'prop'); - - expect(() => Validation.getValidationErrors(place)).to.throw('unvalidatable'); - }); - - it('user_role property contains empty string throws', () => { - const contactType = mockSimpleContactType('string', undefined); - contactType.user_role = ['']; - - const place = mockPlace(contactType, 'prop'); - - expect(() => Validation.getValidationErrors(place)).to.throw('unvalidatable'); - }); - - it('user role is invalid when not allowed', () => { - const contactType = mockSimpleContactType('string', undefined); - contactType.user_role = ['supervisor', 'stock_manager']; - - const place = mockPlace(contactType, 'prop'); - - const formData = { - place_prop: 'abc', - contact_prop: 'efg', - garbage: 'ghj', - user_role: 'supervisor stockmanager', - }; - place.setPropertiesFromFormData(formData); - - expect(Validation.getValidationErrors(place)).to.deep.eq([{ - property_name: 'user_role', - description: `Invalid values for property "Roles": stockmanager` - }]); - }); -}); diff --git a/test/mocks.ts b/test/mocks.ts index 88c4dafa..6fdee4f9 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -1,40 +1,46 @@ import { expect } from 'chai'; -import Sinon from 'sinon'; +import sinon from 'sinon'; -import { ChtApi, RemotePlace } from '../src/lib/cht-api'; +import { ChtApi } from '../src/lib/cht-api'; import ChtSession from '../src/lib/cht-session'; -import { ContactProperty, ContactType } from '../src/config'; +import { Config, ContactProperty, ContactType } from '../src/config'; import Place from '../src/services/place'; +import PlaceFactory from '../src/services/place-factory'; +import { UnvalidatedPropertyValue } from '../src/property-value'; +export type ChtDoc = { + _id: string; + name: string; + [key: string]: string | Object; +}; -export const mockPlace = (type: ContactType, prop: any) : Place => { - const result = new Place(type); - result.properties = { - name: 'place', - prop - }; - result.hierarchyProperties = { - PARENT: 'parent', - }; - result.contact.properties = { - name: 'contact', - }; - result.resolvedHierarchy[1] = { +export const mockPlace = (contactType: ContactType, formDataOverride?: any) : Place => { + const formData = Object.assign({ + place_name: 'name', + place_prop: 'prop', + hierarchy_PARENT: 'parent', + contact_name: 'contact' + }, formDataOverride); + const place = new Place(contactType); + place.setPropertiesFromFormData(formData, 'hierarchy_'); + place.resolvedHierarchy[1] = { id: 'known', - name: 'parent', + name: new UnvalidatedPropertyValue('parent'), + lineage: [], type: 'remote', }; - return result; + place.validate(); + return place; }; -export const mockChtApi: ChtApi = (first: RemotePlace[] = [], second: RemotePlace[] = []) => ({ +export const mockChtApi = (first: ChtDoc[] = [], second: ChtDoc[] = []): any => ({ chtSession: mockChtSession(), - getPlacesWithType: Sinon.stub().resolves(first).onSecondCall().resolves(second), + getPlacesWithType: sinon.stub().resolves(first).onSecondCall().resolves(second), }); export const mockSimpleContactType = ( propertyType: string, - propertyValidator: string | string[] | undefined, + propertyValidator?: string | string[] | object, errorDescription?: string ) : ContactType => { const mockedProperty = mockProperty(propertyType, propertyValidator); @@ -58,10 +64,28 @@ export const mockSimpleContactType = ( mockProperty('name', undefined, 'name'), mockedProperty, ], - contact_properties: [], + contact_properties: [ + mockProperty('name', undefined, 'name'), + ], }; }; +export async function createChu(subcounty: ChtDoc, chu_name: string, sessionCache: any, chtApi: ChtApi, dataOverrides?: any): Promise { + const chuType = Config.getContactType('c_community_health_unit'); + const chuData = Object.assign({ + hierarchy_SUBCOUNTY: subcounty.name, + place_name: chu_name, + place_code: '676767', + place_link_facility_name: 'facility name', + place_link_facility_code: '23456', + contact_name: 'new cha', + contact_phone: '0712345678', + }, dataOverrides); + const chu = await PlaceFactory.createOne(chuData, chuType, sessionCache, chtApi); + expect(chu.validationErrors).to.be.empty; + return chu; +} + export const mockValidContactType = (propertyType: string, propertyValidator: string | string[] | undefined) : ContactType => ({ name: 'contacttype-name', friendly: 'friendly', @@ -94,12 +118,12 @@ export const mockValidContactType = (propertyType: string, propertyValidator: st export const mockParentPlace = (parentPlaceType: ContactType, parentName: string) => { const place = new Place(parentPlaceType); - place.properties.name = parentName; + place.properties.name = new UnvalidatedPropertyValue(parentName, 'name'); return place; }; -export const mockProperty = (type: string, parameter: string | string[] | undefined | object, property_name: string = 'prop'): ContactProperty => ({ - friendly_name: 'csv', +export const mockProperty = (type: string, parameter?: string | string[] | object, property_name: string = 'prop'): ContactProperty => ({ + friendly_name: `friendly ${property_name}`, property_name, type, parameter, diff --git a/test/property-value.spec.ts b/test/property-value.spec.ts new file mode 100644 index 00000000..db279c7a --- /dev/null +++ b/test/property-value.spec.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import { NamePropertyValue, PropertyValues, UnvalidatedPropertyValue } from '../src/property-value'; +import { ContactProperty } from '../src/config'; +import { mockProperty } from './mocks'; + +describe('property-value', () => { + const namePropertyValue: ContactProperty = mockProperty('name'); + const includeScenarios = [ + { searchWithin: 'abc', searchFor: 'bc', expected: true }, + { searchWithin: 'abc', searchFor: 'AbC', expected: true }, + { searchWithin: 'place', searchFor: '', expected: true }, + { searchWithin: 'plÀce', searchFor: 'lac', expected: true }, + { searchWithin: 'place', searchFor: 'À', expected: true }, + { searchWithin: 'plÀce', searchFor: 'lAc', expected: true }, + + { searchWithin: 'abc', searchFor: 'e', expected: false }, + { searchWithin: 'abc', searchFor: undefined, expected: false }, + { searchWithin: 'abc', searchFor: ' a', expected: false }, + { searchWithin: undefined, searchFor: 'a', expected: false }, + { searchWithin: undefined, searchFor: undefined, expected: false }, + + { searchWithin: new UnvalidatedPropertyValue('abc'), searchFor: 'a', expected: true }, + { searchWithin: new UnvalidatedPropertyValue('abc'), searchFor: 'a', expected: true }, + + { searchWithin: new NamePropertyValue('a.b.c', namePropertyValue), searchFor: 'a b c', expected: true }, + { searchWithin: new NamePropertyValue('a.b.c', namePropertyValue), searchFor: new NamePropertyValue('a.b c', namePropertyValue), expected: true }, + ]; + + const matchScenarios = [ + { a: 'abc', b: 'abc', expected: true }, + { a: 'abc', b: 'AbC', expected: true }, + { a: 'plÀce', b: 'PlacE', expected: true }, + { a: 'place', b: 'PlÀcE', expected: true }, + + { a: 'place', b: 'lÀc', expected: false }, + { a: undefined, b: 'abc', expected: false }, + { a: 'abc', b: undefined, expected: false }, + { a: undefined, b: undefined, expected: false }, + ]; + + describe('include', () => { + for (const scenario of includeScenarios) { + it(JSON.stringify(scenario), () => { + const actual = PropertyValues.includes(scenario.searchWithin, scenario.searchFor); + expect(actual).to.eq(scenario.expected); + }); + } + }); + + describe('isMatch', () => { + for (const scenario of matchScenarios) { + it(JSON.stringify(scenario), () => { + const actual = PropertyValues.isMatch(scenario.a, scenario.b); + expect(actual).to.eq(scenario.expected); + }); + } + }); +}); diff --git a/test/services/place-factory.spec.ts b/test/services/place-factory.spec.ts index 6c1ae46b..bd0fb928 100644 --- a/test/services/place-factory.spec.ts +++ b/test/services/place-factory.spec.ts @@ -1,16 +1,21 @@ import _ from 'lodash'; +import Chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; import fs from 'fs'; -import { expect } from 'chai'; import sinon from 'sinon'; -import { expectInvalidProperties, mockChtSession, mockParentPlace, mockProperty, mockValidContactType } from '../mocks'; +import { ChtDoc, expectInvalidProperties, mockChtSession, mockParentPlace, mockProperty, mockValidContactType } from '../mocks'; import { Config } from '../../src/config'; import Place from '../../src/services/place'; import PlaceFactory from '../../src/services/place-factory'; -import { RemotePlace } from '../../src/lib/cht-api'; import RemotePlaceCache from '../../src/lib/remote-place-cache'; import RemotePlaceResolver from '../../src/lib/remote-place-resolver'; import SessionCache from '../../src/services/session-cache'; +import { UnvalidatedPropertyValue } from '../../src/property-value'; + +Chai.use(chaiAsPromised); + +const { expect } = Chai; describe('services/place-factory.ts', () => { beforeEach(() => { @@ -28,10 +33,10 @@ describe('services/place-factory.ts', () => { }); it('name conflict at remote yields invalid', async () => { - const { remotePlace, sessionCache, fakeFormData, contactType, chtApi } = mockScenario(); - const secondParent = _.cloneDeep(remotePlace); - secondParent.id = 'second-id'; - chtApi.getPlacesWithType.resolves([remotePlace, secondParent]); + const { parentDoc, sessionCache, fakeFormData, contactType, chtApi } = mockScenario(); + const secondParent = _.cloneDeep(parentDoc); + secondParent._id = 'second-id'; + chtApi.getPlacesWithType.resolves([parentDoc, secondParent]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); expectInvalidProperties(place.validationErrors, ['hierarchy_PARENT'], 'multiple'); @@ -41,7 +46,7 @@ describe('services/place-factory.ts', () => { const { sessionCache, fakeFormData, contactType, parentContactType, chtApi } = mockScenario(); const chu = new Place(parentContactType); - chu.properties.name = 'Demesi'; + chu.properties.name = new UnvalidatedPropertyValue('Demesi', 'name'); sessionCache.savePlaces(chu); fakeFormData.hierarchy_PARENT = 'Demesi '; @@ -49,41 +54,41 @@ describe('services/place-factory.ts', () => { const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); expect(place.validationErrors).to.be.empty; expect(place.resolvedHierarchy[1]?.id).to.eq(chu.id); - expect(place.hierarchyProperties.PARENT).to.eq('Demesi'); + expect(place.hierarchyProperties.PARENT.formatted).to.eq('Demesi'); }); it('bulk upload fuzzed parent matching', async () => { - const { remotePlace, sessionCache, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, fakeFormData, chtApi } = mockScenario(); - const nameValidator = ['Cu', 'Community Health Unit']; const contactType = mockValidContactType('string', undefined); + const nameValidatorParameter = ['Cu', 'Community Health Unit']; contactType.hierarchy[0] = { - ...mockProperty('name', nameValidator, 'PARENT'), + ...mockProperty('name', nameValidatorParameter, 'PARENT'), level: 1, contact_type: 'parent', }; - remotePlace.name = 'Cheplanget Cu'; + parentDoc.name = 'Cheplanget Cu'; fakeFormData.hierarchy_PARENT = 'Cheplanget Community Health Unit'; const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.be.empty; expect(place.resolvedHierarchy[1]?.id).to.eq('parent-id'); }); it('simple replacement', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); fakeFormData.hierarchy_replacement = 'to-replace'; - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'to-replace', - lineage: [remotePlace.id], - type: 'remote', + parent: { _id: parentDoc._id }, }; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([parentDoc]) .onSecondCall().resolves([toReplace]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); @@ -93,11 +98,11 @@ describe('services/place-factory.ts', () => { }); it('invalid when name doesnt match any remote place', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); - remotePlace.name = 'foobar'; + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + parentDoc.name = 'foobar'; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([parentDoc]) .onSecondCall().resolves([]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); @@ -105,19 +110,18 @@ describe('services/place-factory.ts', () => { }); it('simple eCHIS csv', async () => { - const { remotePlace, sessionCache, chtApi } = mockScenario(); + const { parentDoc, sessionCache, chtApi } = mockScenario(); - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'bob', - lineage: [remotePlace.id], - type: 'remote', + parent: { _id: parentDoc._id }, }; - remotePlace.name = 'Chepalungu CHU'; + parentDoc.name = 'Chepalungu CHU'; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([parentDoc]) .onSecondCall().resolves([toReplace]); const singleCsvBuffer = fs.readFileSync('./test/single.csv'); @@ -128,47 +132,58 @@ describe('services/place-factory.ts', () => { const [successfulPlace] = places; expect(successfulPlace).to.deep.nested.include({ - 'contact.properties.name': 'Sally', - 'contact.properties.phone': '0712 345678', + name: 'Sally Area', + 'contact.properties.name.formatted': 'Sally', + 'contact.properties.phone.formatted': '0712 345678', creationDetails: {}, - 'properties.name': 'Sally Area', - 'hierarchyProperties.CHU': 'Chepalungu', + 'properties.name.formatted': 'Sally Area', + 'hierarchyProperties.CHU.original': 'chepalungu', + 'hierarchyProperties.CHU.formatted': 'Chepalungu', resolvedHierarchy: [ { id: 'id-replace', - name: 'bob', + name: { + formatted: 'Bob', + original: 'bob', + propertyNameWithPrefix: 'place_name', + }, lineage: ['parent-id'], type: 'remote', }, { id: 'parent-id', - name: 'Chepalungu CHU', + name: { + formatted: 'Chepalungu', + original: parentDoc.name, + propertyNameWithPrefix: 'place_name', + }, type: 'remote', lineage: [], }, ], validationErrors: {}, + userRoleProperties: {}, + state: 'staged', }); }); it('ambiguous parent resolves if only one has the replacement', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'to-replace', - lineage: [remotePlace.id], - type: 'remote', + parent: { _id: parentDoc._id }, }; fakeFormData.hierarchy_replacement = toReplace.name; const ambiguous = { - ...remotePlace, - id: 'id-parent-ambiguous', + ...parentDoc, + _id: 'id-parent-ambiguous', }; chtApi.getPlacesWithType - .resolves([remotePlace, ambiguous]) + .resolves([parentDoc, ambiguous]) .onSecondCall().resolves([toReplace]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); @@ -178,13 +193,11 @@ describe('services/place-factory.ts', () => { }); it('ambiguous greatgrandparent disambiguated by parent', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); - const greatParent: RemotePlace = { - id: 'id-great-grandparent', + const greatParent: ChtDoc = { + _id: 'id-great-grandparent', name: 'great-grand-parent', - type: 'remote', - lineage: [], }; contactType.hierarchy[1] = { ...mockProperty('name', undefined, 'GREATGRANDPARENT'), @@ -193,65 +206,62 @@ describe('services/place-factory.ts', () => { required: false, }; fakeFormData.hierarchy_GREATGRANDPARENT = greatParent.name; - remotePlace.lineage[1] = greatParent.id; + parentDoc.parent = { /*_id: ?,*/ parent: { _id: greatParent._id } }; - const ambiguous = { + const ambiguous: ChtDoc = { ...greatParent, - id: 'ambiguous-great-grandparent', + _id: 'ambiguous-great-grandparent', }; chtApi.getPlacesWithType .resolves([greatParent, ambiguous]) - .onSecondCall().resolves([remotePlace]); + .onSecondCall().resolves([parentDoc]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); expect(place.validationErrors).to.be.empty; - expect(place.resolvedHierarchy[3]?.id).to.eq(greatParent.id); - expect(place.resolvedHierarchy[1]?.id).to.eq(remotePlace.id); + expect(place.resolvedHierarchy[3]?.id).to.eq(greatParent._id); + expect(place.resolvedHierarchy[1]?.id).to.eq(parentDoc._id); }); it('ambiguous parent disambiguated by grandparent', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); - const grandParent: RemotePlace = { - id: 'id-grandparent', + const grandParent: ChtDoc = { + _id: 'id-grandparent', name: 'grand-parent', - type: 'remote', - lineage: [], }; fakeFormData.hierarchy_GRANDPARENT = grandParent.name; - const ambiguous = { - ...remotePlace, - id: 'id-ambiguous', + const ambiguous: ChtDoc = { + ...parentDoc, + _id: 'id-ambiguous', }; - remotePlace.lineage = [grandParent.id]; - ambiguous.lineage = ['not-grandpa']; + parentDoc.parent = { _id: grandParent._id }; + ambiguous.parent = { _id: 'not-grandpa' }; chtApi.getPlacesWithType .resolves([grandParent]) - .onSecondCall().resolves([remotePlace, ambiguous]); + .onSecondCall().resolves([parentDoc, ambiguous]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); expect(place.validationErrors).to.be.empty; - expect(place.resolvedHierarchy).to.deep.eq([undefined, remotePlace, grandParent]); + const resolvedHierarchyIds = place.resolvedHierarchy.map(h => h?.id); + expect(resolvedHierarchyIds).to.deep.eq([undefined, parentDoc._id, grandParent._id]); }); it('#91 - no result for optional level in hierarchy causes validation error', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); - const grandParent: RemotePlace = { - id: 'id-grandparent', + const grandParent: ChtDoc = { + _id: 'id-grandparent', name: 'grand-parent', - type: 'remote', - lineage: [], }; - remotePlace.lineage = [grandParent.id]; + parentDoc.parent = { _id: grandParent._id }; fakeFormData.hierarchy_GRANDPARENT = 'no match'; chtApi.getPlacesWithType .resolves([grandParent]) - .onSecondCall().resolves([remotePlace]); + .onSecondCall().resolves([parentDoc]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); expect(place.resolvedHierarchy[2]).to.eq(RemotePlaceResolver.NoResult); @@ -259,20 +269,18 @@ describe('services/place-factory.ts', () => { }); it('hierarchy resolution can be resolved by editing to blank', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); - const grandParent: RemotePlace = { - id: 'id-grandparent', + const grandParent: ChtDoc = { + _id: 'id-grandparent', name: 'grand-parent', - type: 'remote', - lineage: [], }; - remotePlace.lineage = [grandParent.id]; + parentDoc.parent = { _id: grandParent._id }; fakeFormData.hierarchy_GRANDPARENT = 'no match'; chtApi.getPlacesWithType .resolves([grandParent]) - .onSecondCall().resolves([remotePlace]); + .onSecondCall().resolves([parentDoc]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); expectInvalidProperties(place.validationErrors, ['hierarchy_PARENT', 'hierarchy_GRANDPARENT'], 'Cannot find'); @@ -283,13 +291,11 @@ describe('services/place-factory.ts', () => { }); it('ambiguous parent disambiguated by greatgrandparent', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); - const greatParent: RemotePlace = { - id: 'id-great-grandparent', + const greatParent: ChtDoc = { + _id: 'id-great-grandparent', name: 'great-grand-parent', - type: 'remote', - lineage: [], }; contactType.hierarchy[1] = { ...mockProperty('name', undefined, 'GREATGRANDPARENT'), @@ -299,40 +305,39 @@ describe('services/place-factory.ts', () => { }; fakeFormData.hierarchy_GREATGRANDPARENT = greatParent.name; - const ambiguous = { - ...remotePlace, - id: 'id-ambiguous', + const ambiguous: ChtDoc = { + ...parentDoc, + _id: 'id-ambiguous', }; - remotePlace.lineage = ['no-matter', greatParent.id]; - ambiguous.lineage = ['not-grandpa', 'not-grandpa']; + parentDoc.parent = { _id: 'no-matter', parent: { _id: greatParent._id } }; + ambiguous.parent = { _id: 'not-grandpa', parent: { _id: 'not-grandpa' } }; chtApi.getPlacesWithType .resolves([greatParent]) - .onSecondCall().resolves([remotePlace, ambiguous]); + .onSecondCall().resolves([parentDoc, ambiguous]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); expect(place.validationErrors).to.be.empty; - expect(place.resolvedHierarchy).to.deep.eq([undefined, remotePlace, undefined, greatParent]); + const resolvedHierarchyIds = place.resolvedHierarchy.map(h => h?.id); + expect(resolvedHierarchyIds).to.deep.eq([undefined, parentDoc._id, undefined, greatParent._id]); }); it('ambiguous place under single parent is invalid', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); fakeFormData.hierarchy_replacement = 'to-replace'; - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'to-replace', - lineage: [remotePlace.id], - type: 'remote', }; - const ambiguous = { + const ambiguous: ChtDoc = { ...toReplace, - id: 'id-replace-ambiguous', - parentId: remotePlace.id, + _id: 'id-replace-ambiguous', + parentId: parentDoc._id, }; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([parentDoc]) .onSecondCall() .resolves([toReplace, ambiguous]); @@ -344,17 +349,16 @@ describe('services/place-factory.ts', () => { }); it('replacement place not under parent is invalid', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'to-replace', - lineage: ['different-parent'], - type: 'remote', + parent: { _id: 'different-parent' }, }; fakeFormData.hierarchy_replacement = toReplace.name; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([parentDoc]) .onSecondCall().resolves([toReplace]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); @@ -364,9 +368,9 @@ describe('services/place-factory.ts', () => { }); it('place not under users facility is invalid', async () => { - const { remotePlace, sessionCache, contactType, parentContactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, parentContactType, fakeFormData, chtApi } = mockScenario(); const parent1 = mockParentPlace(parentContactType, fakeFormData.hierarchy_PARENT); - chtApi.getPlacesWithType.resolves([remotePlace]); + chtApi.getPlacesWithType.resolves([parentDoc]); chtApi.chtSession = mockChtSession('other'); fakeFormData.hierarchy_PARENT = parent1.name; @@ -375,31 +379,30 @@ describe('services/place-factory.ts', () => { }); it('#124 - testing replacement when place.name is generated', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); fakeFormData.hierarchy_replacement = 'dne'; contactType.place_properties[0] = { friendly_name: '124', property_name: 'name', type: 'generated', + required: true, parameter: '{{contact.name}} Area', }; - const otherPlace: RemotePlace = { - id: 'other-place', + const otherPlace: ChtDoc = { + _id: 'other-place', name: 'other-place', - lineage: [remotePlace.id], - type: 'remote', + parent: { _id: parentDoc._id }, }; - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'to-replace', - lineage: [remotePlace.id], - type: 'remote', + parent: { _id: parentDoc._id }, }; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([parentDoc]) .onSecondCall().resolves([toReplace, otherPlace]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); @@ -407,7 +410,7 @@ describe('services/place-factory.ts', () => { }); it('#124 - replacement_property is used for fuzzing during replacement', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + const { parentDoc, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); fakeFormData.hierarchy_replacement = 'to-replace'; contactType.replacement_property = { @@ -418,15 +421,14 @@ describe('services/place-factory.ts', () => { required: true }; - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'to-replace Area (village)', - lineage: [remotePlace.id], - type: 'remote', + parent: { _id: parentDoc._id }, }; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([parentDoc]) .onSecondCall().resolves([toReplace]); const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); @@ -435,31 +437,42 @@ describe('services/place-factory.ts', () => { expect(place.resolvedHierarchy[0]?.id).to.eq('id-replace'); }); - it('#124 - replacement_property cannot have type:"generated"', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); - fakeFormData.hierarchy_replacement = 'to-replace'; + it('assertion if data for a required level is totally missing', async () => { + const { sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + delete fakeFormData.hierarchy_PARENT; - contactType.replacement_property = { - friendly_name: 'Outgoing CHP', - property_name: 'replacement', + const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expectInvalidProperties(place.validationErrors, ['hierarchy_PARENT'], 'is empty'); + }); + + it('create a place even if generated property is required', async () => { + const { sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + contactType.place_properties[0] = { + friendly_name: 'CHP Area Name', + property_name: 'name', type: 'generated', parameter: '{{ contact.name }} Area', required: true }; + delete fakeFormData.place_name; - const toReplace: RemotePlace = { - id: 'id-replace', - name: 'to-replace Area', - lineage: [remotePlace.id], - type: 'remote', - }; + const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.be.empty; + }); - chtApi.getPlacesWithType - .resolves([remotePlace]) - .onSecondCall().resolves([toReplace]); + it('fail to create a place with missing generated property which is required', async () => { + const { sessionCache, contactType, fakeFormData, chtApi } = mockScenario(); + contactType.place_properties[0] = { + friendly_name: 'CHP Area Name', + property_name: 'name', + type: 'generated', + parameter: '{{ contact.dne }}', + required: true + }; + delete fakeFormData.place_name; - const createOne = PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - await expect(createOne).to.eventually.be.rejectedWith('cannot be of type "generated"'); + const place: Place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expectInvalidProperties(place.validationErrors, ['place_name'], 'Required'); }); }); @@ -480,16 +493,14 @@ it('#165 - create a place when generated property is required', async () => { function mockScenario() { const contactType = mockValidContactType('string', undefined); - const remotePlace: RemotePlace = { - id: 'parent-id', + const parentDoc: ChtDoc = { + _id: 'parent-id', name: 'parent-name', - type: 'remote', - lineage: [], }; const sessionCache = new SessionCache(); const chtApi = { chtSession: mockChtSession(), - getPlacesWithType: sinon.stub().resolves([remotePlace]), + getPlacesWithType: sinon.stub().resolves([parentDoc]), createPlace: sinon.stub().resolves('created-place-id'), updateContactParent: sinon.stub().resolves('created-contact-id'), createUser: sinon.stub().resolves(), @@ -497,14 +508,14 @@ function mockScenario() { const fakeFormData:any = { place_name: 'place', place_prop: 'foo', - hierarchy_PARENT: remotePlace.name, + hierarchy_PARENT: parentDoc.name, contact_name: 'contact', }; const parentContactType = mockValidContactType('string', undefined); parentContactType.name = contactType.hierarchy[0].contact_type; return { - remotePlace, + parentDoc, sessionCache, fakeFormData, parentContactType, diff --git a/test/services/place.spec.ts b/test/services/place.spec.ts index 4d172447..04f95898 100644 --- a/test/services/place.spec.ts +++ b/test/services/place.spec.ts @@ -2,28 +2,30 @@ import { expect } from 'chai'; import Place from '../../src/services/place'; import { mockSimpleContactType, mockValidContactType } from '../mocks'; -import RemotePlaceResolver from '../../src/lib/remote-place-resolver'; +import { UnvalidatedPropertyValue, ContactPropertyValue } from '../../src/property-value'; describe('services/place.ts', () => { it('setPropertiesFromFormData', () => { - const contactType = mockSimpleContactType('string', undefined); + const contactType = mockSimpleContactType('name', undefined); contactType.contact_properties = contactType.place_properties; const place = new Place(contactType); - place.properties.existing = 'existing'; + place.properties.existing = new ContactPropertyValue(place, contactType.place_properties[0], 'place_', 'existing'); const formData = { place_prop: 'abc', contact_prop: 'efg', garbage: 'ghj', }; - place.setPropertiesFromFormData(formData); + place.setPropertiesFromFormData(formData, 'hierarchy_'); - expect(place.properties).to.deep.eq({ - existing: 'existing', - prop: 'abc', + expect(place.properties).to.nested.include({ + 'existing.original': 'existing', + 'prop.original': 'abc', + 'prop.formattedValue': 'Abc', }); - expect(place.contact.properties).to.deep.eq({ - prop: 'efg', + expect(place.contact.properties).to.nested.include({ + 'prop.original': 'efg', + 'prop.formattedValue': 'Efg', }); }); @@ -31,13 +33,13 @@ describe('services/place.ts', () => { const contactType = mockSimpleContactType('string', undefined); contactType.contact_properties = contactType.place_properties; const place = new Place(contactType); - place.properties.existing = 'existing'; - place.properties.prop = 'abc'; - place.contact.properties.prop = 'efg'; - const actual = place.asFormData(); + place.properties.name = new UnvalidatedPropertyValue('name'); + place.properties.prop = new UnvalidatedPropertyValue('abc'); + place.contact.properties.prop = new UnvalidatedPropertyValue('efg'); + const actual = place.asFormData('hierachy_'); expect(actual).to.deep.eq({ - place_existing: 'existing', + place_name: 'name', place_prop: 'abc', contact_prop: 'efg', }); @@ -46,23 +48,23 @@ describe('services/place.ts', () => { it('basic asRemotePlace', () => { const contactType = mockSimpleContactType('string', undefined); const place = new Place(contactType); - place.properties.name = 'name'; + place.properties.name = new UnvalidatedPropertyValue('name'); place.resolvedHierarchy[0] = { id: 'to-replace', - name: 'replaced', + name: new UnvalidatedPropertyValue('replaced'), lineage: ['parent-id'], type: 'remote', }; place.resolvedHierarchy[1] = { id: 'parent-id', - name: 'parent', + name: new UnvalidatedPropertyValue('parent'), lineage: [], type: 'remote', }; const actual = place.asRemotePlace(); - expect(actual).to.deep.include({ - name: 'name', + expect(actual).to.deep.nested.include({ + 'name.original': 'name', type: 'local', lineage: ['parent-id'], }); @@ -71,40 +73,33 @@ describe('services/place.ts', () => { it('asRemotePlace with great grandfather (missing place in lineage)', () => { const contactType = mockSimpleContactType('string', undefined); const place = new Place(contactType); - place.properties.name = 'name'; + place.properties.name = new UnvalidatedPropertyValue('name'); place.resolvedHierarchy[0] = { id: 'to-replace', - name: 'replaced', + name: new UnvalidatedPropertyValue('replaced'), lineage: ['parent-id', 'grandparent-id', 'greatgrandparent-id'], type: 'remote', }; place.resolvedHierarchy[3] = { id: 'greatgrandparent-id', - name: 'greatgrandparent', + name: new UnvalidatedPropertyValue('greatgrandparent'), lineage: [], type: 'remote', }; const actual = place.asRemotePlace(); - expect(actual).to.deep.include({ - name: 'name', + expect(actual).to.deep.nested.include({ + 'name.original': 'name', type: 'local', lineage: ['parent-id', 'grandparent-id', 'greatgrandparent-id'], }); }); - it('asRemotePlace throws if hierarchy is invalid', () => { - const contactType = mockSimpleContactType('string', undefined); - const place = new Place(contactType); - place.resolvedHierarchy[1] = RemotePlaceResolver.Multiple; - expect(() => place.asRemotePlace()).to.throw('invalid hierarchy'); - }); - it('generateUsername shouldnt have double underscores', () => { const contactType = mockSimpleContactType('string', undefined); const place = new Place(contactType); - place.contact.properties.name = 'Migwani / Itoloni'; + place.contact.properties.name = new ContactPropertyValue(place, contactType.place_properties[0], 'place_', 'Migwani / Itoloni'); const actual = place.generateUsername(); expect(actual).to.eq('migwani_itoloni'); @@ -141,7 +136,6 @@ describe('services/place.ts', () => { contactType.user_role = ['role1', 'role2']; contactType.contact_properties = contactType.place_properties; const place = new Place(contactType); - place.properties.existing = 'existing'; const formData = { place_prop: 'abc', @@ -149,15 +143,8 @@ describe('services/place.ts', () => { garbage: 'ghj', user_role: 'role1 role2', }; - place.setPropertiesFromFormData(formData); + place.setPropertiesFromFormData(formData, 'hierarchy_'); - expect(place.properties).to.deep.eq({ - existing: 'existing', - prop: 'abc', - }); - expect(place.contact.properties).to.deep.eq({ - prop: 'efg', - }); expect(place.userRoles).to.deep.eq([ 'role1', 'role2', diff --git a/test/services/upload-manager.spec.ts b/test/services/upload-manager.spec.ts index f9a831d7..0fca6ea5 100644 --- a/test/services/upload-manager.spec.ts +++ b/test/services/upload-manager.spec.ts @@ -3,10 +3,9 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { UploadManager } from '../../src/services/upload-manager'; -import { mockValidContactType, mockParentPlace, mockChtSession, expectInvalidProperties } from '../mocks'; +import { mockValidContactType, mockParentPlace, mockChtSession, expectInvalidProperties, ChtDoc, createChu } from '../mocks'; import PlaceFactory from '../../src/services/place-factory'; import SessionCache from '../../src/services/session-cache'; -import { ChtApi, RemotePlace } from '../../src/lib/cht-api'; import RemotePlaceCache from '../../src/lib/remote-place-cache'; import { Config } from '../../src/config'; import RemotePlaceResolver from '../../src/lib/remote-place-resolver'; @@ -17,8 +16,8 @@ describe('services/upload-manager.ts', () => { RemotePlaceCache.clear({}); }); - it('mock data is properly sent to chtApi', async () => { - const { fakeFormData, contactType, chtApi, sessionCache, remotePlace } = await createMocks(); + it('mock data is properly sent to chtApi - standard', async () => { + const { fakeFormData, contactType, chtApi, sessionCache, subcounty } = await createMocks(); const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); const uploadManager = new UploadManager(); @@ -31,7 +30,7 @@ describe('services/upload-manager.ts', () => { 'contact.name': 'contact', prop: 'foo', name: 'Place Community Health Unit', - parent: remotePlace.id, + parent: subcounty._id, contact_type: contactType.name, }); expect(chtApi.updateContactParent.calledOnce).to.be.true; @@ -49,19 +48,19 @@ describe('services/upload-manager.ts', () => { expect(place.isCreated).to.be.true; }); - it('mock data is properly sent to chtApi (sessionCache cache)', async () => { - const { fakeFormData, contactType, sessionCache, chtApi, remotePlace } = await createMocks(); + it('mock data is properly sent to chtApi - sessionCache cache', async () => { + const { fakeFormData, contactType, sessionCache, chtApi, subcounty } = await createMocks(); const parentContactType = mockValidContactType('string', undefined); - parentContactType.name = remotePlace.name; + parentContactType.name = subcounty.name; - const parentPlace = mockParentPlace(parentContactType, remotePlace.name); + const parentPlace = mockParentPlace(parentContactType, subcounty.name); sessionCache.savePlaces(parentPlace); const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); const uploadManager = new UploadManager(); await uploadManager.doUpload([place], chtApi); - expect(chtApi.getPlacesWithType.calledTwice).to.be.true; + expect(chtApi.getPlacesWithType.callCount).to.eq(1); expect(chtApi.getPlacesWithType.args[0]).to.deep.eq(['parent']); expect(chtApi.deleteDoc.called).to.be.false; expect(place.isCreated).to.be.true; @@ -79,20 +78,19 @@ describe('services/upload-manager.ts', () => { }); it('required attributes can be inherited during replacement', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); + const { subcounty, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); fakeFormData.hierarchy_replacement = 'to-replace'; fakeFormData.place_prop = ''; // required during creation, but can be empty (ui) or undefined (csv) fakeFormData.place_name = undefined; - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'to-replace', - lineage: [remotePlace.id], - type: 'remote', + parent: { _id: subcounty._id }, }; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([subcounty]) .onSecondCall() .resolves([toReplace]); @@ -109,21 +107,20 @@ describe('services/upload-manager.ts', () => { }); it('contact_type replacement with username_from_place:true', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); + const { subcounty, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); contactType.username_from_place = true; fakeFormData.hierarchy_replacement = 'replacement based username'; fakeFormData.place_name = ''; // optional due to replacement - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'replac"e$mENT baSed username', - lineage: [remotePlace.id], - type: 'remote', + parent: { _id: subcounty._id }, }; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([subcounty]) .onSecondCall() .resolves([toReplace]); @@ -139,21 +136,20 @@ describe('services/upload-manager.ts', () => { }); it('contact_type replacement with deactivate_users_on_replace:true', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); + const { subcounty, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); contactType.deactivate_users_on_replace = true; fakeFormData.hierarchy_replacement = 'deactivate me'; fakeFormData.place_name = ''; // optional due to replacement - const toReplace: RemotePlace = { - id: 'id-replace', + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'deactivate me', - lineage: [remotePlace.id], - type: 'remote', + parent: { _id: subcounty._id }, }; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([subcounty]) .onSecondCall() .resolves([toReplace]); @@ -183,11 +179,11 @@ describe('services/upload-manager.ts', () => { }); it('uploading a chu and dependant chp where chp is created first', async () => { - const { remotePlace, sessionCache, chtApi } = await createMocks(); + const { subcounty, sessionCache, chtApi } = await createMocks(); chtApi.getPlacesWithType .resolves([]) // parent of chp - .onSecondCall().resolves([remotePlace]) // grandparent of chp (subcounty) + .onSecondCall().resolves([subcounty]) // grandparent of chp (subcounty) .onThirdCall().resolves([]); // chp replacements const chu_name = 'new chu'; @@ -203,7 +199,7 @@ describe('services/upload-manager.ts', () => { const chp = await PlaceFactory.createOne(chpData, chpType, sessionCache, chtApi); expectInvalidProperties(chp.validationErrors, ['hierarchy_CHU'], 'Cannot find'); - const chu = await createChu(remotePlace, chu_name, sessionCache, chtApi); + const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); // refresh the chp await RemotePlaceResolver.resolveOne(chp, sessionCache, chtApi, { fuzz: true }); @@ -220,22 +216,17 @@ describe('services/upload-manager.ts', () => { // chu is created first expect(chtApi.createUser.args[0][0].roles).to.deep.eq(['community_health_assistant']); expect(chtApi.createUser.args[1][0].roles).to.deep.eq(['community_health_volunteer']); - - const cachedChus = await RemotePlaceCache.getPlacesWithType(chtApi, chu.type.name); - expect(cachedChus).to.have.property('length', 1); - const cachedChps = await RemotePlaceCache.getPlacesWithType(chtApi, chp.type.name); - expect(cachedChps).to.have.property('length', 1); }); it('failure to upload', async () => { - const { remotePlace, sessionCache, chtApi } = await createMocks(); + const { subcounty, sessionCache, chtApi } = await createMocks(); chtApi.createUser .throws({ response: { status: 404 }, toString: () => 'upload-error' }) .onSecondCall().resolves(); const chu_name = 'new chu'; - const chu = await createChu(remotePlace, chu_name, sessionCache, chtApi); + const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); const uploadManager = new UploadManager(); await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); @@ -268,13 +259,13 @@ describe('services/upload-manager.ts', () => { }); it('#146 - error details are clear when CHT returns a string', async () => { - const { remotePlace, sessionCache, chtApi } = await createMocks(); + const { subcounty, sessionCache, chtApi } = await createMocks(); const errorString = 'foo'; chtApi.createPlace.throws({ response: { data: errorString } }); const chu_name = 'new chu'; - const chu = await createChu(remotePlace, chu_name, sessionCache, chtApi); + const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); const uploadManager = new UploadManager(); await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); @@ -284,12 +275,12 @@ describe('services/upload-manager.ts', () => { }); it(`createUser is retried`, async() => { - const { remotePlace, sessionCache, chtApi } = await createMocks(); + const { subcounty, sessionCache, chtApi } = await createMocks(); chtApi.createUser.throws(UploadManagerRetryScenario.axiosError); const chu_name = 'new chu'; - const chu = await createChu(remotePlace, chu_name, sessionCache, chtApi); + const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); const uploadManager = new UploadManager(); await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); @@ -298,8 +289,8 @@ describe('services/upload-manager.ts', () => { expect(chu.uploadError).to.include('could not create user'); }); - it('mock data is properly sent to chtApi (multiple roles)', async () => { - const { fakeFormData, contactType, chtApi, sessionCache, remotePlace } = await createMocks(); + it('mock data is properly sent to chtApi - multiple roles', async () => { + const { fakeFormData, contactType, chtApi, sessionCache, subcounty } = await createMocks(); contactType.user_role = ['role1', 'role2']; fakeFormData.user_role = 'role1 role2'; @@ -316,7 +307,7 @@ describe('services/upload-manager.ts', () => { 'contact.name': 'contact', prop: 'foo', name: 'Place Community Health Unit', - parent: remotePlace.id, + parent: subcounty._id, contact_type: contactType.name, }); expect(chtApi.updateContactParent.calledOnce).to.be.true; @@ -334,19 +325,17 @@ describe('services/upload-manager.ts', () => { }); it('#173 - replacement when place has no primary contact', async () => { - const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); - const toReplace: RemotePlace = { - id: 'id-replace', + const { subcounty, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); + const toReplace: ChtDoc = { + _id: 'id-replace', name: 'to-replace', - lineage: [remotePlace.id], - type: 'remote', }; chtApi.updatePlace.resolves({ _id: 'updated-place-id' }); fakeFormData.hierarchy_replacement = toReplace.name; chtApi.getPlacesWithType - .resolves([remotePlace]) + .resolves([subcounty]) .onSecondCall() .resolves([toReplace]); @@ -360,34 +349,16 @@ describe('services/upload-manager.ts', () => { }); }); -async function createChu(remotePlace: RemotePlace, chu_name: string, sessionCache: any, chtApi: ChtApi) { - const chuType = Config.getContactType('c_community_health_unit'); - const chuData = { - hierarchy_SUBCOUNTY: remotePlace.name, - place_name: chu_name, - place_code: '676767', - place_link_facility_name: 'facility name', - place_link_facility_code: '23456', - contact_name: 'new cha', - contact_phone: '0712345678', - }; - const chu = await PlaceFactory.createOne(chuData, chuType, sessionCache, chtApi); - expect(chu.validationErrors).to.be.empty; - return chu; -} - async function createMocks() { const contactType = mockValidContactType('string', undefined); - const remotePlace: RemotePlace = { - id: 'parent-id', + const subcounty: ChtDoc = { + _id: 'parent-id', name: 'parent-name', - type: 'remote', - lineage: [], }; const sessionCache = new SessionCache(); const chtApi = { chtSession: mockChtSession(), - getPlacesWithType: sinon.stub().resolves([remotePlace]), + getPlacesWithType: sinon.stub().resolves([subcounty]), createPlace: sinon.stub().resolves('created-place-id'), updateContactParent: sinon.stub().resolves('created-contact-id'), createUser: sinon.stub().resolves(), @@ -408,9 +379,9 @@ async function createMocks() { const fakeFormData: any = { place_name: 'place', place_prop: 'foo', - hierarchy_PARENT: remotePlace.name, + hierarchy_PARENT: subcounty.name, contact_name: 'contact' }; - return { fakeFormData, contactType, sessionCache, chtApi, remotePlace }; + return { fakeFormData, contactType, sessionCache, chtApi, subcounty }; } diff --git a/test/single.csv b/test/single.csv index 0989a531..36a37bc8 100644 --- a/test/single.csv +++ b/test/single.csv @@ -1,2 +1,2 @@ Outgoing CHP,CHU,CHP Area Name,CHP Name,CHP Phone -Bob,Chepalungu,Sally,Sally,0712345678 \ No newline at end of file +Bob,chepalungu,Sally,Sally,0712345678 \ No newline at end of file diff --git a/test/validation.spec.ts b/test/validation.spec.ts new file mode 100644 index 00000000..a85ee3a2 --- /dev/null +++ b/test/validation.spec.ts @@ -0,0 +1,206 @@ +import { DateTime } from 'luxon'; +import { expect } from 'chai'; + +import { mockPlace, mockSimpleContactType } from './mocks'; +import RemotePlaceResolver from '../src/lib/remote-place-resolver'; +import { UnvalidatedPropertyValue } from '../src/property-value'; + +type Scenario = { + type: string; + prop?: string; + isValid: boolean; + propertyParameter?: string | string[] | object; + formatted?: string; + propertyErrorDescription?: string; + error?: string; +}; + +const EMAIL_REGEX = '^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$'; +const GENDER_OPTIONS = { male: 'Male', female: 'Female' }; +const CANDIES_OPTIONS = { chocolate: 'Chocolate', strawberry: 'Strawberry' }; + +const scenarios: Scenario[] = [ + { type: 'string', prop: undefined, isValid: false, error: 'Required' }, + { type: 'string', prop: 'abc', isValid: true }, + { type: 'string', prop: ' ab\nc', isValid: true, formatted: 'abc' }, + { type: 'string', prop: 'Mr. Sand(m-a-n)', isValid: true, formatted: 'Mr. Sand(m-a-n)' }, + { type: 'string', prop: 'Université ', isValid: true, formatted: 'Université' }, + { type: 'string', prop: `Infirmière d'Etat`, isValid: true, formatted: `Infirmière d'Etat` }, + { type: 'string', prop: '', isValid: false, formatted: '', error: 'Required' }, + + { type: 'phone', prop: undefined, isValid: false, error: 'Required' }, + { type: 'phone', prop: '+254712345678', isValid: true, formatted: '0712 345678', propertyParameter: 'KE' }, + { type: 'phone', prop: '712345678', isValid: true, formatted: '0712 345678', propertyParameter: 'KE' }, + { type: 'phone', prop: '+254712345678', isValid: false, formatted: '0712 345678', propertyParameter: 'UG', error: 'Not a valid' }, + { type: 'phone', prop: '+17058772274', isValid: false, formatted: '(705) 877-2274', propertyParameter: 'KE', error: 'KE' }, + + { type: 'regex', prop: undefined, isValid: false, error: 'Required' }, + { type: 'regex', propertyParameter: '^\\d{6}$', prop: '123456', isValid: true }, + { type: 'regex', propertyParameter: '^\\d{6}$', prop: ' 123456 *&%', isValid: true, formatted: '123456' }, + { type: 'regex', propertyParameter: '^\\d{6}$', prop: '1234567', isValid: false, error: 'six digit', propertyErrorDescription: 'six digit number' }, + { type: 'regex', propertyParameter: EMAIL_REGEX, prop: 'email@address.com', isValid: true, formatted: 'email@address.com' }, + { type: 'regex', propertyParameter: EMAIL_REGEX, prop: '.com', isValid: false, propertyErrorDescription: 'valid email address', error: 'email' }, + { type: 'regex', propertyParameter: undefined, prop: 'abc', isValid: false, error: 'missing parameter' }, + + { type: 'name', prop: undefined, isValid: false, error: 'Required' }, + { type: 'name', prop: 'abc', isValid: true, formatted: 'Abc' }, + { type: 'name', prop: 'a b c', isValid: true, formatted: 'A B C' }, + { type: 'name', prop: 'Mr. Sand(m-a-n)', isValid: true, formatted: 'Mr Sand(m-a-n)' }, + { type: 'name', prop: 'WELDON KO(E)CH \n', isValid: true, formatted: 'Weldon Ko(e)ch' }, + { type: 'name', prop: 'S \'am \'s', isValid: true, formatted: 'S\'am\'s' }, + { type: 'name', prop: 'KYAMBOO/KALILUNI', isValid: true, formatted: 'Kyamboo / Kaliluni' }, + { type: 'name', prop: 'NZATANI / ILALAMBYU', isValid: true, formatted: 'Nzatani / Ilalambyu' }, + { type: 'name', prop: 'Sam\'s CHU', propertyParameter: ['CHU', 'Comm Unit'], isValid: true, formatted: 'Sam\'s' }, + { type: 'name', prop: 'Jonathan M.Barasa', isValid: true, formatted: 'Jonathan M Barasa' }, + { type: 'name', prop: 'Robert xiv', isValid: true, formatted: 'Robert XIV' }, + { type: 'name', prop: ' ', isValid: true, formatted: '' }, + + { type: 'dob', prop: undefined, isValid: false, error: 'Required' }, + { type: 'dob', prop: '', isValid: false }, + { type: 'dob', prop: '2016/05/25', isValid: false }, + { type: 'dob', prop: 'May 25, 2016', isValid: false }, + { type: 'dob', prop: '2030-05-25', isValid: false }, + { type: 'dob', prop: '2016-05-25', isValid: true, formatted: '2016-05-25' }, + { type: 'dob', prop: ' 20 16- 05- 25 ', isValid: true, formatted: '2016-05-25' }, + { type: 'dob', prop: '20', isValid: true, formatted: DateTime.now().minus({ years: 20 }).toISODate() }, + { type: 'dob', prop: ' 20 ', isValid: true, formatted: DateTime.now().minus({ years: 20 }).toISODate() }, + { type: 'dob', prop: 'abc', isValid: false, formatted: 'abc' }, + { type: 'dob', prop: ' 1 0 0 ', isValid: true, formatted: DateTime.now().minus({ years: 100 }).toISODate() }, + { type: 'dob', prop: '-1', isValid: false, formatted: '-1' }, + { type: 'dob', prop: '15/2/1985', isValid: true, formatted: '1985-02-15' }, + { type: 'dob', prop: '1/2/1 985', isValid: true, formatted: '1985-02-01' }, + { type: 'dob', prop: '1/13/1985', isValid: false }, + + { type: 'select_one', prop: undefined, isValid: false, error: 'Required' }, + { type: 'select_one', prop: ' male', isValid: true, propertyParameter: GENDER_OPTIONS }, + { type: 'select_one', prop: 'female ', isValid: true, propertyParameter: GENDER_OPTIONS }, + { type: 'select_one', prop: 'FeMale ', isValid: false, propertyParameter: GENDER_OPTIONS }, + { type: 'select_one', prop: 'f', isValid: false, propertyParameter: GENDER_OPTIONS }, + { type: 'select_one', prop: '', isValid: false, propertyParameter: GENDER_OPTIONS }, + + { type: 'select_multiple', prop: undefined, isValid: false, error: 'Required' }, + { type: 'select_multiple', prop: 'chocolate', isValid: true, propertyParameter: CANDIES_OPTIONS }, + { type: 'select_multiple', prop: 'chocolate strawberry', isValid: true, propertyParameter: CANDIES_OPTIONS }, + { type: 'select_multiple', prop: ' chocolate strawberry', isValid: true, propertyParameter: CANDIES_OPTIONS }, + { type: 'select_multiple', prop: 'c,s', isValid: false, propertyParameter: CANDIES_OPTIONS, error: 'Invalid values' }, + { type: 'select_multiple', prop: '', isValid: false, propertyParameter: CANDIES_OPTIONS, error: 'Required' }, + + { type: 'generated', prop: 'b', propertyParameter: 'a {{ place.prop }} c', isValid: true, formatted: 'a b c' }, + { type: 'generated', prop: 'b', propertyParameter: '{{ contact.name }} ({{ lineage.PARENT }})', isValid: true, formatted: 'Contact (Parent)' }, + { type: 'generated', prop: 'b', propertyParameter: 'x {{ contact.dne }}', isValid: true, formatted: 'x ' }, +]; + +describe('validation', () => { + for (const scenario of scenarios) { + it(`scenario: ${JSON.stringify(scenario)}`, () => { + const contactType = mockSimpleContactType(scenario.type, scenario.propertyParameter, scenario.propertyErrorDescription); + contactType.contact_properties = [contactType.place_properties[0]]; + const place = mockPlace(contactType, { place_prop: scenario.prop }); + + const actualValidity = Object.keys(place.validationErrors || {}); + expect(actualValidity).to.deep.eq(scenario.isValid ? [] : ['place_prop']); + + if (scenario.error) { + const firstError = Object.values(place.validationErrors || {})[0]; + expect(firstError).to.include(scenario.error); + } + + expect(place.properties.prop.formatted).to.eq(scenario.formatted ?? scenario.prop); + }); + } + + it('unknown property type throws', () => { + const contactType = mockSimpleContactType('unknown`', undefined); + expect(() => mockPlace(contactType)).to.throw('unvalidatable'); + }); + + it('property with required:false can be empty', () => { + const contactType = mockSimpleContactType('string', undefined); + contactType.place_properties[contactType.place_properties.length-1].required = false; + + const place = mockPlace(contactType); + place.properties = { name: new UnvalidatedPropertyValue('name') }; + place.hierarchyProperties = { PARENT: new UnvalidatedPropertyValue('parent', 'PARENT') }; + + place.validate(); + expect(place.validationErrors).to.be.empty; + }); + + it('#91 - parent is invalid when required:false but resolution is NoResult', () => { + const contactType = mockSimpleContactType('string', undefined); + contactType.hierarchy[0].required = false; + + const place = mockPlace(contactType); + place.resolvedHierarchy[1] = RemotePlaceResolver.NoResult; + + place.validate(); + expect(place.validationErrors).to.deep.eq({ + hierarchy_PARENT: `Cannot find 'parent' matching 'parent'` + }); + }); + + it('parent is invalid when missing but expected', () => { + const contactType = mockSimpleContactType('string', undefined); + const place = mockPlace(contactType); + delete place.resolvedHierarchy[1]; + + place.validate(); + expect(place.validationErrors).to.deep.eq({ + hierarchy_PARENT: `Cannot find 'parent' matching 'parent'`, + }); + }); + + it('parent is valid when missing and not expected', () => { + const contactType = mockSimpleContactType('string', undefined); + contactType.hierarchy[0].required = false; + + const place = mockPlace(contactType); + delete place.resolvedHierarchy[1]; + + place.validate(); + expect(place.validationErrors).to.be.empty; + }); + + it('replacement property is validated and formatted as property_name:name', () => { + const contactType = mockSimpleContactType('string', undefined); + + const place = mockPlace(contactType, { hierarchy_replacement: 'sin bad' }); + expect(place.hierarchyProperties.replacement.formatted).to.eq('Sin Bad'); + + place.validate(); + expect(place.validationErrors).to.deep.eq({ + hierarchy_replacement: `Cannot find 'contacttype-name' matching 'sin bad' under 'parent'`, + }); + }); + + it('user_role property empty throws', () => { + const contactType = mockSimpleContactType('string', undefined); + contactType.user_role = []; + + expect(() => mockPlace(contactType)).to.throw('unvalidatable'); + }); + + it('user_role property contains empty string throws', () => { + const contactType = mockSimpleContactType('string', undefined); + contactType.user_role = ['']; + + expect(() => mockPlace(contactType)).to.throw('unvalidatable'); + }); + + it('user role is invalid when not allowed', () => { + const contactType = mockSimpleContactType('string', undefined); + contactType.user_role = ['supervisor', 'stock_manager']; + + const place = mockPlace(contactType, { + place_prop: 'abc', + contact_prop: 'efg', + garbage: 'ghj', + user_role: 'supervisor stockmanager', + }); + + place.validate(); + expect(place.validationErrors).to.deep.eq({ + user_role: `Invalid values for property "Roles": stockmanager` + }); + }); +});