From 73f4cc21125e05e89b58029220e593ea1eb6d132 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 1 May 2024 16:20:41 -0700 Subject: [PATCH 01/12] Build the foundations --- .../create-user-managers.ts | 12 +- .../create-user-managers/ke_user_manager.json | 10 +- src/config/config-factory.ts | 2 +- src/config/index.ts | 38 ++- src/index.ts | 7 +- src/lib/cht-api.ts | 39 +-- src/lib/cht-session.ts | 2 +- src/lib/move.ts | 4 +- src/lib/remote-place-cache.ts | 57 +++- 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/move.ts | 5 +- 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/move.spec.ts | 23 +- test/lib/remote-place-cache.spec.ts | 56 +++- test/lib/search.spec.ts | 53 ++- test/lib/validation.spec.ts | 217 ------------ test/mocks.ts | 76 +++-- 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 | 109 +++--- test/single.csv | 2 +- test/validation.spec.ts | 206 ++++++++++++ 49 files changed, 1326 insertions(+), 976 deletions(-) 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 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/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 1b5b9af8..32ad590c 100644 --- a/src/config/config-factory.ts +++ b/src/config/config-factory.ts @@ -3,7 +3,7 @@ import ugandaConfig from './chis-ug'; import kenyaConfig from './chis-ke'; import togoConfig from './chis-tg'; -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 7dca3fe5..7850fc4f 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[]; @@ -27,10 +28,13 @@ export type ContactType = { deactivate_users_on_replace: boolean; }; +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; @@ -42,7 +46,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; @@ -54,6 +58,7 @@ export type AuthenticationInfo = { useHttp?: boolean; }; + const { CONFIG_NAME, NODE_ENV, @@ -186,6 +191,33 @@ export class Config { return _.sortBy(domains, 'friendly'); } + // TODO: Joi? Chai? + public static assertIfInvalid({ 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); @@ -204,3 +236,5 @@ export class Config { return columns; } } + +Config.assertIfInvalid(); diff --git a/src/index.ts b/src/index.ts index af678aaf..b03f14e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,13 +8,8 @@ const { const port: number = env.PORT ? parseInt(env.PORT) : 3000; (async () => { - const loggerConfig = { - transport: { - target: 'pino-pretty', - }, - }; const server = build({ - logger: loggerConfig, + logger: false, }); // 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 aa2bce26..924d5a09 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; @@ -172,26 +161,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 => { @@ -226,10 +204,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 8942de7d..f640ba4e 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/move.ts b/src/lib/move.ts index 706a0d51..7974697f 100644 --- a/src/lib/move.ts +++ b/src/lib/move.ts @@ -18,7 +18,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 { authInfo } = chtApi.chtSession; @@ -37,7 +37,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..2545cd99 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 { @@ -34,14 +55,14 @@ export default class RemotePlaceCache { } } - 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/move.ts b/src/routes/move.ts index c2a3cdc7..029b0cdd 100644 --- a/src/routes/move.ts +++ b/src/routes/move.ts @@ -34,7 +34,10 @@ export default async function sessionCache(fastify: FastifyInstance) { const chtApi = new ChtApi(req.chtSession); try { - const tmplData = await MoveLib.move(formData, contactType, sessionCache, chtApi); + const tmplData = { + session: req.chtSession, + ...await MoveLib.move(formData, contactType, sessionCache, chtApi), + }; return resp.view('src/liquid/components/move_result.html', tmplData); } catch (e: any) { const tmplData = { 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 22a1a61d..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 { - 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; } - - Validation.format(this); } 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 8a4a907e..0c646b59 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)}`); @@ -120,7 +120,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..cdfb542c --- /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.assertIfInvalid(mockConfig); + }); + + it('assert on unknown property type', () => { + const mockConfig = mockPartnerConfig(); + mockConfig.config.contact_types[0].hierarchy[0].type = 'unknown'; + const assertion = () => Config.assertIfInvalid(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.assertIfInvalid(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.assertIfInvalid(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.assertIfInvalid(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.assertIfInvalid(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.assertIfInvalid(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 f67a73ea..0bfa48ff 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/move.spec.ts b/test/lib/move.spec.ts index 583164bb..abafeaf0 100644 --- a/test/lib/move.spec.ts +++ b/test/lib/move.spec.ts @@ -5,18 +5,25 @@ 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'; Chai.use(chaiAsPromised); const { expect } = Chai; describe('lib/move.ts', () => { - 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' }], - ); + beforeEach(() => { + RemotePlaceCache.clear({}); + }); + + 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); it('move CHU: success', async () => { const formData = { @@ -44,7 +51,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()); + const actual = MoveLib.move(formData, contactType, sessionCache, mockChtApi(subcountyDocs)); await expect(actual).to.eventually.be.rejectedWith('search string is empty'); }); diff --git a/test/lib/remote-place-cache.spec.ts b/test/lib/remote-place-cache.spec.ts index ae205267..db9a7e3e 100644 --- a/test/lib/remote-place-cache.spec.ts +++ b/test/lib/remote-place-cache.spec.ts @@ -1,45 +1,67 @@ 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; }); }); 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 ca474b3e..34c645e0 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -1,39 +1,47 @@ 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 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 type ChtDoc = { + _id: string; + name: string; + [key: string]: string | Object; +}; + +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); @@ -57,10 +65,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', @@ -93,12 +119,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 7b6864b5..31857032 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,46 +437,55 @@ 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'); }); }); 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(), @@ -482,14 +493,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 6cb6cc46..04af654a 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,34 +325,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(), @@ -382,9 +355,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` + }); + }); +}); From 2e2f1898ab3446f7e83d7526e481df627a489b2f Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 15 May 2024 14:58:51 -0700 Subject: [PATCH 02/12] Fred code review --- src/config/index.ts | 3 +-- src/index.ts | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 7850fc4f..e19fb581 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -192,7 +192,7 @@ export class Config { } // TODO: Joi? Chai? - public static assertIfInvalid({ config }: PartnerConfig = partnerConfig) { + public static assertValid({ config }: PartnerConfig = partnerConfig) { for (const contactType of config.contact_types) { const allHierarchyProperties = [...contactType.hierarchy, contactType.replacement_property]; const allProperties = [ @@ -237,4 +237,3 @@ export class Config { } } -Config.assertIfInvalid(); diff --git a/src/index.ts b/src/index.ts index b03f14e0..8a869abc 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,10 +7,11 @@ const { } = process.env; const port: number = env.PORT ? parseInt(env.PORT) : 3000; +Config.assertValid(); (async () => { const server = build({ - logger: false, + logger: true, }); // in 1.1.0 we allowed INTERFACE to be declared in .env, but let's be From 9ad76a030e4ec549113cd1097ea52aa3ffdfb0d5 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 15 May 2024 15:03:07 -0700 Subject: [PATCH 03/12] Fix tests --- test/config.spec.ts | 14 +++++++------- test/lib/move.spec.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/config.spec.ts b/test/config.spec.ts index cdfb542c..81a8ba35 100644 --- a/test/config.spec.ts +++ b/test/config.spec.ts @@ -15,48 +15,48 @@ const mockPartnerConfig = (): PartnerConfig => ({ describe('config', () => { it('mock partner config is valid', () => { const mockConfig = mockPartnerConfig(); - Config.assertIfInvalid(mockConfig); + Config.assertValid(mockConfig); }); it('assert on unknown property type', () => { const mockConfig = mockPartnerConfig(); mockConfig.config.contact_types[0].hierarchy[0].type = 'unknown'; - const assertion = () => Config.assertIfInvalid(mockConfig); + 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.assertIfInvalid(mockConfig); + 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.assertIfInvalid(mockConfig); + 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.assertIfInvalid(mockConfig); + 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.assertIfInvalid(mockConfig); + 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.assertIfInvalid(partnerConfig); + Config.assertValid(partnerConfig); }); } }); diff --git a/test/lib/move.spec.ts b/test/lib/move.spec.ts index 48d1c206..abafeaf0 100644 --- a/test/lib/move.spec.ts +++ b/test/lib/move.spec.ts @@ -78,7 +78,7 @@ describe('lib/move.ts', () => { const sessionCache = new SessionCache(); const actual = MoveLib.move(formData, contactType, sessionCache, chtApi()); - 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\''); }); }); From c591f6bcc958ec661035d658cc65330b1e2f34bc Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 22 May 2024 00:16:22 -0700 Subject: [PATCH 04/12] Fix failing test --- test/services/upload-manager.spec.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/services/upload-manager.spec.ts b/test/services/upload-manager.spec.ts index 16f96ae7..0fca6ea5 100644 --- a/test/services/upload-manager.spec.ts +++ b/test/services/upload-manager.spec.ts @@ -325,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]); From 2a5a813afd6d1b77f7e6a6eb7b2f1bb7393973c2 Mon Sep 17 00:00:00 2001 From: freddieptf Date: Tue, 26 Nov 2024 06:07:33 +0300 Subject: [PATCH 05/12] fix lint errors --- src/index.ts | 1 - test/lib/move.spec.ts | 8 -------- test/mocks.ts | 15 --------------- 3 files changed, 24 deletions(-) diff --git a/src/index.ts b/src/index.ts index cd7d9077..b94d480b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ require('dotenv').config(); -import { Config } from './config'; import build from './server'; import { env } from 'process'; const { diff --git a/test/lib/move.spec.ts b/test/lib/move.spec.ts index 9023b8e2..e0e1fa45 100644 --- a/test/lib/move.spec.ts +++ b/test/lib/move.spec.ts @@ -39,14 +39,6 @@ describe('lib/move.ts', () => { 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', diff --git a/test/mocks.ts b/test/mocks.ts index 454e98d3..6fdee4f9 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -23,22 +23,7 @@ export const mockPlace = (contactType: ContactType, formDataOverride?: any) : Pl }, formDataOverride); const place = new Place(contactType); place.setPropertiesFromFormData(formData, 'hierarchy_'); - place.resolvedHierarchy[1] = { - -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] = { id: 'known', name: new UnvalidatedPropertyValue('parent'), lineage: [], From cb43cc4e533b8928a27953007c0d1c62b0baacf8 Mon Sep 17 00:00:00 2001 From: freddieptf Date: Tue, 26 Nov 2024 06:13:24 +0300 Subject: [PATCH 06/12] revert config validation --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index b94d480b..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,6 +7,7 @@ const { } = process.env; const port: number = env.PORT ? parseInt(env.PORT) : 3500; +Config.assertValid(); (async () => { const server = build({ From 6a7622603d2c36cf45fa823928f2b457a8702198 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 3 Dec 2024 16:54:40 -0800 Subject: [PATCH 07/12] Fix for /files/credentials requests --- src/lib/credentials-file.ts | 52 +++++++++++++++++++++++++++++++ src/routes/files.ts | 41 +++++------------------- test/lib/credentials-file.spec.ts | 33 ++++++++++++++++++++ 3 files changed, 93 insertions(+), 33 deletions(-) create mode 100644 src/lib/credentials-file.ts create mode 100644 test/lib/credentials-file.spec.ts diff --git a/src/lib/credentials-file.ts b/src/lib/credentials-file.ts new file mode 100644 index 00000000..24ff4eb0 --- /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 getCredentialsFileStream(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, + 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/routes/files.ts b/src/routes/files.ts index 86b3236a..ee5549e4 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 getCredentialsFileStream 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 = getCredentialsFileStream(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/test/lib/credentials-file.spec.ts b/test/lib/credentials-file.spec.ts new file mode 100644 index 00000000..e6e5ad70 --- /dev/null +++ b/test/lib/credentials-file.spec.ts @@ -0,0 +1,33 @@ +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' + }; + 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,, +` + }]); + }); +}); From 975489ea3a9a5780126f6efee6ab8f5f49a81253 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 3 Dec 2024 16:55:34 -0800 Subject: [PATCH 08/12] Missed a rename --- src/lib/credentials-file.ts | 2 +- src/routes/files.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/credentials-file.ts b/src/lib/credentials-file.ts index 24ff4eb0..a625a7b1 100644 --- a/src/lib/credentials-file.ts +++ b/src/lib/credentials-file.ts @@ -7,7 +7,7 @@ type File = { content: string; }; -export default function getCredentialsFileStream(sessionCache: SessionCache, contactTypes: ContactType[]): File[] { +export default function getCredentialsFiles(sessionCache: SessionCache, contactTypes: ContactType[]): File[] { const files: File[] = []; for (const contactType of contactTypes) { const places = sessionCache.getPlaces({ type: contactType.name }); diff --git a/src/routes/files.ts b/src/routes/files.ts index ee5549e4..44c16f6e 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -3,7 +3,7 @@ import JSZip from 'jszip'; import { stringify } from 'csv/sync'; import { Config } from '../config'; -import getCredentialsFileStream from '../lib/credentials-file'; +import getCredentialsFiles from '../lib/credentials-file'; import SessionCache from '../services/session-cache'; export default async function files(fastify: FastifyInstance) { @@ -18,7 +18,7 @@ export default async function files(fastify: FastifyInstance) { const sessionCache: SessionCache = req.sessionCache; const zip = new JSZip(); - const files = getCredentialsFileStream(sessionCache, Config.contactTypes()); + const files = getCredentialsFiles(sessionCache, Config.contactTypes()); for (const file of files) { zip.file(file.filename, file.content); } From fc7d22dd583397d5200fab0fd7824368631d725b Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 11 Dec 2024 12:18:03 -0800 Subject: [PATCH 09/12] Fix for circular reference in phone --- docker-local-setup.sh | 2 +- src/lib/credentials-file.ts | 4 ++-- test/lib/credentials-file.spec.ts | 39 +++++++++++++++++++++++++++++-- 3 files changed, 40 insertions(+), 5 deletions(-) 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/src/lib/credentials-file.ts b/src/lib/credentials-file.ts index a625a7b1..815282bd 100644 --- a/src/lib/credentials-file.ts +++ b/src/lib/credentials-file.ts @@ -18,8 +18,8 @@ export default function getCredentialsFiles(sessionCache: SessionCache, contactT const rows = places.map((place) => [ ...Object.values(place.hierarchyProperties).map(prop => prop.formatted), place.name, - place.contact.properties.name.formatted, - place.contact.properties.phone, + place.contact.properties.name?.formatted, + place.contact.properties.phone?.formatted, place.userRoles.join(' '), place.creationDetails.username, place.creationDetails.password, diff --git a/test/lib/credentials-file.spec.ts b/test/lib/credentials-file.spec.ts index e6e5ad70..16f642cb 100644 --- a/test/lib/credentials-file.spec.ts +++ b/test/lib/credentials-file.spec.ts @@ -1,7 +1,7 @@ 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 { ChtDoc, mockChtApi, mockProperty, mockValidContactType } from '../mocks'; import { expect } from 'chai'; describe('lib/credentials-file.ts', () => { @@ -15,7 +15,42 @@ describe('lib/credentials-file.ts', () => { place_name: 'place', place_prop: 'foo', hierarchy_PARENT: subcounty.name, - contact_name: 'contact' + 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); From cee8df21af84f6b712d9528072e4897c9587cccd Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 13 Dec 2024 10:02:58 -0800 Subject: [PATCH 10/12] Fix eslint --- test/lib/credentials-file.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/credentials-file.spec.ts b/test/lib/credentials-file.spec.ts index 16f642cb..b8329a85 100644 --- a/test/lib/credentials-file.spec.ts +++ b/test/lib/credentials-file.spec.ts @@ -1,7 +1,7 @@ import SessionCache from '../../src/services/session-cache'; import getCredentialsFiles from '../../src/lib/credentials-file'; import PlaceFactory from '../../src/services/place-factory'; -import { ChtDoc, mockChtApi, mockProperty, mockValidContactType } from '../mocks'; +import { ChtDoc, mockChtApi, mockValidContactType } from '../mocks'; import { expect } from 'chai'; describe('lib/credentials-file.ts', () => { From bffc12d4a4ccd1a47fe96962c65b1741e9de1dc2 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 13 Dec 2024 10:12:27 -0800 Subject: [PATCH 11/12] Fix this crash --- src/lib/remote-place-cache.ts | 2 +- test/lib/remote-place-cache.spec.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/remote-place-cache.ts b/src/lib/remote-place-cache.ts index 2545cd99..3bffc991 100644 --- a/src/lib/remote-place-cache.ts +++ b/src/lib/remote-place-cache.ts @@ -50,7 +50,7 @@ export default class RemotePlaceCache { RemotePlaceCache.cache = {}; } else if (!contactTypeName) { delete RemotePlaceCache.cache[domain]; - } else { + } else if (RemotePlaceCache.cache[domain]) { delete RemotePlaceCache.cache[domain][contactTypeName]; } } diff --git a/test/lib/remote-place-cache.spec.ts b/test/lib/remote-place-cache.spec.ts index db9a7e3e..76f86642 100644 --- a/test/lib/remote-place-cache.spec.ts +++ b/test/lib/remote-place-cache.spec.ts @@ -64,5 +64,25 @@ describe('lib/remote-place-cache.ts', () => { 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'); + }); }); From 30654b1f93a107411cea5cf334eb6567984192b2 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 13 Dec 2024 10:13:54 -0800 Subject: [PATCH 12/12] 1.5.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",