From bd451650447680f37b5c156141ca404e1f192a21 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 2 Apr 2024 22:51:54 +0700 Subject: [PATCH 01/13] Working on cht 4.6 --- package-lock.json | 8 ++-- package.json | 2 + src/lib/authentication.ts | 2 +- src/lib/cht-api.ts | 14 +++---- src/lib/cht-session.ts | 81 +++++++++++++++++++++++------------- test/lib/cht-session.spec.ts | 3 +- test/mocks.ts | 13 +++--- 7 files changed, 75 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index 129ac29b..f6a29fee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@types/chai-as-promised": "^7.1.8", "@types/mocha": "^10.0.6", "@types/rewire": "^2.5.30", + "@types/semver": "^7.5.8", "@types/sinon": "^17.0.2", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", @@ -54,6 +55,7 @@ "eslint-plugin-promise": "^6.1.1", "mocha": "^10.2.0", "rewire": "^7.0.0", + "semver": "^7.6.0", "sinon": "^17.0.1", "ts-mocha": "^10.0.0", "tsc-watch": "^6.0.4" @@ -3793,9 +3795,9 @@ "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dependencies": { "lru-cache": "^6.0.0" }, diff --git a/package.json b/package.json index b94a58dd..b9b41916 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/chai-as-promised": "^7.1.8", "@types/mocha": "^10.0.6", "@types/rewire": "^2.5.30", + "@types/semver": "^7.5.8", "@types/sinon": "^17.0.2", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", @@ -48,6 +49,7 @@ "eslint-plugin-promise": "^6.1.1", "mocha": "^10.2.0", "rewire": "^7.0.0", + "semver": "^7.6.0", "sinon": "^17.0.1", "ts-mocha": "^10.0.0", "tsc-watch": "^6.0.4" diff --git a/src/lib/authentication.ts b/src/lib/authentication.ts index 8d177615..77d1ade7 100644 --- a/src/lib/authentication.ts +++ b/src/lib/authentication.ts @@ -4,7 +4,7 @@ import ChtSession from './cht-session'; const LOGIN_EXPIRES_AFTER_MS = 2 * 24 * 60 * 60 * 1000; const { COOKIE_PRIVATE_KEY } = process.env; -const PRIVATE_KEY_SALT = '_'; // change to logout all users +const PRIVATE_KEY_SALT = '2'; // change to logout all users const SIGNING_KEY = COOKIE_PRIVATE_KEY + PRIVATE_KEY_SALT; export default class Auth { diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index f8820dd5..e3ca3f8a 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -206,16 +206,12 @@ export class ChtApi { }; private async getUsersAtPlace(placeId: string): Promise { - const url = `_users/_find`; - const payload = { - selector: { - facility_id: placeId, - }, - }; - + const url = `api/v2/users?facility_id=${placeId}`; console.log('axios.post', url); - const resp = await this.axiosInstance.post(url, payload); - return resp.data?.docs?.map((d: any) => d._id); + const resp = await this.axiosInstance.get(url); + return resp.data + ?.filter((d : any) => !d.inactive) + ?.map((d: any) => d.id); } } diff --git a/src/lib/cht-session.ts b/src/lib/cht-session.ts index 376d7bba..a9291e5a 100644 --- a/src/lib/cht-session.ts +++ b/src/lib/cht-session.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; const axios = require('axios'); // require is needed for rewire +import * as semver from 'semver'; import { AuthenticationInfo } from '../config'; import { AxiosHeaders, AxiosInstance } from 'axios'; @@ -7,9 +8,19 @@ import axiosRetry from 'axios-retry'; import { axiosRetryConfig } from './retry-logic'; import { RemotePlace } from './cht-api'; + const COUCH_AUTH_COOKIE_NAME = 'AuthSession='; const ADMIN_FACILITY_ID = '*'; +type SessionCreationDetails = { + authInfo: AuthenticationInfo; + username: string; + sessionToken: string; + + facilityId: string; + chtCoreVersion: string; +}; + axiosRetry(axios, axiosRetryConfig); export default class ChtSession { @@ -18,16 +29,18 @@ export default class ChtSession { public readonly facilityId: string; public readonly axiosInstance: AxiosInstance; public readonly sessionToken: string; + public readonly chtCoreVersion: string; - private constructor(authInfo: AuthenticationInfo, sessionToken: string, username: string, facilityId: string) { - this.authInfo = authInfo; - this.username = username; - this.facilityId = facilityId; - this.sessionToken = sessionToken; + private constructor(creationDetails: SessionCreationDetails) { + this.authInfo = creationDetails.authInfo; + this.username = creationDetails.username; + this.facilityId = creationDetails.facilityId; + this.sessionToken = creationDetails.sessionToken; + this.chtCoreVersion = creationDetails.chtCoreVersion; this.axiosInstance = axios.create({ - baseURL: ChtSession.createUrl(authInfo, ''), - headers: { Cookie: sessionToken }, + baseURL: ChtSession.createUrl(creationDetails.authInfo, ''), + headers: { Cookie: creationDetails.sessionToken }, }); axiosRetry(this.axiosInstance, axiosRetryConfig); @@ -43,22 +56,17 @@ export default class ChtSession { throw new Error(`failed to obtain token for ${username} at ${authInfo.domain}`); } - const userDetails = await ChtSession.fetchUserDetails(authInfo, username, sessionToken); - const facilityId = userDetails.isAdmin ? ADMIN_FACILITY_ID : userDetails.facilityId; - if (!facilityId) { - throw Error(`User ${username} does not have a facility_id connected to their user doc`); - } - - return new ChtSession(authInfo, sessionToken, username, facilityId); + const creationDetails = await ChtSession.fetchCreationDetails(authInfo, username, sessionToken); + return new ChtSession(creationDetails); } public static createFromDataString(data: string): ChtSession { const parsed:any = JSON.parse(data); - return new ChtSession(parsed.authInfo, parsed.sessionToken, parsed.username, parsed.facilityId); + return new ChtSession(parsed); } clone(): ChtSession { - return new ChtSession(this.authInfo, this.sessionToken, this.username, this.facilityId); + return new ChtSession(this); } isPlaceAuthorized(remotePlace: RemotePlace): boolean { @@ -90,22 +98,39 @@ export default class ChtSession { .find((header: string) => header.startsWith(COUCH_AUTH_COOKIE_NAME)); } - private static async fetchUserDetails(authInfo: AuthenticationInfo, username: string, sessionToken: string) { + private static async fetchCreationDetails(authInfo: AuthenticationInfo, username: string, sessionToken: string): Promise { // would prefer to use the _users/org.couchdb.user:username doc // only admins have access + GET api/v2/users returns all users and cant return just one - const sessionUrl = ChtSession.createUrl(authInfo, `medic/org.couchdb.user:${username}`); - const resp = await axios.get( - sessionUrl, - { - headers: { Cookie: sessionToken }, - }, - ); - + const paths = [`medic/org.couchdb.user:${username}`, 'api/v2/monitoring']; + const fetches = paths.map(path => { + const url = ChtSession.createUrl(authInfo, path); + return axios.get( + url, + { headers: { Cookie: sessionToken } }, + ); + }); + const [userResponse, monitoringResponse] = await Promise.all(fetches); + const adminRoles = ['admin', '_admin']; - const isAdmin = _.intersection(adminRoles, resp.data?.roles).length > 0; + const userDoc = userResponse.data; + const isAdmin = _.intersection(adminRoles, userDoc?.roles).length > 0; + const chtCoreVersion = semver.coerce(monitoringResponse.data?.version?.app)?.version; + + const facilityId = isAdmin ? ADMIN_FACILITY_ID : userDoc?.facility_id; + if (!facilityId) { + throw Error(`User ${username} does not have a facility_id connected to their user doc`); + } + + if (!chtCoreVersion) { + throw Error(`Cannot parse cht core version for instance "${authInfo.domain}"`); + } + return { - isAdmin, - facilityId: resp.data?.facility_id, + authInfo, + username, + sessionToken, + chtCoreVersion, + facilityId, }; } diff --git a/test/lib/cht-session.spec.ts b/test/lib/cht-session.spec.ts index f67a73ea..47f351f1 100644 --- a/test/lib/cht-session.spec.ts +++ b/test/lib/cht-session.spec.ts @@ -44,7 +44,8 @@ describe('lib/cht-session.ts', () => { }, }, post: sinon.stub().resolves(mockSessionResponse()), - get: sinon.stub().resolves(mockUserFacilityDoc()), + get: sinon.stub().resolves(mockUserFacilityDoc()) + .onSecondCall().resolves({ data: { version: { app: '4.2.2' } } }), }; ChtSession.__set__('axios', { create: sinon.stub().returns(mockAxios), diff --git a/test/mocks.ts b/test/mocks.ts index ca474b3e..877f9fa7 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -106,16 +106,17 @@ export const mockProperty = (type: string, parameter: string | string[] | undefi }); // Constructor of class ChtSession is private and only accessible within the class declaration. -export const mockChtSession = (userFacilityId: string = '*') : ChtSession => new ChtSession( - { +export const mockChtSession = (userFacilityId: string = '*') : ChtSession => new ChtSession({ + authInfo: { friendly: 'domain', domain: 'domain.com', useHttp: true, }, - 'session-token', - 'username', - userFacilityId -); + sessionToken: 'session-token', + username: 'username', + facilityId: userFacilityId, + chtCoreVersion: '4.2.2', +}); export function expectInvalidProperties( validationErrors: { [key: string]: string } | undefined, From b8a3f6df7bf503e1eb2a4dac455d73e64a685710 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 2 Apr 2024 23:03:26 +0700 Subject: [PATCH 02/13] Getting down to it --- src/lib/cht-api-4-7.ts | 12 ++++++++++++ src/lib/cht-api.ts | 32 +++++++++++++++++++++++--------- src/routes/add-place.ts | 8 ++++---- src/routes/app.ts | 4 ++-- src/routes/move.ts | 2 +- src/routes/search.ts | 2 +- tsconfig.json | 2 +- 7 files changed, 44 insertions(+), 18 deletions(-) create mode 100644 src/lib/cht-api-4-7.ts diff --git a/src/lib/cht-api-4-7.ts b/src/lib/cht-api-4-7.ts new file mode 100644 index 00000000..b1250786 --- /dev/null +++ b/src/lib/cht-api-4-7.ts @@ -0,0 +1,12 @@ +import { ChtApi } from './cht-api'; + +export class ChtApi_4_7 extends ChtApi { + protected override async getUsersAtPlace(placeId: string): Promise { + const url = `api/v2/users?facility_id=${placeId}`; + console.log('axios.post', url); + const resp = await this.axiosInstance.get(url); + return resp.data + ?.filter((d : any) => !d.inactive) + ?.map((d: any) => d.id); + } +} \ No newline at end of file diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index e3ca3f8a..8150db37 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -1,8 +1,10 @@ import _ from 'lodash'; +import { AxiosInstance } from 'axios'; +import { ChtApi_4_7 } from './cht-api-4-7'; import ChtSession from './cht-session'; import { Config, ContactType } from '../config'; +import * as semver from 'semver'; import { UserPayload } from '../services/user-payload'; -import { AxiosInstance } from 'axios'; export type PlacePayload = { name: string; @@ -31,13 +33,21 @@ export type RemotePlace = { export class ChtApi { private session: ChtSession; - private axiosInstance: AxiosInstance; + protected axiosInstance: AxiosInstance; - constructor(session: ChtSession) { + protected constructor(session: ChtSession) { this.session = session; this.axiosInstance = session.axiosInstance; } + public static create(session: ChtSession): ChtApi { + if (semver.gte(session.chtCoreVersion, '4.7.0')) { + return new ChtApi_4_7(session); + } + + return ChtApi.create(session); + } + public get chtSession(): ChtSession { return this.session.clone(); } @@ -205,13 +215,17 @@ export class ChtApi { return resp.data; }; - private async getUsersAtPlace(placeId: string): Promise { - const url = `api/v2/users?facility_id=${placeId}`; + protected async getUsersAtPlace(placeId: string): Promise { + const url = `_users/_find`; + const payload = { + selector: { + facility_id: placeId, + }, + }; + console.log('axios.post', url); - const resp = await this.axiosInstance.get(url); - return resp.data - ?.filter((d : any) => !d.inactive) - ?.map((d: any) => d.id); + const resp = await this.axiosInstance.post(url, payload); + return resp.data?.docs?.map((d: any) => d._id); } } diff --git a/src/routes/add-place.ts b/src/routes/add-place.ts index 8630ffaf..d5e4ee11 100644 --- a/src/routes/add-place.ts +++ b/src/routes/add-place.ts @@ -37,7 +37,7 @@ export default async function addPlace(fastify: FastifyInstance) { const contactType = Config.getContactType(placeType); const sessionCache: SessionCache = req.sessionCache; - const chtApi = new ChtApi(req.chtSession); + const chtApi = ChtApi.create(req.chtSession); if (op === 'new' || op === 'replace') { await PlaceFactory.createOne(req.body, contactType, sessionCache, chtApi); resp.header('HX-Redirect', `/`); @@ -103,7 +103,7 @@ export default async function addPlace(fastify: FastifyInstance) { const { id } = req.params as any; const data: any = req.body; const sessionCache: SessionCache = req.sessionCache; - const chtApi = new ChtApi(req.chtSession); + const chtApi = ChtApi.create(req.chtSession); await PlaceFactory.editOne(id, data, sessionCache, chtApi); @@ -119,7 +119,7 @@ export default async function addPlace(fastify: FastifyInstance) { throw Error(`unable to find place ${id}`); } - const chtApi = new ChtApi(req.chtSession); + const chtApi = ChtApi.create(req.chtSession); RemotePlaceCache.clear(chtApi, place.type.name); await RemotePlaceResolver.resolveOne(place, sessionCache, chtApi, { fuzz: true }); place.validate(); @@ -135,7 +135,7 @@ export default async function addPlace(fastify: FastifyInstance) { throw Error(`unable to find place ${id}`); } - const chtApi = new ChtApi(req.chtSession); + const chtApi = ChtApi.create(req.chtSession); const uploadManager: UploadManager = fastify.uploadManager; uploadManager.doUpload([place], chtApi); }); diff --git a/src/routes/app.ts b/src/routes/app.ts index 745bda5a..ecf526ae 100644 --- a/src/routes/app.ts +++ b/src/routes/app.ts @@ -53,7 +53,7 @@ export default async function sessionCache(fastify: FastifyInstance) { fastify.post('/app/refresh-all', async (req) => { const sessionCache: SessionCache = req.sessionCache; - const chtApi = new ChtApi(req.chtSession); + const chtApi = ChtApi.create(req.chtSession); RemotePlaceCache.clear(chtApi); @@ -69,7 +69,7 @@ export default async function sessionCache(fastify: FastifyInstance) { const uploadManager: UploadManager = fastify.uploadManager; const sessionCache: SessionCache = req.sessionCache; - const chtApi = new ChtApi(req.chtSession); + const chtApi = ChtApi.create(req.chtSession); uploadManager.doUpload(sessionCache.getPlaces(), chtApi); }); diff --git a/src/routes/move.ts b/src/routes/move.ts index c2a3cdc7..e2fac3f2 100644 --- a/src/routes/move.ts +++ b/src/routes/move.ts @@ -31,7 +31,7 @@ export default async function sessionCache(fastify: FastifyInstance) { const sessionCache: SessionCache = req.sessionCache; const contactType = Config.getContactType(formData.place_type); - const chtApi = new ChtApi(req.chtSession); + const chtApi = ChtApi.create(req.chtSession); try { const tmplData = await MoveLib.move(formData, contactType, sessionCache, chtApi); diff --git a/src/routes/search.ts b/src/routes/search.ts index 2ad7a57e..7a739563 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -28,7 +28,7 @@ export default async function place(fastify: FastifyInstance) { throw Error('must have place_id when editing'); } - const chtApi = new ChtApi(req.chtSession); + const chtApi = ChtApi.create(req.chtSession); const hierarchyLevel = Config.getHierarchyWithReplacement(contactType).find(hierarchy => hierarchy.level === level); if (!hierarchyLevel) { throw Error(`not hierarchy constraint at ${level}`); diff --git a/tsconfig.json b/tsconfig.json index 0cf40197..27eadedc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -98,7 +98,7 @@ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ From 48ae756327845d51051c899ead00bccc1af0f1d7 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 2 Apr 2024 23:04:35 +0700 Subject: [PATCH 03/13] Linting --- src/lib/cht-api-4-7.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/cht-api-4-7.ts b/src/lib/cht-api-4-7.ts index b1250786..3ed181bc 100644 --- a/src/lib/cht-api-4-7.ts +++ b/src/lib/cht-api-4-7.ts @@ -9,4 +9,4 @@ export class ChtApi_4_7 extends ChtApi { ?.filter((d : any) => !d.inactive) ?.map((d: any) => d.id); } -} \ No newline at end of file +} From 39d4888fbd393a8e3af8496d9e2209f60152a0b6 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 2 Apr 2024 23:14:36 +0700 Subject: [PATCH 04/13] Fix circular dependency --- src/lib/cht-api-4-7.ts | 7 ++++++- src/lib/cht-api-factory.ts | 14 ++++++++++++++ src/lib/cht-api.ts | 12 +----------- src/routes/add-place.ts | 14 +++++++------- src/routes/app.ts | 6 +++--- src/routes/move.ts | 4 ++-- src/routes/search.ts | 5 +++-- 7 files changed, 36 insertions(+), 26 deletions(-) create mode 100644 src/lib/cht-api-factory.ts diff --git a/src/lib/cht-api-4-7.ts b/src/lib/cht-api-4-7.ts index 3ed181bc..c6829517 100644 --- a/src/lib/cht-api-4-7.ts +++ b/src/lib/cht-api-4-7.ts @@ -1,9 +1,14 @@ import { ChtApi } from './cht-api'; +import ChtSession from './cht-session'; export class ChtApi_4_7 extends ChtApi { + public constructor(session: ChtSession) { + super(session); + } + protected override async getUsersAtPlace(placeId: string): Promise { const url = `api/v2/users?facility_id=${placeId}`; - console.log('axios.post', url); + console.log('axios.get', url); const resp = await this.axiosInstance.get(url); return resp.data ?.filter((d : any) => !d.inactive) diff --git a/src/lib/cht-api-factory.ts b/src/lib/cht-api-factory.ts new file mode 100644 index 00000000..fa30051f --- /dev/null +++ b/src/lib/cht-api-factory.ts @@ -0,0 +1,14 @@ +import * as semver from "semver"; +import { ChtApi } from "./cht-api"; +import ChtSession from "./cht-session"; +import { ChtApi_4_7 } from "./cht-api-4-7"; + +export abstract class ChtApiFactory { + public static create(session: ChtSession): ChtApi { + if (semver.gte(session.chtCoreVersion, '4.7.0')) { + return new ChtApi_4_7(session); + } + + return new ChtApi(session); + } +} diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index 8150db37..500b38b3 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -1,9 +1,7 @@ import _ from 'lodash'; import { AxiosInstance } from 'axios'; -import { ChtApi_4_7 } from './cht-api-4-7'; import ChtSession from './cht-session'; import { Config, ContactType } from '../config'; -import * as semver from 'semver'; import { UserPayload } from '../services/user-payload'; export type PlacePayload = { @@ -35,19 +33,11 @@ export class ChtApi { private session: ChtSession; protected axiosInstance: AxiosInstance; - protected constructor(session: ChtSession) { + public constructor(session: ChtSession) { this.session = session; this.axiosInstance = session.axiosInstance; } - public static create(session: ChtSession): ChtApi { - if (semver.gte(session.chtCoreVersion, '4.7.0')) { - return new ChtApi_4_7(session); - } - - return ChtApi.create(session); - } - public get chtSession(): ChtSession { return this.session.clone(); } diff --git a/src/routes/add-place.ts b/src/routes/add-place.ts index d5e4ee11..9a23578c 100644 --- a/src/routes/add-place.ts +++ b/src/routes/add-place.ts @@ -1,12 +1,12 @@ import { FastifyInstance } from 'fastify'; +import { ChtApiFactory } from '../lib/cht-api-factory'; import { Config } from '../config'; -import { ChtApi } from '../lib/cht-api'; import PlaceFactory from '../services/place-factory'; -import SessionCache from '../services/session-cache'; +import RemotePlaceCache from '../lib/remote-place-cache'; import RemotePlaceResolver from '../lib/remote-place-resolver'; +import SessionCache from '../services/session-cache'; import { UploadManager } from '../services/upload-manager'; -import RemotePlaceCache from '../lib/remote-place-cache'; export default async function addPlace(fastify: FastifyInstance) { fastify.get('/add-place', async (req, resp) => { @@ -37,7 +37,7 @@ export default async function addPlace(fastify: FastifyInstance) { const contactType = Config.getContactType(placeType); const sessionCache: SessionCache = req.sessionCache; - const chtApi = ChtApi.create(req.chtSession); + const chtApi = ChtApiFactory.create(req.chtSession); if (op === 'new' || op === 'replace') { await PlaceFactory.createOne(req.body, contactType, sessionCache, chtApi); resp.header('HX-Redirect', `/`); @@ -103,7 +103,7 @@ export default async function addPlace(fastify: FastifyInstance) { const { id } = req.params as any; const data: any = req.body; const sessionCache: SessionCache = req.sessionCache; - const chtApi = ChtApi.create(req.chtSession); + const chtApi = ChtApiFactory.create(req.chtSession); await PlaceFactory.editOne(id, data, sessionCache, chtApi); @@ -119,7 +119,7 @@ export default async function addPlace(fastify: FastifyInstance) { throw Error(`unable to find place ${id}`); } - const chtApi = ChtApi.create(req.chtSession); + const chtApi = ChtApiFactory.create(req.chtSession); RemotePlaceCache.clear(chtApi, place.type.name); await RemotePlaceResolver.resolveOne(place, sessionCache, chtApi, { fuzz: true }); place.validate(); @@ -135,7 +135,7 @@ export default async function addPlace(fastify: FastifyInstance) { throw Error(`unable to find place ${id}`); } - const chtApi = ChtApi.create(req.chtSession); + const chtApi = ChtApiFactory.create(req.chtSession); const uploadManager: UploadManager = fastify.uploadManager; uploadManager.doUpload([place], chtApi); }); diff --git a/src/routes/app.ts b/src/routes/app.ts index ecf526ae..6d96a37d 100644 --- a/src/routes/app.ts +++ b/src/routes/app.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from 'fastify'; import Auth from '../lib/authentication'; -import { ChtApi } from '../lib/cht-api'; +import { ChtApiFactory } from '../lib/cht-api-factory'; import { Config } from '../config'; import DirectiveModel from '../services/directive-model'; import RemotePlaceCache from '../lib/remote-place-cache'; @@ -53,7 +53,7 @@ export default async function sessionCache(fastify: FastifyInstance) { fastify.post('/app/refresh-all', async (req) => { const sessionCache: SessionCache = req.sessionCache; - const chtApi = ChtApi.create(req.chtSession); + const chtApi = ChtApiFactory.create(req.chtSession); RemotePlaceCache.clear(chtApi); @@ -69,7 +69,7 @@ export default async function sessionCache(fastify: FastifyInstance) { const uploadManager: UploadManager = fastify.uploadManager; const sessionCache: SessionCache = req.sessionCache; - const chtApi = ChtApi.create(req.chtSession); + const chtApi = ChtApiFactory.create(req.chtSession); uploadManager.doUpload(sessionCache.getPlaces(), chtApi); }); diff --git a/src/routes/move.ts b/src/routes/move.ts index e2fac3f2..8836d801 100644 --- a/src/routes/move.ts +++ b/src/routes/move.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import { Config, ContactType } from '../config'; -import { ChtApi } from '../lib/cht-api'; +import { ChtApiFactory } from '../lib/cht-api-factory'; import { FastifyInstance } from 'fastify'; import MoveLib from '../lib/move'; import SessionCache from '../services/session-cache'; @@ -31,7 +31,7 @@ export default async function sessionCache(fastify: FastifyInstance) { const sessionCache: SessionCache = req.sessionCache; const contactType = Config.getContactType(formData.place_type); - const chtApi = ChtApi.create(req.chtSession); + const chtApi = ChtApiFactory.create(req.chtSession); try { const tmplData = await MoveLib.move(formData, contactType, sessionCache, chtApi); diff --git a/src/routes/search.ts b/src/routes/search.ts index 7a739563..9b14b94c 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 { ChtApiFactory } from '../lib/cht-api-factory'; +import { RemotePlace } from '../lib/cht-api'; import SessionCache from '../services/session-cache'; import SearchLib from '../lib/search'; @@ -28,7 +29,7 @@ export default async function place(fastify: FastifyInstance) { throw Error('must have place_id when editing'); } - const chtApi = ChtApi.create(req.chtSession); + const chtApi = ChtApiFactory.create(req.chtSession); const hierarchyLevel = Config.getHierarchyWithReplacement(contactType).find(hierarchy => hierarchy.level === level); if (!hierarchyLevel) { throw Error(`not hierarchy constraint at ${level}`); From dcc9e3ddff8f0759bf76dbda8600ee6d41ae8e4b Mon Sep 17 00:00:00 2001 From: kennsippell Date: Tue, 2 Apr 2024 23:59:40 +0700 Subject: [PATCH 05/13] Refactor chtapi creation from factory into server --- src/lib/cht-api-factory.ts | 14 -------------- src/routes/add-place.ts | 21 ++++++++------------- src/routes/app.ts | 12 ++++-------- src/routes/events.ts | 2 +- src/routes/files.ts | 2 +- src/routes/move.ts | 8 +++----- src/routes/search.ts | 4 +--- src/server.ts | 24 ++++++++++++++++++------ src/types/fastify/index.d.ts | 4 ++-- 9 files changed, 38 insertions(+), 53 deletions(-) delete mode 100644 src/lib/cht-api-factory.ts diff --git a/src/lib/cht-api-factory.ts b/src/lib/cht-api-factory.ts deleted file mode 100644 index fa30051f..00000000 --- a/src/lib/cht-api-factory.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as semver from "semver"; -import { ChtApi } from "./cht-api"; -import ChtSession from "./cht-session"; -import { ChtApi_4_7 } from "./cht-api-4-7"; - -export abstract class ChtApiFactory { - public static create(session: ChtSession): ChtApi { - if (semver.gte(session.chtCoreVersion, '4.7.0')) { - return new ChtApi_4_7(session); - } - - return new ChtApi(session); - } -} diff --git a/src/routes/add-place.ts b/src/routes/add-place.ts index 9a23578c..4c8ab0d5 100644 --- a/src/routes/add-place.ts +++ b/src/routes/add-place.ts @@ -1,6 +1,5 @@ import { FastifyInstance } from 'fastify'; -import { ChtApiFactory } from '../lib/cht-api-factory'; import { Config } from '../config'; import PlaceFactory from '../services/place-factory'; import RemotePlaceCache from '../lib/remote-place-cache'; @@ -20,7 +19,7 @@ export default async function addPlace(fastify: FastifyInstance) { const tmplData = { view: 'add', logo: Config.getLogoBase64(), - session: req.chtSession, + session: req.chtApi.chtSession, op, hierarchy: Config.getHierarchyWithReplacement(contactType, 'desc'), contactType, @@ -37,9 +36,8 @@ export default async function addPlace(fastify: FastifyInstance) { const contactType = Config.getContactType(placeType); const sessionCache: SessionCache = req.sessionCache; - const chtApi = ChtApiFactory.create(req.chtSession); if (op === 'new' || op === 'replace') { - await PlaceFactory.createOne(req.body, contactType, sessionCache, chtApi); + await PlaceFactory.createOne(req.body, contactType, sessionCache, req.chtApi); resp.header('HX-Redirect', `/`); return; } @@ -52,7 +50,7 @@ export default async function addPlace(fastify: FastifyInstance) { } try { const csvBuf = await fileData.toBuffer(); - await PlaceFactory.createFromCsv(csvBuf, contactType, sessionCache, chtApi); + await PlaceFactory.createFromCsv(csvBuf, contactType, sessionCache, req.chtApi); } catch (error) { return fastify.view('src/liquid/place/bulk_create_form.html', { contactType, @@ -87,7 +85,7 @@ export default async function addPlace(fastify: FastifyInstance) { logo: Config.getLogoBase64(), hierarchy: Config.getHierarchyWithReplacement(place.type, 'desc'), place, - session: req.chtSession, + session: req.chtApi.chtSession, contactType: place.type, contactTypes: Config.contactTypes(), backend: `/place/edit/${id}`, @@ -103,9 +101,8 @@ export default async function addPlace(fastify: FastifyInstance) { const { id } = req.params as any; const data: any = req.body; const sessionCache: SessionCache = req.sessionCache; - const chtApi = ChtApiFactory.create(req.chtSession); - await PlaceFactory.editOne(id, data, sessionCache, chtApi); + await PlaceFactory.editOne(id, data, sessionCache, req.chtApi); // back to places list resp.header('HX-Redirect', `/`); @@ -119,9 +116,8 @@ export default async function addPlace(fastify: FastifyInstance) { throw Error(`unable to find place ${id}`); } - const chtApi = ChtApiFactory.create(req.chtSession); - RemotePlaceCache.clear(chtApi, place.type.name); - await RemotePlaceResolver.resolveOne(place, sessionCache, chtApi, { fuzz: true }); + RemotePlaceCache.clear(req.chtApi, place.type.name); + await RemotePlaceResolver.resolveOne(place, sessionCache, req.chtApi, { fuzz: true }); place.validate(); fastify.uploadManager.triggerRefresh(place.id); @@ -135,9 +131,8 @@ export default async function addPlace(fastify: FastifyInstance) { throw Error(`unable to find place ${id}`); } - const chtApi = ChtApiFactory.create(req.chtSession); const uploadManager: UploadManager = fastify.uploadManager; - uploadManager.doUpload([place], chtApi); + uploadManager.doUpload([place], req.chtApi); }); fastify.post('/place/remove/:id', async (req) => { diff --git a/src/routes/app.ts b/src/routes/app.ts index 6d96a37d..a49ecd40 100644 --- a/src/routes/app.ts +++ b/src/routes/app.ts @@ -1,7 +1,6 @@ import { FastifyInstance } from 'fastify'; import Auth from '../lib/authentication'; -import { ChtApiFactory } from '../lib/cht-api-factory'; import { Config } from '../config'; import DirectiveModel from '../services/directive-model'; import RemotePlaceCache from '../lib/remote-place-cache'; @@ -34,7 +33,7 @@ export default async function sessionCache(fastify: FastifyInstance) { const tmplData = { view: 'list', - session: req.chtSession, + session: req.chtApi.chtSession, logo: Config.getLogoBase64(), op, contactType, @@ -53,12 +52,10 @@ export default async function sessionCache(fastify: FastifyInstance) { fastify.post('/app/refresh-all', async (req) => { const sessionCache: SessionCache = req.sessionCache; - const chtApi = ChtApiFactory.create(req.chtSession); - - RemotePlaceCache.clear(chtApi); + RemotePlaceCache.clear(req.chtApi); const places = sessionCache.getPlaces({ created: false }); - await RemotePlaceResolver.resolve(places, sessionCache, chtApi, { fuzz: true }); + await RemotePlaceResolver.resolve(places, sessionCache, req.chtApi, { fuzz: true }); places.forEach(p => p.validate()); fastify.uploadManager.triggerRefresh(undefined); @@ -69,8 +66,7 @@ export default async function sessionCache(fastify: FastifyInstance) { const uploadManager: UploadManager = fastify.uploadManager; const sessionCache: SessionCache = req.sessionCache; - const chtApi = ChtApiFactory.create(req.chtSession); - uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + uploadManager.doUpload(sessionCache.getPlaces(), req.chtApi); }); fastify.post('/app/set-filter/:filter', async (req, resp) => { diff --git a/src/routes/events.ts b/src/routes/events.ts index 494b253c..daaa7a30 100644 --- a/src/routes/events.ts +++ b/src/routes/events.ts @@ -22,7 +22,7 @@ export default async function events(fastify: FastifyInstance) { return resp.view('src/liquid/place/list_event.html', { contactTypes: placeData, - session: req.chtSession, + session: req.chtApi.chtSession, directiveModel, }); }); diff --git a/src/routes/files.ts b/src/routes/files.ts index 86b3236a..68e1cbde 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -48,7 +48,7 @@ export default async function files(fastify: FastifyInstance) { }) ); } - reply.header('Content-Disposition', `attachment; filename="${Date.now()}_${req.chtSession.authInfo.friendly}_users.zip"`); + reply.header('Content-Disposition', `attachment; filename="${Date.now()}_${req.chtApi.chtSession.authInfo.friendly}_users.zip"`); return zip.generateNodeStream(); }); } diff --git a/src/routes/move.ts b/src/routes/move.ts index 8836d801..929a417f 100644 --- a/src/routes/move.ts +++ b/src/routes/move.ts @@ -1,7 +1,6 @@ import _ from 'lodash'; import { Config, ContactType } from '../config'; -import { ChtApiFactory } from '../lib/cht-api-factory'; import { FastifyInstance } from 'fastify'; import MoveLib from '../lib/move'; import SessionCache from '../services/session-cache'; @@ -19,7 +18,7 @@ export default async function sessionCache(fastify: FastifyInstance) { logo: Config.getLogoBase64(), contactTypes, contactType, - session: req.chtSession, + session: req.chtApi.chtSession, ...moveViewModel(contactType), }; @@ -31,17 +30,16 @@ export default async function sessionCache(fastify: FastifyInstance) { const sessionCache: SessionCache = req.sessionCache; const contactType = Config.getContactType(formData.place_type); - const chtApi = ChtApiFactory.create(req.chtSession); try { - const tmplData = await MoveLib.move(formData, contactType, sessionCache, chtApi); + const tmplData = await MoveLib.move(formData, contactType, sessionCache, req.chtApi); return resp.view('src/liquid/components/move_result.html', tmplData); } catch (e: any) { const tmplData = { view: 'move', op: 'move', contactTypes: Config.contactTypes(), - session: req.chtSession, + session: req.chtApi.chtSession, data: formData, contactType, ...moveViewModel(contactType), diff --git a/src/routes/search.ts b/src/routes/search.ts index 9b14b94c..be5679ae 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -1,7 +1,6 @@ import { FastifyInstance } from 'fastify'; import { Config } from '../config'; -import { ChtApiFactory } from '../lib/cht-api-factory'; import { RemotePlace } from '../lib/cht-api'; import SessionCache from '../services/session-cache'; import SearchLib from '../lib/search'; @@ -29,12 +28,11 @@ export default async function place(fastify: FastifyInstance) { throw Error('must have place_id when editing'); } - const chtApi = ChtApiFactory.create(req.chtSession); const hierarchyLevel = Config.getHierarchyWithReplacement(contactType).find(hierarchy => hierarchy.level === level); if (!hierarchyLevel) { throw Error(`not hierarchy constraint at ${level}`); } - const searchResults: RemotePlace[] = await SearchLib.search(contactType, data, dataPrefix, hierarchyLevel, chtApi, sessionCache); + const searchResults: RemotePlace[] = await SearchLib.search(contactType, data, dataPrefix, hierarchyLevel, req.chtApi, sessionCache); return resp.view('src/liquid/components/search_results.html', { op, diff --git a/src/server.ts b/src/server.ts index 562dba0e..7ab5bd53 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,16 +1,20 @@ -import Fastify, { FastifyInstance, FastifyReply, FastifyRequest, FastifyServerOptions } from 'fastify'; import autoload from '@fastify/autoload'; import cookie from '@fastify/cookie'; -import formbody from '@fastify/formbody'; -import multipart from '@fastify/multipart'; +import Fastify, { FastifyInstance, FastifyReply, FastifyRequest, FastifyServerOptions } from 'fastify'; +import { FastifySSEPlugin } from 'fastify-sse-v2'; import fastifyStatic from '@fastify/static'; -import view from '@fastify/view'; +import formbody from '@fastify/formbody'; import { Liquid } from 'liquidjs'; -import { FastifySSEPlugin } from 'fastify-sse-v2'; +import multipart from '@fastify/multipart'; import path from 'path'; +import * as semver from 'semver'; +import view from '@fastify/view'; import Auth from './lib/authentication'; import SessionCache from './services/session-cache'; +import { ChtApi_4_7 } from './lib/cht-api-4-7'; +import { ChtApi } from './lib/cht-api'; +import ChtSession from './lib/cht-session'; const build = (opts: FastifyServerOptions): FastifyInstance => { const fastify = Fastify(opts); @@ -50,7 +54,7 @@ const build = (opts: FastifyServerOptions): FastifyInstance => { try { const chtSession = Auth.decodeToken(cookieToken); - req.chtSession = chtSession; + req.chtApi = createChtApi(chtSession); req.sessionCache = SessionCache.getForSession(chtSession); } catch (e) { reply.redirect('/login'); @@ -61,4 +65,12 @@ const build = (opts: FastifyServerOptions): FastifyInstance => { return fastify; }; +function createChtApi(chtSession: ChtSession): ChtApi { + if (semver.gte(chtSession.chtCoreVersion, '4.7.0')) { + return new ChtApi_4_7(chtSession); + } + + return new ChtApi(chtSession); +} + export default build; diff --git a/src/types/fastify/index.d.ts b/src/types/fastify/index.d.ts index 6ea7c61c..15074442 100644 --- a/src/types/fastify/index.d.ts +++ b/src/types/fastify/index.d.ts @@ -1,4 +1,4 @@ -import ChtSession from '../../lib/cht-session'; +import { ChtApi } from '../../lib/cht-api'; import SessionCache from '../../services/session-cache'; import { UploadManager } from '../../services/upload-manager'; @@ -9,7 +9,7 @@ declare module 'fastify' { interface FastifyRequest { unauthenticated: boolean; - chtSession: ChtSession; + chtApi: ChtApi; sessionCache: SessionCache; } } From 0b6d415dd5aa39f15033ffe3f63ef928e1f189a3 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 3 Apr 2024 01:24:54 +0700 Subject: [PATCH 06/13] Progress --- src/lib/cht-api-4-6.ts | 13 +++++++++++++ src/lib/cht-api-4-7.ts | 7 +++++-- src/lib/cht-api.ts | 19 ++++++++++++++++--- src/lib/cht-session.ts | 4 ++-- src/server.ts | 7 ++++++- src/services/upload-manager.ts | 19 +++++++++++-------- src/services/upload.deactivate.ts | 9 ++++++--- src/services/upload.new.ts | 10 +++------- src/services/upload.replacement.ts | 6 +++--- 9 files changed, 65 insertions(+), 29 deletions(-) create mode 100644 src/lib/cht-api-4-6.ts diff --git a/src/lib/cht-api-4-6.ts b/src/lib/cht-api-4-6.ts new file mode 100644 index 00000000..320d1c93 --- /dev/null +++ b/src/lib/cht-api-4-6.ts @@ -0,0 +1,13 @@ +import { ChtApi } from './cht-api'; +import ChtSession from './cht-session'; + +export class ChtApi_4_6 extends ChtApi { + public constructor(session: ChtSession) { + super(session); + } + + // #8674: assign parent place to new contacts + public override updateContactParent = async (parentId: string): Promise => { + throw Error(`invalid program. should never update contact's parent after cht-core 4.6`); + }; +} diff --git a/src/lib/cht-api-4-7.ts b/src/lib/cht-api-4-7.ts index c6829517..863866b2 100644 --- a/src/lib/cht-api-4-7.ts +++ b/src/lib/cht-api-4-7.ts @@ -1,17 +1,20 @@ import { ChtApi } from './cht-api'; +import { ChtApi_4_6 } from './cht-api-4-6'; import ChtSession from './cht-session'; -export class ChtApi_4_7 extends ChtApi { +export class ChtApi_4_7 extends ChtApi_4_6 { public constructor(session: ChtSession) { super(session); } + // #8877: Look up a single user from their username + // #8877: Look up users from their facility_id or contact_id protected override async getUsersAtPlace(placeId: string): Promise { const url = `api/v2/users?facility_id=${placeId}`; console.log('axios.get', url); const resp = await this.axiosInstance.get(url); return resp.data - ?.filter((d : any) => !d.inactive) + ?.filter((d : any) => !d.inactive) ?.map((d: any) => d.id); } } diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index 500b38b3..d9535d2c 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -29,6 +29,11 @@ export type RemotePlace = { type: 'remote' | 'local' | 'invalid'; }; +export type CreatedPlaceResult = { + placeId: string; + contactId?: string; +}; + export class ChtApi { private session: ChtSession; protected axiosInstance: AxiosInstance; @@ -67,18 +72,26 @@ export class ChtApi { return contactDoc._id; }; - createPlace = async (payload: PlacePayload): Promise => { + createPlace = async (payload: PlacePayload): Promise => { const url = `api/v1/places`; console.log('axios.post', url); const resp = await this.axiosInstance.post(url, payload); - return resp.data.id; + return { + placeId: resp.data.id, + contactId: resp.data.contact?.id, + }; }; // because there is no PUT for /api/v1/places createContact = async (payload: PlacePayload): Promise => { + const payloadWithPlace = { + ...payload.contact, + place: payload._id, + }; + const url = `api/v1/people`; console.log('axios.post', url); - const resp = await this.axiosInstance.post(url, payload.contact); + const resp = await this.axiosInstance.post(url, payloadWithPlace); return resp.data.id; }; diff --git a/src/lib/cht-session.ts b/src/lib/cht-session.ts index a9291e5a..2d57995c 100644 --- a/src/lib/cht-session.ts +++ b/src/lib/cht-session.ts @@ -114,14 +114,14 @@ export default class ChtSession { const adminRoles = ['admin', '_admin']; const userDoc = userResponse.data; const isAdmin = _.intersection(adminRoles, userDoc?.roles).length > 0; - const chtCoreVersion = semver.coerce(monitoringResponse.data?.version?.app)?.version; + const chtCoreVersion = monitoringResponse.data?.version?.app; const facilityId = isAdmin ? ADMIN_FACILITY_ID : userDoc?.facility_id; if (!facilityId) { throw Error(`User ${username} does not have a facility_id connected to their user doc`); } - if (!chtCoreVersion) { + if (!semver.valid(chtCoreVersion)) { throw Error(`Cannot parse cht core version for instance "${authInfo.domain}"`); } diff --git a/src/server.ts b/src/server.ts index 7ab5bd53..24d55dad 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import view from '@fastify/view'; import Auth from './lib/authentication'; import SessionCache from './services/session-cache'; +import { ChtApi_4_6 } from './lib/cht-api-4-6'; import { ChtApi_4_7 } from './lib/cht-api-4-7'; import { ChtApi } from './lib/cht-api'; import ChtSession from './lib/cht-session'; @@ -66,10 +67,14 @@ const build = (opts: FastifyServerOptions): FastifyInstance => { }; function createChtApi(chtSession: ChtSession): ChtApi { - if (semver.gte(chtSession.chtCoreVersion, '4.7.0')) { + if (semver.gte(chtSession.chtCoreVersion, '4.5.0')) { // TODO: change when not testing on dev return new ChtApi_4_7(chtSession); } + if (semver.gte(chtSession.chtCoreVersion, '4.6.0')) { + return new ChtApi_4_6(chtSession); + } + return new ChtApi(chtSession); } diff --git a/src/services/upload-manager.ts b/src/services/upload-manager.ts index 7255e35a..d98c6a10 100644 --- a/src/services/upload-manager.ts +++ b/src/services/upload-manager.ts @@ -1,7 +1,7 @@ import EventEmitter from 'events'; import * as RetryLogic from '../lib/retry-logic'; -import { ChtApi, PlacePayload } from '../lib/cht-api'; +import { ChtApi, CreatedPlaceResult, PlacePayload } from '../lib/cht-api'; import { Config } from '../config'; import Place, { PlaceUploadState } from './place'; import RemotePlaceCache from '../lib/remote-place-cache'; @@ -14,7 +14,7 @@ const UPLOAD_BATCH_SIZE = 15; export interface Uploader { handleContact (payload: PlacePayload): Promise; - handlePlacePayload (place: Place, payload: PlacePayload) : Promise; + handlePlacePayload (place: Place, payload: PlacePayload) : Promise; linkContactAndPlace (place: Place, placeId: string): Promise; } @@ -51,15 +51,18 @@ export class UploadManager extends EventEmitter { } if (!place.creationDetails.placeId) { - const placeId = await uploader.handlePlacePayload(place, payload); - place.creationDetails.placeId = placeId; + const placeResult = await uploader.handlePlacePayload(place, payload); + place.creationDetails.placeId = placeResult.placeId; + place.creationDetails.contactId ||= placeResult.contactId; } - const createdPlaceId = place.creationDetails.placeId; // closure required for typescript - await RetryLogic.retryOnUpdateConflict(() => uploader.linkContactAndPlace(place, createdPlaceId)); - if (!place.creationDetails.contactId) { - throw Error('creationDetails.contactId not set'); + const createdPlaceId = place.creationDetails.placeId; // closure required for typescript + await RetryLogic.retryOnUpdateConflict(() => uploader.linkContactAndPlace(place, createdPlaceId)); + + if (!place.creationDetails.contactId) { + throw Error('creationDetails.contactId not set'); + } } if (!place.creationDetails.username) { diff --git a/src/services/upload.deactivate.ts b/src/services/upload.deactivate.ts index 64bbc11f..2f384c5a 100644 --- a/src/services/upload.deactivate.ts +++ b/src/services/upload.deactivate.ts @@ -1,4 +1,4 @@ -import { ChtApi, PlacePayload } from '../lib/cht-api'; +import { ChtApi, CreatedPlaceResult, PlacePayload } from '../lib/cht-api'; import Place from './place'; import { retryOnUpdateConflict } from '../lib/retry-logic'; import { Uploader } from './upload-manager'; @@ -14,7 +14,7 @@ export class UploadReplacementWithDeactivation implements Uploader { return await this.chtApi.createContact(payload); }; - handlePlacePayload = async (place: Place, payload: PlacePayload): Promise => { + handlePlacePayload = async (place: Place, payload: PlacePayload): Promise => { const contactId = place.creationDetails?.contactId; const placeId = place.resolvedHierarchy[0]?.id; @@ -24,7 +24,10 @@ export class UploadReplacementWithDeactivation implements Uploader { const updatedPlaceDoc = await retryOnUpdateConflict(() => this.chtApi.updatePlace(payload, contactId)); await this.chtApi.deactivateUsersWithPlace(placeId); - return updatedPlaceDoc._id; + return { + placeId: updatedPlaceDoc._id, + contactId, + }; }; linkContactAndPlace = async (place: Place, placeId: string): Promise => { diff --git a/src/services/upload.new.ts b/src/services/upload.new.ts index 95b60f0a..38bcf984 100644 --- a/src/services/upload.new.ts +++ b/src/services/upload.new.ts @@ -1,4 +1,4 @@ -import { ChtApi, PlacePayload } from '../lib/cht-api'; +import { ChtApi, CreatedPlaceResult, PlacePayload } from '../lib/cht-api'; import Place from './place'; import { Uploader } from './upload-manager'; @@ -13,17 +13,13 @@ export class UploadNewPlace implements Uploader { return; }; - handlePlacePayload = async (place: Place, payload: PlacePayload): Promise => { + handlePlacePayload = async (place: Place, payload: PlacePayload): Promise => { return await this.chtApi.createPlace(payload); }; - // we don't get a contact id when we create a place with a contact defined + // we don't get a contact id when we create a place with a contact defined prior to cht 4.6 // https://github.com/medic/cht-core/issues/8674 linkContactAndPlace = async (place: Place, placeId: string): Promise => { - if (place.creationDetails?.contactId) { - return; - } - const contactId = await this.chtApi.updateContactParent(placeId); place.creationDetails.contactId = contactId; }; diff --git a/src/services/upload.replacement.ts b/src/services/upload.replacement.ts index 1a32422a..f213952b 100644 --- a/src/services/upload.replacement.ts +++ b/src/services/upload.replacement.ts @@ -1,4 +1,4 @@ -import { ChtApi, PlacePayload } from '../lib/cht-api'; +import { ChtApi, CreatedPlaceResult, PlacePayload } from '../lib/cht-api'; import Place from './place'; import { retryOnUpdateConflict } from '../lib/retry-logic'; import { Uploader } from './upload-manager'; @@ -14,7 +14,7 @@ export class UploadReplacementWithDeletion implements Uploader { return await this.chtApi.createContact(payload); }; - handlePlacePayload = async (place: Place, payload: PlacePayload): Promise => { + handlePlacePayload = async (place: Place, payload: PlacePayload): Promise => { const contactId = place.creationDetails?.contactId; const placeId = place.resolvedHierarchy[0]?.id; @@ -29,7 +29,7 @@ export class UploadReplacementWithDeletion implements Uploader { } await this.chtApi.disableUsersWithPlace(placeId); - return updatedPlaceDoc._id; + return { placeId, contactId }; }; linkContactAndPlace = async (place: Place, placeId: string): Promise => { From 447756ae7c4eeb7f836a457f502dd57f47e73034 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 3 Apr 2024 01:27:02 +0700 Subject: [PATCH 07/13] Fix tests and lint --- src/lib/cht-api-4-6.ts | 2 +- src/lib/cht-api-4-7.ts | 1 - test/services/upload-manager.spec.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/cht-api-4-6.ts b/src/lib/cht-api-4-6.ts index 320d1c93..c27dc239 100644 --- a/src/lib/cht-api-4-6.ts +++ b/src/lib/cht-api-4-6.ts @@ -7,7 +7,7 @@ export class ChtApi_4_6 extends ChtApi { } // #8674: assign parent place to new contacts - public override updateContactParent = async (parentId: string): Promise => { + public override updateContactParent = async (): Promise => { throw Error(`invalid program. should never update contact's parent after cht-core 4.6`); }; } diff --git a/src/lib/cht-api-4-7.ts b/src/lib/cht-api-4-7.ts index 863866b2..39766d57 100644 --- a/src/lib/cht-api-4-7.ts +++ b/src/lib/cht-api-4-7.ts @@ -1,4 +1,3 @@ -import { ChtApi } from './cht-api'; import { ChtApi_4_6 } from './cht-api-4-6'; import ChtSession from './cht-session'; diff --git a/test/services/upload-manager.spec.ts b/test/services/upload-manager.spec.ts index e8502dfb..1043c935 100644 --- a/test/services/upload-manager.spec.ts +++ b/test/services/upload-manager.spec.ts @@ -346,7 +346,7 @@ async function createMocks() { const chtApi = { chtSession: mockChtSession(), getPlacesWithType: sinon.stub().resolves([remotePlace]), - createPlace: sinon.stub().resolves('created-place-id'), + createPlace: sinon.stub().resolves({ placeId: 'created-place-id' }), updateContactParent: sinon.stub().resolves('created-contact-id'), createUser: sinon.stub().resolves(), From 3d96919a12553511f97dfe43621a79052f7be636 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 3 Apr 2024 15:50:38 +0700 Subject: [PATCH 08/13] Combine into single file --- src/lib/cht-api-4-6.ts | 13 ------------- src/lib/{cht-api-4-7.ts => cht-api-override.ts} | 17 ++++++++++++++--- src/server.ts | 3 +-- 3 files changed, 15 insertions(+), 18 deletions(-) delete mode 100644 src/lib/cht-api-4-6.ts rename src/lib/{cht-api-4-7.ts => cht-api-override.ts} (51%) diff --git a/src/lib/cht-api-4-6.ts b/src/lib/cht-api-4-6.ts deleted file mode 100644 index c27dc239..00000000 --- a/src/lib/cht-api-4-6.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ChtApi } from './cht-api'; -import ChtSession from './cht-session'; - -export class ChtApi_4_6 extends ChtApi { - public constructor(session: ChtSession) { - super(session); - } - - // #8674: assign parent place to new contacts - public override updateContactParent = async (): Promise => { - throw Error(`invalid program. should never update contact's parent after cht-core 4.6`); - }; -} diff --git a/src/lib/cht-api-4-7.ts b/src/lib/cht-api-override.ts similarity index 51% rename from src/lib/cht-api-4-7.ts rename to src/lib/cht-api-override.ts index 39766d57..24644925 100644 --- a/src/lib/cht-api-4-7.ts +++ b/src/lib/cht-api-override.ts @@ -1,19 +1,30 @@ -import { ChtApi_4_6 } from './cht-api-4-6'; +import { ChtApi } from './cht-api'; import ChtSession from './cht-session'; +export class ChtApi_4_6 extends ChtApi { + public constructor(session: ChtSession) { + super(session); + } + + // #8674: assign parent place to new contacts + public override updateContactParent = async (): Promise => { + throw Error(`program should never update contact's parent after cht-core 4.6`); + }; +} + export class ChtApi_4_7 extends ChtApi_4_6 { public constructor(session: ChtSession) { super(session); } - // #8877: Look up a single user from their username + // #8986: Look up a single user from their username // #8877: Look up users from their facility_id or contact_id protected override async getUsersAtPlace(placeId: string): Promise { const url = `api/v2/users?facility_id=${placeId}`; console.log('axios.get', url); const resp = await this.axiosInstance.get(url); return resp.data - ?.filter((d : any) => !d.inactive) + ?.filter((d : any) => !d.inactive) // TODO: needed? ?.map((d: any) => d.id); } } diff --git a/src/server.ts b/src/server.ts index 24d55dad..a29f496b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,8 +12,7 @@ import view from '@fastify/view'; import Auth from './lib/authentication'; import SessionCache from './services/session-cache'; -import { ChtApi_4_6 } from './lib/cht-api-4-6'; -import { ChtApi_4_7 } from './lib/cht-api-4-7'; +import { ChtApi_4_6, ChtApi_4_7 } from './lib/cht-api-override'; import { ChtApi } from './lib/cht-api'; import ChtSession from './lib/cht-session'; From 6a035b29f5128ca668dd5b955b36998c96328256 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 3 Apr 2024 16:26:45 +0700 Subject: [PATCH 09/13] Refactor into ChtApi.create and test --- src/lib/cht-api-override.ts | 30 ---------- src/lib/cht-api.ts | 106 +++++++++++++++++++++++++++--------- src/server.ts | 17 +----- test/lib/cht-api.spec.ts | 36 ++++++++++++ 4 files changed, 118 insertions(+), 71 deletions(-) delete mode 100644 src/lib/cht-api-override.ts create mode 100644 test/lib/cht-api.spec.ts diff --git a/src/lib/cht-api-override.ts b/src/lib/cht-api-override.ts deleted file mode 100644 index 24644925..00000000 --- a/src/lib/cht-api-override.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ChtApi } from './cht-api'; -import ChtSession from './cht-session'; - -export class ChtApi_4_6 extends ChtApi { - public constructor(session: ChtSession) { - super(session); - } - - // #8674: assign parent place to new contacts - public override updateContactParent = async (): Promise => { - throw Error(`program should never update contact's parent after cht-core 4.6`); - }; -} - -export class ChtApi_4_7 extends ChtApi_4_6 { - public constructor(session: ChtSession) { - super(session); - } - - // #8986: Look up a single user from their username - // #8877: Look up users from their facility_id or contact_id - protected override async getUsersAtPlace(placeId: string): Promise { - const url = `api/v2/users?facility_id=${placeId}`; - console.log('axios.get', url); - const resp = await this.axiosInstance.get(url); - return resp.data - ?.filter((d : any) => !d.inactive) // TODO: needed? - ?.map((d: any) => d.id); - } -} diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index d9535d2c..46d76b9d 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -1,5 +1,7 @@ import _ from 'lodash'; import { AxiosInstance } from 'axios'; +import * as semver from 'semver'; + import ChtSession from './cht-session'; import { Config, ContactType } from '../config'; import { UserPayload } from '../services/user-payload'; @@ -35,16 +37,34 @@ export type CreatedPlaceResult = { }; export class ChtApi { - private session: ChtSession; protected axiosInstance: AxiosInstance; + private session: ChtSession; + private version: string; - public constructor(session: ChtSession) { + protected constructor(session: ChtSession) { this.session = session; this.axiosInstance = session.axiosInstance; + this.version = 'base'; } - public get chtSession(): ChtSession { - return this.session.clone(); + public static create(chtSession: ChtSession): ChtApi { + let result; + const coercedVersion = semver.valid(semver.coerce(chtSession.chtCoreVersion)); + if (!coercedVersion) { + throw Error('invalid chtSession.chtCoreVersion'); + } + + if (semver.gte(coercedVersion, '4.7.0') || chtSession.chtCoreVersion === '4.6.0-local-development') { // TODO: change when not testing on dev + result = new ChtApi_4_7(chtSession); + result.version = '4.7'; + } else if (semver.gte(coercedVersion, '4.6.0')) { + result = new ChtApi_4_6(chtSession); + result.version = '4.6'; + } else { + result = new ChtApi(chtSession); + } + + return result; } // workaround https://github.com/medic/cht-core/issues/8674 @@ -137,13 +157,6 @@ export class ChtApi { return usersToDisable; }; - disableUser = async (docId: string): Promise => { - const username = docId.substring('org.couchdb.user:'.length); - const url = `api/v1/users/${username}`; - console.log('axios.delete', url); - return this.axiosInstance.delete(url); - }; - deactivateUsersWithPlace = async (placeId: string): Promise => { const usersToDeactivate: string[] = await this.getUsersAtPlace(placeId); for (const userDocId of usersToDeactivate) { @@ -152,14 +165,6 @@ export class ChtApi { return usersToDeactivate; }; - deactivateUser = async (docId: string): Promise => { - const username = docId.substring('org.couchdb.user:'.length); - const url = `api/v1/users/${username}`; - console.log('axios.post', url); - const deactivationPayload = { roles: ['deactivated' ]}; - return this.axiosInstance.post(url, deactivationPayload); - }; - createUser = async (user: UserPayload): Promise => { const url = `api/v1/users`; console.log('axios.post', url); @@ -211,12 +216,13 @@ export class ChtApi { }); }; - getDoc = async (id: string): Promise => { - const url = `medic/${id}`; - console.log('axios.get', url); - const resp = await this.axiosInstance.get(url); - return resp.data; - }; + public get chtSession(): ChtSession { + return this.session.clone(); + } + + public get coreVersion(): string { + return this.version; + } protected async getUsersAtPlace(placeId: string): Promise { const url = `_users/_find`; @@ -230,6 +236,56 @@ export class ChtApi { const resp = await this.axiosInstance.post(url, payload); return resp.data?.docs?.map((d: any) => d._id); } + + private getDoc = async (id: string): Promise => { + const url = `medic/${id}`; + console.log('axios.get', url); + const resp = await this.axiosInstance.get(url); + return resp.data; + }; + + private async deactivateUser(docId: string): Promise { + const username = docId.substring('org.couchdb.user:'.length); + const url = `api/v1/users/${username}`; + console.log('axios.post', url); + const deactivationPayload = { roles: ['deactivated' ]}; + return this.axiosInstance.post(url, deactivationPayload); + }; + + private async disableUser(docId: string): Promise { + const username = docId.substring('org.couchdb.user:'.length); + const url = `api/v1/users/${username}`; + console.log('axios.delete', url); + return this.axiosInstance.delete(url); + }; +} + +class ChtApi_4_6 extends ChtApi { + public constructor(session: ChtSession) { + super(session); + } + + // #8674: assign parent place to new contacts + public override updateContactParent = async (): Promise => { + throw Error(`program should never update contact's parent after cht-core 4.6`); + }; +} + +class ChtApi_4_7 extends ChtApi_4_6 { + public constructor(session: ChtSession) { + super(session); + } + + // #8986: Look up a single user from their username + // #8877: Look up users from their facility_id or contact_id + protected override async getUsersAtPlace(placeId: string): Promise { + const url = `api/v2/users?facility_id=${placeId}`; + console.log('axios.get', url); + const resp = await this.axiosInstance.get(url); + return resp.data + ?.filter((d : any) => !d.inactive) // TODO: needed? + ?.map((d: any) => d.id); + } } function minify(doc: any): any { diff --git a/src/server.ts b/src/server.ts index a29f496b..3730034b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,14 +7,11 @@ import formbody from '@fastify/formbody'; import { Liquid } from 'liquidjs'; import multipart from '@fastify/multipart'; import path from 'path'; -import * as semver from 'semver'; import view from '@fastify/view'; import Auth from './lib/authentication'; import SessionCache from './services/session-cache'; -import { ChtApi_4_6, ChtApi_4_7 } from './lib/cht-api-override'; import { ChtApi } from './lib/cht-api'; -import ChtSession from './lib/cht-session'; const build = (opts: FastifyServerOptions): FastifyInstance => { const fastify = Fastify(opts); @@ -54,7 +51,7 @@ const build = (opts: FastifyServerOptions): FastifyInstance => { try { const chtSession = Auth.decodeToken(cookieToken); - req.chtApi = createChtApi(chtSession); + req.chtApi = ChtApi.create(chtSession); req.sessionCache = SessionCache.getForSession(chtSession); } catch (e) { reply.redirect('/login'); @@ -65,16 +62,4 @@ const build = (opts: FastifyServerOptions): FastifyInstance => { return fastify; }; -function createChtApi(chtSession: ChtSession): ChtApi { - if (semver.gte(chtSession.chtCoreVersion, '4.5.0')) { // TODO: change when not testing on dev - return new ChtApi_4_7(chtSession); - } - - if (semver.gte(chtSession.chtCoreVersion, '4.6.0')) { - return new ChtApi_4_6(chtSession); - } - - return new ChtApi(chtSession); -} - export default build; diff --git a/test/lib/cht-api.spec.ts b/test/lib/cht-api.spec.ts new file mode 100644 index 00000000..526e2776 --- /dev/null +++ b/test/lib/cht-api.spec.ts @@ -0,0 +1,36 @@ +import { ChtApi } from '../../src/lib/cht-api'; +import { expect } from 'chai'; +import { mockChtSession } from '../mocks'; + +const scenarios = [ + { version: '4.2.2', expected: 'base' }, + { version: '4.2.2.6922454971', expected: 'base' }, + { version: '4.8.1', expected: '4.7' }, + { version: '5.0', expected: '4.7' }, + { version: '4.6.0-local-development', expected: '4.7' }, + { version: '4.6.0', expected: '4.6' }, + { version: '4.6.0-feature-release', expected: '4.6' }, + { version: '4.6.0-dev.1682192676689', expected: '4.6' }, +]; + +describe('lib/cht-api.ts', () => { + beforeEach(() => {}); + + describe('create', () => { + for (const scenario of scenarios) { + it(JSON.stringify(scenario), () => { + const session = mockChtSession(); + session.chtCoreVersion = scenario.version; + const chtApi = ChtApi.create(session); + expect(chtApi.coreVersion).to.eq(scenario.expected); + }); + } + + it('crash due to whatever', () => { + const session = mockChtSession(); + session.chtCoreVersion = 'invalid'; + expect(() => ChtApi.create(session)).to.throw('invalid'); + }) + }); +}); + From e2e26bec50fa85e3754a844a321a7cf3a50baff1 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 3 Apr 2024 20:02:29 +0700 Subject: [PATCH 10/13] Add a bunch of tests --- src/lib/cht-api.ts | 4 +- test/services/upload-manager.spec.ts | 621 ++++++++++++++------------- 2 files changed, 324 insertions(+), 301 deletions(-) diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index 46d76b9d..317d2a61 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -51,10 +51,10 @@ export class ChtApi { let result; const coercedVersion = semver.valid(semver.coerce(chtSession.chtCoreVersion)); if (!coercedVersion) { - throw Error('invalid chtSession.chtCoreVersion'); + throw Error(`invalid cht core version "${chtSession.chtCoreVersion}"`); } - if (semver.gte(coercedVersion, '4.7.0') || chtSession.chtCoreVersion === '4.6.0-local-development') { // TODO: change when not testing on dev + if (semver.gte(coercedVersion, '4.7.0') || chtSession.chtCoreVersion === '4.6.0-local-development') { result = new ChtApi_4_7(chtSession); result.version = '4.7'; } else if (semver.gte(coercedVersion, '4.6.0')) { diff --git a/test/services/upload-manager.spec.ts b/test/services/upload-manager.spec.ts index 1043c935..9efe1620 100644 --- a/test/services/upload-manager.spec.ts +++ b/test/services/upload-manager.spec.ts @@ -13,309 +13,325 @@ import RemotePlaceResolver from '../../src/lib/remote-place-resolver'; import { UploadManagerRetryScenario } from '../lib/retry-logic.spec'; describe('services/upload-manager.ts', () => { + const coreScenarios = [ + 'base', + '4.6', + '4.7', + ]; + beforeEach(() => { RemotePlaceCache.clear({}); }); - it('mock data is properly sent to chtApi', async () => { - const { fakeFormData, contactType, chtApi, sessionCache, remotePlace } = await createMocks(); - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - - expect(chtApi.createPlace.calledOnce).to.be.true; - const placePayload = chtApi.createPlace.args[0][0]; - expect(placePayload).to.nested.include({ - 'contact.contact_type': contactType.contact_type, - 'contact.name': 'contact', - prop: 'foo', - name: 'Place Community Health Unit', - parent: remotePlace.id, - contact_type: contactType.name, - }); - expect(chtApi.updateContactParent.calledOnce).to.be.true; - expect(chtApi.updateContactParent.args[0]).to.deep.eq(['created-place-id']); - - expect(chtApi.createUser.calledOnce).to.be.true; - const userPayload = chtApi.createUser.args[0][0]; - expect(userPayload).to.deep.include({ - contact: 'created-contact-id', - place: 'created-place-id', - roles: ['role'], - username: 'contact', + for (const scenario of coreScenarios) { + describe(`cht-core version ${scenario}`, () => { + it('mock data is properly sent to chtApi', async () => { + const { fakeFormData, contactType, chtApi, sessionCache, remotePlace } = await createMocks(scenario); + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + + expect(chtApi.createPlace.calledOnce).to.be.true; + const placePayload = chtApi.createPlace.args[0][0]; + expect(placePayload).to.nested.include({ + 'contact.contact_type': contactType.contact_type, + 'contact.name': 'contact', + prop: 'foo', + name: 'Place Community Health Unit', + parent: remotePlace.id, + contact_type: contactType.name, + }); + + if (scenario === 'base') { + expect(chtApi.updateContactParent.calledOnce).to.be.true; + expect(chtApi.updateContactParent.args[0]).to.deep.eq(['created-place-id']); + } + + expect(chtApi.createUser.calledOnce).to.be.true; + const userPayload = chtApi.createUser.args[0][0]; + expect(userPayload).to.deep.include({ + contact: 'created-contact-id', + place: 'created-place-id', + roles: ['role'], + username: 'contact', + }); + expect(chtApi.deleteDoc.called).to.be.false; + 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(scenario); + + const parentContactType = mockValidContactType('string', undefined); + parentContactType.name = remotePlace.name; + + const parentPlace = mockParentPlace(parentContactType, remotePlace.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.args[0]).to.deep.eq(['parent']); + expect(chtApi.deleteDoc.called).to.be.false; + expect(place.isCreated).to.be.true; + }); + + it('uploads in batches', async () => { + const placeCount = 11; + const { fakeFormData, contactType, sessionCache, chtApi } = await createMocks(scenario); + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + const places = Array(placeCount).fill(place).map(p => _.cloneDeep(p)); + const uploadManager = new UploadManager(); + await uploadManager.doUpload(places, chtApi); + expect(chtApi.createUser.callCount).to.eq(placeCount); + expect(places.find(p => !p.isCreated)).to.be.undefined; + }); + + it('required attributes can be inherited during replacement', async () => { + const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(scenario); + 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', + name: 'to-replace', + lineage: [remotePlace.id], + type: 'remote', + }; + + chtApi.getPlacesWithType + .resolves([remotePlace]) + .onSecondCall() + .resolves([toReplace]); + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.be.empty; // only parent is required when replacing + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + expect(chtApi.updatePlace.calledOnce).to.be.true; + expect(chtApi.updatePlace.args[0][0]).to.not.have.property('prop'); + expect(chtApi.updatePlace.args[0][0]).to.not.have.property('name'); + expect(chtApi.deleteDoc.calledOnce).to.be.true; + expect(place.isCreated).to.be.true; + }); + + it('contact_type replacement with username_from_place:true', async () => { + const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(scenario); + contactType.username_from_place = true; + + fakeFormData.hierarchy_replacement = 'replacement based username'; + fakeFormData.place_name = ''; // optional due to replacement + + const toReplace: RemotePlace = { + id: 'id-replace', + name: 'replac"e$mENT baSed username', + lineage: [remotePlace.id], + type: 'remote', + }; + + chtApi.getPlacesWithType + .resolves([remotePlace]) + .onSecondCall() + .resolves([toReplace]); + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.be.empty; // only parent is required when replacing + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + expect(chtApi.createUser.args[0][0]).to.deep.include({ + username: 'replacement_based_username', + }); + expect(place.isCreated).to.be.true; + }); + + it('contact_type replacement with deactivate_users_on_replace:true', async () => { + const { remotePlace, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(scenario); + contactType.deactivate_users_on_replace = true; + + fakeFormData.hierarchy_replacement = 'deactivate me'; + fakeFormData.place_name = ''; // optional due to replacement + + const toReplace: RemotePlace = { + id: 'id-replace', + name: 'deactivate me', + lineage: [remotePlace.id], + type: 'remote', + }; + + chtApi.getPlacesWithType + .resolves([remotePlace]) + .onSecondCall() + .resolves([toReplace]); + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.be.empty; // only parent is required when replacing + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + expect(chtApi.createUser.callCount).to.eq(1); + expect(chtApi.disableUsersWithPlace.called).to.be.false; + expect(chtApi.deleteDoc.called).to.be.false; + expect(chtApi.deactivateUsersWithPlace.called).to.be.true; + expect(chtApi.deactivateUsersWithPlace.args[0][0]).to.eq('id-replace'); + expect(place.isCreated).to.be.true; + }); + + it('place with validation error is not uploaded', async () => { + const { sessionCache, contactType, fakeFormData, chtApi } = await createMocks(scenario); + delete fakeFormData.place_name; + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.not.be.empty; + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + expect(chtApi.createUser.called).to.be.false; + expect(place.isCreated).to.be.false; + }); + + it('uploading a chu and dependant chp where chp is created first', async () => { + const { remotePlace, sessionCache, chtApi } = await createMocks(scenario); + + chtApi.getPlacesWithType + .resolves([]) // parent of chp + .onSecondCall().resolves([remotePlace]) // grandparent of chp (subcounty) + .onThirdCall().resolves([]); // chp replacements + + const chu_name = 'new chu'; + const chpType = Config.getContactType('d_community_health_volunteer_area'); + const chpData = { + hierarchy_CHU: chu_name, + place_name: 'new chp', + contact_name: 'new chp', + contact_phone: '0788889999', + }; + + // CHP has validation errors because it references a CHU which does not exist + 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); + + // refresh the chp + await RemotePlaceResolver.resolveOne(chp, sessionCache, chtApi, { fuzz: true }); + chp.validate(); + expect(chp.validationErrors).to.be.empty; + + // upload succeeds + chtApi.getParentAndSibling = sinon.stub().resolves({ parent: chu.asChtPayload('user'), sibling: undefined }); + const uploadManager = new UploadManager(); + await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + expect(chu.isCreated).to.be.true; + expect(chp.isCreated).to.be.true; + + // 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(scenario); + + 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 uploadManager = new UploadManager(); + await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + expect(chu.isCreated).to.be.false; + expect(chtApi.createUser.calledOnce).to.be.true; + expect(chu.uploadError).to.include('upload-error'); + expect(chu.creationDetails).to.deep.eq({ + contactId: 'created-contact-id', + placeId: 'created-place-id', + }); + + await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + expect(chu.isCreated).to.be.true; + expect(chu.uploadError).to.be.undefined; + expect(chu.creationDetails).to.deep.include({ + contactId: 'created-contact-id', + placeId: 'created-place-id', + username: 'new' + }); + expect(chu.creationDetails.password).to.not.be.undefined; + + expect(chtApi.createPlace.callCount).to.eq(1); + expect(chtApi.updateContactParent.callCount).to.eq(scenario === 'base' ? 1 : 0); + expect(chtApi.createUser.callCount).to.eq(2); + expect(chtApi.getParentAndSibling.called).to.be.false; + expect(chtApi.createContact.called).to.be.false; + expect(chtApi.updatePlace.called).to.be.false; + expect(chtApi.deleteDoc.called).to.be.false; + expect(chtApi.disableUsersWithPlace.called).to.be.false; + }); + + it(`createUser is retried`, async() => { + const { remotePlace, sessionCache, chtApi } = await createMocks(scenario); + + chtApi.createUser.throws(UploadManagerRetryScenario.axiosError); + + const chu_name = 'new chu'; + const chu = await createChu(remotePlace, chu_name, sessionCache, chtApi); + + const uploadManager = new UploadManager(); + await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + expect(chu.isCreated).to.be.false; + expect(chtApi.createUser.callCount).to.be.gt(2); // retried + 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(scenario); + + contactType.user_role = ['role1', 'role2']; + fakeFormData.user_role = 'role1 role2'; + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + + expect(chtApi.createPlace.calledOnce).to.be.true; + const placePayload = chtApi.createPlace.args[0][0]; + expect(placePayload).to.nested.include({ + 'contact.contact_type': contactType.contact_type, + 'contact.name': 'contact', + prop: 'foo', + name: 'Place Community Health Unit', + parent: remotePlace.id, + contact_type: contactType.name, + }); + + if (scenario === 'base') { + expect(chtApi.updateContactParent.calledOnce).to.be.true; + expect(chtApi.updateContactParent.args[0]).to.deep.eq(['created-place-id']); + } + + expect(chtApi.createUser.calledOnce).to.be.true; + const userPayload = chtApi.createUser.args[0][0]; + expect(userPayload).to.deep.include({ + contact: 'created-contact-id', + place: 'created-place-id', + roles: ['role1', 'role2'], + username: 'contact', + }); + expect(place.isCreated).to.be.true; + }); }); - expect(chtApi.deleteDoc.called).to.be.false; - 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(); - - const parentContactType = mockValidContactType('string', undefined); - parentContactType.name = remotePlace.name; - - const parentPlace = mockParentPlace(parentContactType, remotePlace.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.args[0]).to.deep.eq(['parent']); - expect(chtApi.deleteDoc.called).to.be.false; - expect(place.isCreated).to.be.true; - }); - - it('uploads in batches', async () => { - const placeCount = 11; - const { fakeFormData, contactType, sessionCache, chtApi } = await createMocks(); - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - const places = Array(placeCount).fill(place).map(p => _.cloneDeep(p)); - const uploadManager = new UploadManager(); - await uploadManager.doUpload(places, chtApi); - expect(chtApi.createUser.callCount).to.eq(placeCount); - expect(places.find(p => !p.isCreated)).to.be.undefined; - }); - - it('required attributes can be inherited during replacement', async () => { - const { remotePlace, 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', - name: 'to-replace', - lineage: [remotePlace.id], - type: 'remote', - }; - - chtApi.getPlacesWithType - .resolves([remotePlace]) - .onSecondCall() - .resolves([toReplace]); - - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - expect(place.validationErrors).to.be.empty; // only parent is required when replacing - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - expect(chtApi.updatePlace.calledOnce).to.be.true; - expect(chtApi.updatePlace.args[0][0]).to.not.have.property('prop'); - expect(chtApi.updatePlace.args[0][0]).to.not.have.property('name'); - expect(chtApi.deleteDoc.calledOnce).to.be.true; - expect(place.isCreated).to.be.true; - }); - - it('contact_type replacement with username_from_place:true', async () => { - const { remotePlace, 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', - name: 'replac"e$mENT baSed username', - lineage: [remotePlace.id], - type: 'remote', - }; - - chtApi.getPlacesWithType - .resolves([remotePlace]) - .onSecondCall() - .resolves([toReplace]); - - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - expect(place.validationErrors).to.be.empty; // only parent is required when replacing - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - expect(chtApi.createUser.args[0][0]).to.deep.include({ - username: 'replacement_based_username', - }); - expect(place.isCreated).to.be.true; - }); - - it('contact_type replacement with deactivate_users_on_replace:true', async () => { - const { remotePlace, 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', - name: 'deactivate me', - lineage: [remotePlace.id], - type: 'remote', - }; - - chtApi.getPlacesWithType - .resolves([remotePlace]) - .onSecondCall() - .resolves([toReplace]); - - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - expect(place.validationErrors).to.be.empty; // only parent is required when replacing - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - expect(chtApi.createUser.callCount).to.eq(1); - expect(chtApi.disableUsersWithPlace.called).to.be.false; - expect(chtApi.deleteDoc.called).to.be.false; - expect(chtApi.deactivateUsersWithPlace.called).to.be.true; - expect(chtApi.deactivateUsersWithPlace.args[0][0]).to.eq('id-replace'); - expect(place.isCreated).to.be.true; - }); - - it('place with validation error is not uploaded', async () => { - const { sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); - delete fakeFormData.place_name; - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - expect(place.validationErrors).to.not.be.empty; - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - expect(chtApi.createUser.called).to.be.false; - expect(place.isCreated).to.be.false; - }); - - it('uploading a chu and dependant chp where chp is created first', async () => { - const { remotePlace, sessionCache, chtApi } = await createMocks(); - - chtApi.getPlacesWithType - .resolves([]) // parent of chp - .onSecondCall().resolves([remotePlace]) // grandparent of chp (subcounty) - .onThirdCall().resolves([]); // chp replacements - - const chu_name = 'new chu'; - const chpType = Config.getContactType('d_community_health_volunteer_area'); - const chpData = { - hierarchy_CHU: chu_name, - place_name: 'new chp', - contact_name: 'new chp', - contact_phone: '0788889999', - }; - - // CHP has validation errors because it references a CHU which does not exist - 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); - - // refresh the chp - await RemotePlaceResolver.resolveOne(chp, sessionCache, chtApi, { fuzz: true }); - chp.validate(); - expect(chp.validationErrors).to.be.empty; - - // upload succeeds - chtApi.getParentAndSibling = sinon.stub().resolves({ parent: chu.asChtPayload('user'), sibling: undefined }); - const uploadManager = new UploadManager(); - await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); - expect(chu.isCreated).to.be.true; - expect(chp.isCreated).to.be.true; - - // 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(); - - 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 uploadManager = new UploadManager(); - await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); - expect(chu.isCreated).to.be.false; - expect(chtApi.createUser.calledOnce).to.be.true; - expect(chu.uploadError).to.include('upload-error'); - expect(chu.creationDetails).to.deep.eq({ - contactId: 'created-contact-id', - placeId: 'created-place-id', - }); - - await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); - expect(chu.isCreated).to.be.true; - expect(chu.uploadError).to.be.undefined; - expect(chu.creationDetails).to.deep.include({ - contactId: 'created-contact-id', - placeId: 'created-place-id', - username: 'new' - }); - expect(chu.creationDetails.password).to.not.be.undefined; - - expect(chtApi.createPlace.callCount).to.eq(1); - expect(chtApi.updateContactParent.callCount).to.eq(1); - expect(chtApi.createUser.callCount).to.eq(2); - expect(chtApi.getParentAndSibling.called).to.be.false; - expect(chtApi.createContact.called).to.be.false; - expect(chtApi.updatePlace.called).to.be.false; - expect(chtApi.deleteDoc.called).to.be.false; - expect(chtApi.disableUsersWithPlace.called).to.be.false; - }); - - it(`createUser is retried`, async() => { - const { remotePlace, sessionCache, chtApi } = await createMocks(); - - chtApi.createUser.throws(UploadManagerRetryScenario.axiosError); - - const chu_name = 'new chu'; - const chu = await createChu(remotePlace, chu_name, sessionCache, chtApi); - - const uploadManager = new UploadManager(); - await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); - expect(chu.isCreated).to.be.false; - expect(chtApi.createUser.callCount).to.be.gt(2); // retried - 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(); - - contactType.user_role = ['role1', 'role2']; - fakeFormData.user_role = 'role1 role2'; - - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - - expect(chtApi.createPlace.calledOnce).to.be.true; - const placePayload = chtApi.createPlace.args[0][0]; - expect(placePayload).to.nested.include({ - 'contact.contact_type': contactType.contact_type, - 'contact.name': 'contact', - prop: 'foo', - name: 'Place Community Health Unit', - parent: remotePlace.id, - contact_type: contactType.name, - }); - expect(chtApi.updateContactParent.calledOnce).to.be.true; - expect(chtApi.updateContactParent.args[0]).to.deep.eq(['created-place-id']); - - expect(chtApi.createUser.calledOnce).to.be.true; - const userPayload = chtApi.createUser.args[0][0]; - expect(userPayload).to.deep.include({ - contact: 'created-contact-id', - place: 'created-place-id', - roles: ['role1', 'role2'], - username: 'contact', - }); - expect(place.isCreated).to.be.true; - }); + } }); async function createChu(remotePlace: RemotePlace, chu_name: string, sessionCache: any, chtApi: ChtApi) { @@ -334,7 +350,7 @@ async function createChu(remotePlace: RemotePlace, chu_name: string, sessionCach return chu; } -async function createMocks() { +async function createMocks(coreVersion: string) { const contactType = mockValidContactType('string', undefined); const remotePlace: RemotePlace = { id: 'parent-id', @@ -362,6 +378,13 @@ async function createMocks() { disableUsersWithPlace: sinon.stub().resolves(['org.couchdb.user:disabled']), deactivateUsersWithPlace: sinon.stub().resolves(), }; + + if (coreVersion === '4.6') { + chtApi.createPlace.resolves({ placeId: 'created-place-id', contactId: 'created-contact-id' }); + chtApi.updateContactParent.throws('never'); + } else if (coreVersion === '4.7') { + chtApi.createPlace.resolves({ placeId: 'created-place-id', contactId: 'created-contact-id' }); + } const fakeFormData: any = { place_name: 'place', From 9077e459143ca11c4ea517636abe7691eadd4bc2 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 3 Apr 2024 21:10:31 +0700 Subject: [PATCH 11/13] Code polish --- src/lib/cht-api.ts | 5 +---- src/lib/cht-session.ts | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index 317d2a61..eb696f7a 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -276,15 +276,12 @@ class ChtApi_4_7 extends ChtApi_4_6 { super(session); } - // #8986: Look up a single user from their username // #8877: Look up users from their facility_id or contact_id protected override async getUsersAtPlace(placeId: string): Promise { const url = `api/v2/users?facility_id=${placeId}`; console.log('axios.get', url); const resp = await this.axiosInstance.get(url); - return resp.data - ?.filter((d : any) => !d.inactive) // TODO: needed? - ?.map((d: any) => d.id); + return resp.data?.map((d: any) => d.id); } } diff --git a/src/lib/cht-session.ts b/src/lib/cht-session.ts index 2d57995c..e748046d 100644 --- a/src/lib/cht-session.ts +++ b/src/lib/cht-session.ts @@ -16,7 +16,6 @@ type SessionCreationDetails = { authInfo: AuthenticationInfo; username: string; sessionToken: string; - facilityId: string; chtCoreVersion: string; }; @@ -99,8 +98,9 @@ export default class ChtSession { } private static async fetchCreationDetails(authInfo: AuthenticationInfo, username: string, sessionToken: string): Promise { - // would prefer to use the _users/org.couchdb.user:username doc - // only admins have access + GET api/v2/users returns all users and cant return just one + // api/v2/users returns all users prior to 4.6 + // only admins have access to _users database after 4.4 + // we don't know what version of cht-core is running, so we do the only thing supported by all versions const paths = [`medic/org.couchdb.user:${username}`, 'api/v2/monitoring']; const fetches = paths.map(path => { const url = ChtSession.createUrl(authInfo, path); From 91382c8ac06d334a83110122abe9c2863619da70 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Wed, 3 Apr 2024 21:17:04 +0700 Subject: [PATCH 12/13] Polish --- src/lib/cht-api.ts | 50 ++++++++++++++-------------- test/lib/cht-api.spec.ts | 2 +- test/services/upload-manager.spec.ts | 6 ++-- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index eb696f7a..90a00807 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -68,7 +68,7 @@ export class ChtApi { } // workaround https://github.com/medic/cht-core/issues/8674 - updateContactParent = async (parentId: string): Promise => { + async updateContactParent(parentId: string): Promise { const parentDoc = await this.getDoc(parentId); const contactId = parentDoc?.contact?._id; if (!contactId) { @@ -90,9 +90,9 @@ export class ChtApi { } return contactDoc._id; - }; + } - createPlace = async (payload: PlacePayload): Promise => { + async createPlace(payload: PlacePayload): Promise { const url = `api/v1/places`; console.log('axios.post', url); const resp = await this.axiosInstance.post(url, payload); @@ -100,10 +100,10 @@ export class ChtApi { placeId: resp.data.id, contactId: resp.data.contact?.id, }; - }; + } // because there is no PUT for /api/v1/places - createContact = async (payload: PlacePayload): Promise => { + async createContact(payload: PlacePayload): Promise { const payloadWithPlace = { ...payload.contact, place: payload._id, @@ -113,9 +113,9 @@ export class ChtApi { console.log('axios.post', url); const resp = await this.axiosInstance.post(url, payloadWithPlace); return resp.data.id; - }; + } - updatePlace = async (payload: PlacePayload, contactId: string): Promise => { + async updatePlace(payload: PlacePayload, contactId: string): Promise { const doc: any = await this.getDoc(payload._id); const payloadClone:any = _.cloneDeep(payload); @@ -136,9 +136,9 @@ export class ChtApi { } return doc; - }; + } - deleteDoc = async (docId: string): Promise => { + async deleteDoc(docId: string): Promise { const doc: any = await this.getDoc(docId); const deleteContactUrl = `medic/${doc._id}?rev=${doc._rev}`; @@ -147,34 +147,34 @@ export class ChtApi { if (!resp.data.ok) { throw Error('response from chtApi.deleteDoc was not OK'); } - }; + } - disableUsersWithPlace = async (placeId: string): Promise => { + async disableUsersWithPlace(placeId: string): Promise { const usersToDisable: string[] = await this.getUsersAtPlace(placeId); for (const userDocId of usersToDisable) { await this.disableUser(userDocId); } return usersToDisable; - }; + } - deactivateUsersWithPlace = async (placeId: string): Promise => { + async deactivateUsersWithPlace(placeId: string): Promise { const usersToDeactivate: string[] = await this.getUsersAtPlace(placeId); for (const userDocId of usersToDeactivate) { await this.deactivateUser(userDocId); } return usersToDeactivate; - }; + } - createUser = async (user: UserPayload): Promise => { + async createUser(user: UserPayload): Promise { const url = `api/v1/users`; console.log('axios.post', url); const axiosRequestionConfig = { 'axios-retry': { retries: 0 }, // upload-manager handles retries for this }; await this.axiosInstance.post(url, user, axiosRequestionConfig); - }; + } - getParentAndSibling = async (parentId: string, contactType: ContactType): Promise<{ parent: any; sibling: any }> => { + async getParentAndSibling(parentId: string, contactType: ContactType): Promise<{ parent: any; sibling: any }> { const url = `medic/_design/medic/_view/contacts_by_depth`; console.log('axios.get', url); const resp = await this.axiosInstance.get(url, { @@ -191,10 +191,10 @@ export class ChtApi { const parent = docs.find((d: any) => d.contact_type === parentType); const sibling = docs.find((d: any) => d.contact_type === contactType.name); return { parent, sibling }; - }; + } - getPlacesWithType = async (placeType: string) - : Promise => { + public async getPlacesWithType(placeType: string) + : Promise { const url = `medic/_design/medic-client/_view/contacts_by_type_freetext`; const params = { startkey: JSON.stringify([ placeType, 'name:']), @@ -214,7 +214,7 @@ export class ChtApi { type: 'remote', }; }); - }; + } public get chtSession(): ChtSession { return this.session.clone(); @@ -237,12 +237,12 @@ export class ChtApi { return resp.data?.docs?.map((d: any) => d._id); } - private getDoc = async (id: string): Promise => { + private async getDoc(id: string): Promise { const url = `medic/${id}`; console.log('axios.get', url); const resp = await this.axiosInstance.get(url); return resp.data; - }; + } private async deactivateUser(docId: string): Promise { const username = docId.substring('org.couchdb.user:'.length); @@ -250,14 +250,14 @@ export class ChtApi { console.log('axios.post', url); const deactivationPayload = { roles: ['deactivated' ]}; return this.axiosInstance.post(url, deactivationPayload); - }; + } private async disableUser(docId: string): Promise { const username = docId.substring('org.couchdb.user:'.length); const url = `api/v1/users/${username}`; console.log('axios.delete', url); return this.axiosInstance.delete(url); - }; + } } class ChtApi_4_6 extends ChtApi { diff --git a/test/lib/cht-api.spec.ts b/test/lib/cht-api.spec.ts index 526e2776..fb5626cf 100644 --- a/test/lib/cht-api.spec.ts +++ b/test/lib/cht-api.spec.ts @@ -30,7 +30,7 @@ describe('lib/cht-api.ts', () => { const session = mockChtSession(); session.chtCoreVersion = 'invalid'; expect(() => ChtApi.create(session)).to.throw('invalid'); - }) + }); }); }); diff --git a/test/services/upload-manager.spec.ts b/test/services/upload-manager.spec.ts index 9efe1620..dc0c7be8 100644 --- a/test/services/upload-manager.spec.ts +++ b/test/services/upload-manager.spec.ts @@ -16,7 +16,7 @@ describe('services/upload-manager.ts', () => { const coreScenarios = [ 'base', '4.6', - '4.7', + // no value to test this '4.7' since the results of the mock are identical ]; beforeEach(() => { @@ -379,11 +379,9 @@ async function createMocks(coreVersion: string) { deactivateUsersWithPlace: sinon.stub().resolves(), }; - if (coreVersion === '4.6') { + if (coreVersion !== 'base') { chtApi.createPlace.resolves({ placeId: 'created-place-id', contactId: 'created-contact-id' }); chtApi.updateContactParent.throws('never'); - } else if (coreVersion === '4.7') { - chtApi.createPlace.resolves({ placeId: 'created-place-id', contactId: 'created-contact-id' }); } const fakeFormData: any = { From e86f589aeaa31bde680417a788a7a7bf3591bee4 Mon Sep 17 00:00:00 2001 From: kennsippell Date: Fri, 20 Dec 2024 14:41:59 -0700 Subject: [PATCH 13/13] Tests passing --- src/lib/cht-session.ts | 2 +- src/server.ts | 3 +- test/lib/cht-session.spec.ts | 2 - test/mocks.ts | 2 +- test/services/upload-manager.spec.ts | 685 ++++++++++++++------------- 5 files changed, 357 insertions(+), 337 deletions(-) diff --git a/src/lib/cht-session.ts b/src/lib/cht-session.ts index a7ecbe75..bc96a2fb 100644 --- a/src/lib/cht-session.ts +++ b/src/lib/cht-session.ts @@ -115,7 +115,7 @@ export default class ChtSession { const isAdmin = _.intersection(adminRoles, userDoc?.roles).length > 0; const chtCoreVersion = monitoringResponse.data?.version?.app; - const facilityIds = isAdmin ? [ADMIN_FACILITY_ID] : _.flatten([userDoc?.facility_id]); + const facilityIds = isAdmin ? [ADMIN_FACILITY_ID] : _.flatten([userDoc?.facility_id]).filter(Boolean); if (!facilityIds?.length) { throw Error(`User ${username} does not have a facility_id connected to their user doc`); } diff --git a/src/server.ts b/src/server.ts index 63553acf..523875ca 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,8 +12,9 @@ import view from '@fastify/view'; const metricsPlugin = require('fastify-metrics'); import Auth from './lib/authentication'; -import SessionCache from './services/session-cache'; +import { ChtApi } from './lib/cht-api'; import { checkRedisConnection } from './config/config-worker'; +import SessionCache from './services/session-cache'; const build = (opts: FastifyServerOptions): FastifyInstance => { const fastify = Fastify(opts); diff --git a/test/lib/cht-session.spec.ts b/test/lib/cht-session.spec.ts index a159b13c..3087f322 100644 --- a/test/lib/cht-session.spec.ts +++ b/test/lib/cht-session.spec.ts @@ -73,8 +73,6 @@ describe('lib/cht-session.ts', () => { it('throw if user-settings has no facility_id', async () => { mockAxios.get.resolves(mockUserFacilityDoc('', [])); - ChtSession.__set__('axios', mockAxios); - await expect(ChtSession.default.create(mockAuthInfo, 'user', 'pwd')).to.eventually.be.rejectedWith('does not have a facility_id'); }); }); diff --git a/test/mocks.ts b/test/mocks.ts index ef26095e..05636f47 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -139,7 +139,7 @@ export const mockChtSession = (userFacilityId: string = '*') : ChtSession => new }, sessionToken: 'session-token', username: 'username', - facilityId: userFacilityId, + facilityIds: [userFacilityId], chtCoreVersion: '4.2.2', }); diff --git a/test/services/upload-manager.spec.ts b/test/services/upload-manager.spec.ts index 0fca6ea5..26ccee6d 100644 --- a/test/services/upload-manager.spec.ts +++ b/test/services/upload-manager.spec.ts @@ -11,345 +11,361 @@ import { Config } from '../../src/config'; import RemotePlaceResolver from '../../src/lib/remote-place-resolver'; import { UploadManagerRetryScenario } from '../lib/retry-logic.spec'; +const CORE_SCENARIOS = [ + 'base', + '4.6', + // no value to test this '4.7' since the results of the mock are identical +]; + describe('services/upload-manager.ts', () => { beforeEach(() => { RemotePlaceCache.clear({}); }); - 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(); - await uploadManager.doUpload([place], chtApi); - - expect(chtApi.createPlace.calledOnce).to.be.true; - const placePayload = chtApi.createPlace.args[0][0]; - expect(placePayload).to.nested.include({ - 'contact.contact_type': contactType.contact_type, - 'contact.name': 'contact', - prop: 'foo', - name: 'Place Community Health Unit', - parent: subcounty._id, - contact_type: contactType.name, - }); - expect(chtApi.updateContactParent.calledOnce).to.be.true; - expect(chtApi.updateContactParent.args[0]).to.deep.eq(['created-place-id']); - - expect(chtApi.createUser.calledOnce).to.be.true; - const userPayload = chtApi.createUser.args[0][0]; - expect(userPayload).to.deep.include({ - contact: 'created-contact-id', - place: 'created-place-id', - roles: ['role'], - username: 'contact', - }); - expect(chtApi.deleteDoc.called).to.be.false; - expect(place.isCreated).to.be.true; - }); - - 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 = subcounty.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.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; - }); - - it('uploads in batches', async () => { - const placeCount = 11; - const { fakeFormData, contactType, sessionCache, chtApi } = await createMocks(); - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - const places = Array(placeCount).fill(place).map(p => _.cloneDeep(p)); - const uploadManager = new UploadManager(); - await uploadManager.doUpload(places, chtApi); - expect(chtApi.createUser.callCount).to.eq(placeCount); - expect(places.find(p => !p.isCreated)).to.be.undefined; - }); - - it('required attributes can be inherited during replacement', async () => { - 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: ChtDoc = { - _id: 'id-replace', - name: 'to-replace', - parent: { _id: subcounty._id }, - }; - - chtApi.getPlacesWithType - .resolves([subcounty]) - .onSecondCall() - .resolves([toReplace]); - - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - expect(place.validationErrors).to.be.empty; // only parent is required when replacing - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - expect(chtApi.updatePlace.calledOnce).to.be.true; - expect(chtApi.updatePlace.args[0][0]).to.not.have.property('prop'); - expect(chtApi.updatePlace.args[0][0]).to.not.have.property('name'); - expect(chtApi.deleteDoc.calledOnce).to.be.true; - expect(place.isCreated).to.be.true; - }); - - it('contact_type replacement with username_from_place:true', async () => { - 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: ChtDoc = { - _id: 'id-replace', - name: 'replac"e$mENT baSed username', - parent: { _id: subcounty._id }, - }; - - chtApi.getPlacesWithType - .resolves([subcounty]) - .onSecondCall() - .resolves([toReplace]); - - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - expect(place.validationErrors).to.be.empty; // only parent is required when replacing - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - expect(chtApi.createUser.args[0][0]).to.deep.include({ - username: 'replacement_based_username', - }); - expect(place.isCreated).to.be.true; - }); - - it('contact_type replacement with deactivate_users_on_replace:true', async () => { - 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: ChtDoc = { - _id: 'id-replace', - name: 'deactivate me', - parent: { _id: subcounty._id }, - }; - - chtApi.getPlacesWithType - .resolves([subcounty]) - .onSecondCall() - .resolves([toReplace]); - - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - expect(place.validationErrors).to.be.empty; // only parent is required when replacing - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - expect(chtApi.createUser.callCount).to.eq(1); - expect(chtApi.disableUsersWithPlace.called).to.be.false; - expect(chtApi.deleteDoc.called).to.be.false; - expect(chtApi.deactivateUsersWithPlace.called).to.be.true; - expect(chtApi.deactivateUsersWithPlace.args[0][0]).to.eq('id-replace'); - expect(place.isCreated).to.be.true; - }); - - it('place with validation error is not uploaded', async () => { - const { sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); - delete fakeFormData.place_name; - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - expect(place.validationErrors).to.not.be.empty; - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - expect(chtApi.createUser.called).to.be.false; - expect(place.isCreated).to.be.false; - }); - - it('uploading a chu and dependant chp where chp is created first', async () => { - const { subcounty, sessionCache, chtApi } = await createMocks(); - - chtApi.getPlacesWithType - .resolves([]) // parent of chp - .onSecondCall().resolves([subcounty]) // grandparent of chp (subcounty) - .onThirdCall().resolves([]); // chp replacements - - const chu_name = 'new chu'; - const chpType = Config.getContactType('d_community_health_volunteer_area'); - const chpData = { - hierarchy_CHU: chu_name, - place_name: 'new chp', - contact_name: 'new chp', - contact_phone: '0788889999', - }; - - // CHP has validation errors because it references a CHU which does not exist - const chp = await PlaceFactory.createOne(chpData, chpType, sessionCache, chtApi); - expectInvalidProperties(chp.validationErrors, ['hierarchy_CHU'], 'Cannot find'); - - const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); - - // refresh the chp - await RemotePlaceResolver.resolveOne(chp, sessionCache, chtApi, { fuzz: true }); - chp.validate(); - expect(chp.validationErrors).to.be.empty; - - // upload succeeds - chtApi.getParentAndSibling = sinon.stub().resolves({ parent: chu.asChtPayload('user'), sibling: undefined }); - const uploadManager = new UploadManager(); - await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); - expect(chu.isCreated).to.be.true; - expect(chp.isCreated).to.be.true; - - // 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']); - }); - - it('failure to upload', async () => { - 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(subcounty, chu_name, sessionCache, chtApi); - - const uploadManager = new UploadManager(); - await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); - expect(chu.isCreated).to.be.false; - expect(chtApi.createUser.calledOnce).to.be.true; - expect(chu.uploadError).to.include('upload-error'); - expect(chu.creationDetails).to.deep.eq({ - contactId: 'created-contact-id', - placeId: 'created-place-id', - }); - - await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); - expect(chu.isCreated).to.be.true; - expect(chu.uploadError).to.be.undefined; - expect(chu.creationDetails).to.deep.include({ - contactId: 'created-contact-id', - placeId: 'created-place-id', - username: 'new' + for (const scenario of CORE_SCENARIOS) { + describe(`cht-core version ${scenario}`, () => { + it('mock data is properly sent to chtApi - standard', async () => { + const { fakeFormData, contactType, chtApi, sessionCache, subcounty } = await createMocks(scenario); + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + + expect(chtApi.createPlace.calledOnce).to.be.true; + const placePayload = chtApi.createPlace.args[0][0]; + expect(placePayload).to.nested.include({ + 'contact.contact_type': contactType.contact_type, + 'contact.name': 'contact', + prop: 'foo', + name: 'Place Community Health Unit', + parent: subcounty._id, + contact_type: contactType.name, + }); + + if (scenario === 'base') { + expect(chtApi.updateContactParent.calledOnce).to.be.true; + expect(chtApi.updateContactParent.args[0]).to.deep.eq(['created-place-id']); + } + + expect(chtApi.createUser.calledOnce).to.be.true; + const userPayload = chtApi.createUser.args[0][0]; + expect(userPayload).to.deep.include({ + contact: 'created-contact-id', + place: 'created-place-id', + roles: ['role'], + username: 'contact', + }); + expect(chtApi.deleteDoc.called).to.be.false; + expect(place.isCreated).to.be.true; + }); + + it('mock data is properly sent to chtApi - sessionCache cache', async () => { + const { fakeFormData, contactType, sessionCache, chtApi, subcounty } = await createMocks(scenario); + + const parentContactType = mockValidContactType('string', undefined); + parentContactType.name = subcounty.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.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; + }); + + it('uploads in batches', async () => { + const placeCount = 11; + const { fakeFormData, contactType, sessionCache, chtApi } = await createMocks(scenario); + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + const places = Array(placeCount).fill(place).map(p => _.cloneDeep(p)); + const uploadManager = new UploadManager(); + await uploadManager.doUpload(places, chtApi); + expect(chtApi.createUser.callCount).to.eq(placeCount); + expect(places.find(p => !p.isCreated)).to.be.undefined; + }); + + it('required attributes can be inherited during replacement', async () => { + const { subcounty, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(scenario); + 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: ChtDoc = { + _id: 'id-replace', + name: 'to-replace', + parent: { _id: subcounty._id }, + }; + + chtApi.getPlacesWithType + .resolves([subcounty]) + .onSecondCall() + .resolves([toReplace]); + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.be.empty; // only parent is required when replacing + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + expect(chtApi.updatePlace.calledOnce).to.be.true; + expect(chtApi.updatePlace.args[0][0]).to.not.have.property('prop'); + expect(chtApi.updatePlace.args[0][0]).to.not.have.property('name'); + expect(chtApi.deleteDoc.calledOnce).to.be.true; + expect(place.isCreated).to.be.true; + }); + + it('contact_type replacement with username_from_place:true', async () => { + const { subcounty, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(scenario); + contactType.username_from_place = true; + + fakeFormData.hierarchy_replacement = 'replacement based username'; + fakeFormData.place_name = ''; // optional due to replacement + + const toReplace: ChtDoc = { + _id: 'id-replace', + name: 'replac"e$mENT baSed username', + parent: { _id: subcounty._id }, + }; + + chtApi.getPlacesWithType + .resolves([subcounty]) + .onSecondCall() + .resolves([toReplace]); + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.be.empty; // only parent is required when replacing + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + expect(chtApi.createUser.args[0][0]).to.deep.include({ + username: 'replacement_based_username', + }); + expect(place.isCreated).to.be.true; + }); + + it('contact_type replacement with deactivate_users_on_replace:true', async () => { + const { subcounty, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(scenario); + contactType.deactivate_users_on_replace = true; + + fakeFormData.hierarchy_replacement = 'deactivate me'; + fakeFormData.place_name = ''; // optional due to replacement + + const toReplace: ChtDoc = { + _id: 'id-replace', + name: 'deactivate me', + parent: { _id: subcounty._id }, + }; + + chtApi.getPlacesWithType + .resolves([subcounty]) + .onSecondCall() + .resolves([toReplace]); + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.be.empty; // only parent is required when replacing + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + expect(chtApi.createUser.callCount).to.eq(1); + expect(chtApi.disableUsersWithPlace.called).to.be.false; + expect(chtApi.deleteDoc.called).to.be.false; + expect(chtApi.deactivateUsersWithPlace.called).to.be.true; + expect(chtApi.deactivateUsersWithPlace.args[0][0]).to.eq('id-replace'); + expect(place.isCreated).to.be.true; + }); + + it('place with validation error is not uploaded', async () => { + const { sessionCache, contactType, fakeFormData, chtApi } = await createMocks(scenario); + delete fakeFormData.place_name; + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.not.be.empty; + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + expect(chtApi.createUser.called).to.be.false; + expect(place.isCreated).to.be.false; + }); + + it('uploading a chu and dependant chp where chp is created first', async () => { + const { subcounty, sessionCache, chtApi } = await createMocks(scenario); + + chtApi.getPlacesWithType + .resolves([]) // parent of chp + .onSecondCall().resolves([subcounty]) // grandparent of chp (subcounty) + .onThirdCall().resolves([]); // chp replacements + + const chu_name = 'new chu'; + const chpType = Config.getContactType('d_community_health_volunteer_area'); + const chpData = { + hierarchy_CHU: chu_name, + place_name: 'new chp', + contact_name: 'new chp', + contact_phone: '0788889999', + }; + + // CHP has validation errors because it references a CHU which does not exist + const chp = await PlaceFactory.createOne(chpData, chpType, sessionCache, chtApi); + expectInvalidProperties(chp.validationErrors, ['hierarchy_CHU'], 'Cannot find'); + + const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); + + // refresh the chp + await RemotePlaceResolver.resolveOne(chp, sessionCache, chtApi, { fuzz: true }); + chp.validate(); + expect(chp.validationErrors).to.be.empty; + + // upload succeeds + chtApi.getParentAndSibling = sinon.stub().resolves({ parent: chu.asChtPayload('user'), sibling: undefined }); + const uploadManager = new UploadManager(); + await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + expect(chu.isCreated).to.be.true; + expect(chp.isCreated).to.be.true; + + // 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']); + }); + + it('failure to upload', async () => { + const { subcounty, sessionCache, chtApi } = await createMocks(scenario); + + chtApi.createUser + .throws({ response: { status: 404 }, toString: () => 'upload-error' }) + .onSecondCall().resolves(); + + const chu_name = 'new chu'; + const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); + + const uploadManager = new UploadManager(); + await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + expect(chu.isCreated).to.be.false; + expect(chtApi.createUser.calledOnce).to.be.true; + expect(chu.uploadError).to.include('upload-error'); + expect(chu.creationDetails).to.deep.eq({ + contactId: 'created-contact-id', + placeId: 'created-place-id', + }); + + await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + expect(chu.isCreated).to.be.true; + expect(chu.uploadError).to.be.undefined; + expect(chu.creationDetails).to.deep.include({ + contactId: 'created-contact-id', + placeId: 'created-place-id', + username: 'new' + }); + expect(chu.creationDetails.password).to.not.be.undefined; + + expect(chtApi.createPlace.callCount).to.eq(1); + expect(chtApi.updateContactParent.callCount).to.eq(scenario === 'base' ? 1 : 0); + expect(chtApi.createUser.callCount).to.eq(2); + expect(chtApi.getParentAndSibling.called).to.be.false; + expect(chtApi.createContact.called).to.be.false; + expect(chtApi.updatePlace.called).to.be.false; + expect(chtApi.deleteDoc.called).to.be.false; + expect(chtApi.disableUsersWithPlace.called).to.be.false; + }); + + it('#146 - error details are clear when CHT returns a string', async () => { + const { subcounty, sessionCache, chtApi } = await createMocks(scenario); + const errorString = 'foo'; + + chtApi.createPlace.throws({ response: { data: errorString } }); + + const chu_name = 'new chu'; + const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); + + const uploadManager = new UploadManager(); + await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + expect(chu.isCreated).to.be.false; + expect(chtApi.createUser.called).to.be.false; + expect(chu.uploadError).to.include(errorString); + }); + + it(`createUser is retried`, async() => { + const { subcounty, sessionCache, chtApi } = await createMocks(scenario); + + chtApi.createUser.throws(UploadManagerRetryScenario.axiosError); + + const chu_name = 'new chu'; + const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); + + const uploadManager = new UploadManager(); + await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); + expect(chu.isCreated).to.be.false; + expect(chtApi.createUser.callCount).to.be.gt(2); // retried + 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, subcounty } = await createMocks(scenario); + + contactType.user_role = ['role1', 'role2']; + fakeFormData.user_role = 'role1 role2'; + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + + expect(chtApi.createPlace.calledOnce).to.be.true; + const placePayload = chtApi.createPlace.args[0][0]; + expect(placePayload).to.nested.include({ + 'contact.contact_type': contactType.contact_type, + 'contact.name': 'contact', + prop: 'foo', + name: 'Place Community Health Unit', + parent: subcounty._id, + contact_type: contactType.name, + }); + + if (scenario === 'base') { + expect(chtApi.updateContactParent.calledOnce).to.be.true; + expect(chtApi.updateContactParent.args[0]).to.deep.eq(['created-place-id']); + } + + expect(chtApi.createUser.calledOnce).to.be.true; + const userPayload = chtApi.createUser.args[0][0]; + expect(userPayload).to.deep.include({ + contact: 'created-contact-id', + place: 'created-place-id', + roles: ['role1', 'role2'], + username: 'contact', + }); + expect(place.isCreated).to.be.true; + }); + + it('#173 - replacement when place has no primary contact', async () => { + const { subcounty, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(scenario); + const toReplace: ChtDoc = { + _id: 'id-replace', + name: 'to-replace', + }; + + chtApi.updatePlace.resolves({ _id: 'updated-place-id' }); + fakeFormData.hierarchy_replacement = toReplace.name; + + chtApi.getPlacesWithType + .resolves([subcounty]) + .onSecondCall() + .resolves([toReplace]); + + const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); + expect(place.validationErrors).to.be.empty; + + const uploadManager = new UploadManager(); + await uploadManager.doUpload([place], chtApi); + expect(chtApi.deleteDoc.callCount).to.eq(0); + expect(place.isCreated).to.be.true; + }); }); - expect(chu.creationDetails.password).to.not.be.undefined; - - expect(chtApi.createPlace.callCount).to.eq(1); - expect(chtApi.updateContactParent.callCount).to.eq(1); - expect(chtApi.createUser.callCount).to.eq(2); - expect(chtApi.getParentAndSibling.called).to.be.false; - expect(chtApi.createContact.called).to.be.false; - expect(chtApi.updatePlace.called).to.be.false; - expect(chtApi.deleteDoc.called).to.be.false; - expect(chtApi.disableUsersWithPlace.called).to.be.false; - }); - - it('#146 - error details are clear when CHT returns a string', async () => { - const { subcounty, sessionCache, chtApi } = await createMocks(); - const errorString = 'foo'; - - chtApi.createPlace.throws({ response: { data: errorString } }); - - const chu_name = 'new chu'; - const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); - - const uploadManager = new UploadManager(); - await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); - expect(chu.isCreated).to.be.false; - expect(chtApi.createUser.called).to.be.false; - expect(chu.uploadError).to.include(errorString); - }); - - it(`createUser is retried`, async() => { - const { subcounty, sessionCache, chtApi } = await createMocks(); - - chtApi.createUser.throws(UploadManagerRetryScenario.axiosError); - - const chu_name = 'new chu'; - const chu = await createChu(subcounty, chu_name, sessionCache, chtApi); - - const uploadManager = new UploadManager(); - await uploadManager.doUpload(sessionCache.getPlaces(), chtApi); - expect(chu.isCreated).to.be.false; - expect(chtApi.createUser.callCount).to.be.gt(2); // retried - 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, subcounty } = await createMocks(); - - contactType.user_role = ['role1', 'role2']; - fakeFormData.user_role = 'role1 role2'; - - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - - expect(chtApi.createPlace.calledOnce).to.be.true; - const placePayload = chtApi.createPlace.args[0][0]; - expect(placePayload).to.nested.include({ - 'contact.contact_type': contactType.contact_type, - 'contact.name': 'contact', - prop: 'foo', - name: 'Place Community Health Unit', - parent: subcounty._id, - contact_type: contactType.name, - }); - expect(chtApi.updateContactParent.calledOnce).to.be.true; - expect(chtApi.updateContactParent.args[0]).to.deep.eq(['created-place-id']); - - expect(chtApi.createUser.calledOnce).to.be.true; - const userPayload = chtApi.createUser.args[0][0]; - expect(userPayload).to.deep.include({ - contact: 'created-contact-id', - place: 'created-place-id', - roles: ['role1', 'role2'], - username: 'contact', - }); - expect(place.isCreated).to.be.true; - }); - - it('#173 - replacement when place has no primary contact', async () => { - const { subcounty, sessionCache, contactType, fakeFormData, chtApi } = await createMocks(); - const toReplace: ChtDoc = { - _id: 'id-replace', - name: 'to-replace', - }; - - chtApi.updatePlace.resolves({ _id: 'updated-place-id' }); - fakeFormData.hierarchy_replacement = toReplace.name; - - chtApi.getPlacesWithType - .resolves([subcounty]) - .onSecondCall() - .resolves([toReplace]); - - const place = await PlaceFactory.createOne(fakeFormData, contactType, sessionCache, chtApi); - expect(place.validationErrors).to.be.empty; - - const uploadManager = new UploadManager(); - await uploadManager.doUpload([place], chtApi); - expect(chtApi.deleteDoc.callCount).to.eq(0); - expect(place.isCreated).to.be.true; - }); + } }); -async function createMocks() { +async function createMocks(coreVersion) { const contactType = mockValidContactType('string', undefined); const subcounty: ChtDoc = { _id: 'parent-id', @@ -359,7 +375,7 @@ async function createMocks() { const chtApi = { chtSession: mockChtSession(), getPlacesWithType: sinon.stub().resolves([subcounty]), - createPlace: sinon.stub().resolves('created-place-id'), + createPlace: sinon.stub().resolves({ placeId: 'created-place-id' }), updateContactParent: sinon.stub().resolves('created-contact-id'), createUser: sinon.stub().resolves(), @@ -375,6 +391,11 @@ async function createMocks() { disableUsersWithPlace: sinon.stub().resolves(['org.couchdb.user:disabled']), deactivateUsersWithPlace: sinon.stub().resolves(), }; + + if (coreVersion !== 'base') { + chtApi.createPlace.resolves({ placeId: 'created-place-id', contactId: 'created-contact-id' }); + chtApi.updateContactParent.throws('never'); + } const fakeFormData: any = { place_name: 'place',