diff --git a/package-lock.json b/package-lock.json index 1b41c7d9..cb95915d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/lodash": "^4.14.201", "@types/luxon": "^3.4.2", "@types/node": "^20.8.8", + "@types/semver": "^7.5.8", "@types/uuid": "^9.0.6", "axios": "^1.5.1", "axios-retry": "^4.0.0", @@ -1235,8 +1236,8 @@ }, "node_modules/@types/semver": { "version": "7.5.8", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==" }, "node_modules/@types/sinon": { "version": "17.0.3", diff --git a/package.json b/package.json index 98655ed9..b836fe6c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@types/lodash": "^4.14.201", "@types/luxon": "^3.4.2", "@types/node": "^20.8.8", + "@types/semver": "^7.5.8", "@types/uuid": "^9.0.6", "axios": "^1.5.1", "axios-retry": "^4.0.0", diff --git a/src/lib/cht-api.ts b/src/lib/cht-api.ts index ea79f39d..7b6a244a 100644 --- a/src/lib/cht-api.ts +++ b/src/lib/cht-api.ts @@ -2,6 +2,7 @@ import _ from 'lodash'; import { AxiosInstance } from 'axios'; import ChtSession from './cht-session'; import { Config, ContactType } from '../config'; +import { DateTime } from 'luxon'; import { UserPayload } from '../services/user-payload'; export type PlacePayload = { @@ -113,6 +114,20 @@ export class ChtApi { return this.axiosInstance.post(url, userInfo); } + async countContactsUnderPlace(docId: string): Promise { + const url = `medic/_design/medic/_view/contacts_by_depth`; + console.log('axios.get', url); + const resp = await this.axiosInstance.get(url, { + params: { + startkey: JSON.stringify([docId, 0]), + endkey: JSON.stringify([docId, 20]), + include_docs: false, + }, + }); + + return resp.data?.rows?.length || 0; + } + async createUser(user: UserPayload): Promise { const url = `api/v1/users`; console.log('axios.post', url); @@ -168,4 +183,35 @@ export class ChtApi { place: doc.place, })); } + + async lastSyncAtPlace(placeId: string): Promise { + const userIds = await this.getUsersAtPlace(placeId); + const usernames = userIds.map(userId => userId.username); + const result = await this.getLastSyncForUsers(usernames); + return result || DateTime.invalid('unknown'); + } + + private getLastSyncForUsers = async (usernames: string[]): Promise => { + if (!usernames?.length) { + return undefined; + } + + const url = '/medic-logs/_all_docs'; + const keys = usernames.map(username => `connected-user-${username}`); + const payload = { + keys, + include_docs: true, + }; + + console.log('axios.post', url); + const resp = await this.axiosInstance.post(url, payload); + const timestamps = resp.data?.rows?.map((row: any) => row.doc?.timestamp); + + if (!timestamps?.length) { + return undefined; + } + + const maxTimestamp = Math.max(timestamps); + return DateTime.fromMillis(maxTimestamp); + }; } diff --git a/src/lib/cht-session.ts b/src/lib/cht-session.ts index 356b1df9..3e2648e5 100644 --- a/src/lib/cht-session.ts +++ b/src/lib/cht-session.ts @@ -47,6 +47,10 @@ export default class ChtSession { } } + public get isAdmin(): boolean { + return this.facilityIds.includes(ADMIN_FACILITY_ID); + } + public static async create(authInfo: AuthenticationInfo, username : string, password: string): Promise { const sessionToken = await ChtSession.createSessionToken(authInfo, username, password); @@ -66,7 +70,7 @@ export default class ChtSession { isPlaceAuthorized(remotePlace: RemotePlace): boolean { return this.facilityIds?.length > 0 && ( - this.facilityIds.includes(ADMIN_FACILITY_ID) + this.isAdmin || _.intersection(remotePlace?.lineage, this.facilityIds).length > 0 || this.facilityIds.includes(remotePlace?.id) ); diff --git a/src/lib/manage-hierarchy.ts b/src/lib/manage-hierarchy.ts index 4df5d655..7dd0c148 100644 --- a/src/lib/manage-hierarchy.ts +++ b/src/lib/manage-hierarchy.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { DateTime } from 'luxon'; import Auth from './authentication'; import { ChtApi } from './cht-api'; @@ -13,25 +14,21 @@ import { RemotePlace } from './remote-place-cache'; export const HIERARCHY_ACTIONS = ['move', 'merge', 'delete']; export type HierarchyAction = typeof HIERARCHY_ACTIONS[number]; +const ACTIVE_USER_THRESHOLD_DAYS = 60; +const LARGE_PLACE_THRESHOLD_PLACE_COUNT = 100; + +export type WarningInformation = { + affectedPlaceCount: number; + lastSyncDescription: string; + userIsActive: boolean; + lotsOfPlaces: boolean; +}; + export default class ManageHierarchyLib { private constructor() { } - public static async scheduleJob( - formData: any, - contactType: ContactType, - sessionCache: SessionCache, - chtApi: ChtApi, - queueName: IQueue = getChtConfQueue() - ) { - const { sourceLineage, destinationLineage, jobParam } = await getJobDetails(formData, contactType, sessionCache, chtApi); - - await queueName.add(jobParam); - - return { - destinationLineage, - sourceLineage, - success: true - }; + public static async scheduleJob(job: JobParams, queueName: IQueue = getChtConfQueue()) { + await queueName.add(job); } public static parseHierarchyAction(action: string = ''): HierarchyAction { @@ -41,26 +38,57 @@ export default class ManageHierarchyLib { return action as HierarchyAction; } + + public static async getJobDetails(formData: any, contactType: ContactType, sessionCache: SessionCache, chtApi: ChtApi): Promise { + const hierarchyAction = ManageHierarchyLib.parseHierarchyAction(formData.op); + const sourceLineage = await resolve('source_', formData, contactType, sessionCache, chtApi); + const destinationLineage = hierarchyAction === 'delete' ? [] : await resolve('destination_', formData, contactType, sessionCache, chtApi); + + const { sourceId, destinationId } = getSourceAndDestinationIds(hierarchyAction, sourceLineage, destinationLineage); + const jobData = getJobData(hierarchyAction, sourceId, destinationId, chtApi); + const jobName = getJobName(jobData.action, sourceLineage, destinationLineage); + const jobParam: JobParams = { + jobName, + jobData, + }; + + return jobParam; + } + + public static async getWarningInfo(job: JobParams, chtApi: ChtApi): Promise { + const { jobData: { sourceId } } = job; + const affectedPlaceCount = await chtApi.countContactsUnderPlace(sourceId); + const lastSyncTime = chtApi.chtSession.isAdmin ? await chtApi.lastSyncAtPlace(sourceId) : DateTime.invalid('must be admin'); + const syncBelowThreshold = diffNowInDays(lastSyncTime) < ACTIVE_USER_THRESHOLD_DAYS; + const lastSyncDescription = describeDateTime(lastSyncTime); + return { + affectedPlaceCount, + lastSyncDescription, + userIsActive: syncBelowThreshold && lastSyncDescription !== '-', + lotsOfPlaces: affectedPlaceCount > LARGE_PLACE_THRESHOLD_PLACE_COUNT, + }; + } } -async function getJobDetails(formData: any, contactType: ContactType, sessionCache: SessionCache, chtApi: ChtApi) { - const hierarchyAction = ManageHierarchyLib.parseHierarchyAction(formData.op); - const sourceLineage = await resolve('source_', formData, contactType, sessionCache, chtApi); - const destinationLineage = hierarchyAction === 'delete' ? [] : await resolve('destination_', formData, contactType, sessionCache, chtApi); - - const { sourceId, destinationId } = getSourceAndDestinationIds(hierarchyAction, sourceLineage, destinationLineage); - const jobData = getJobData(hierarchyAction, sourceId, destinationId, chtApi); - const jobName = getJobName(jobData.action, sourceLineage, destinationLineage); - const jobParam: JobParams = { - jobName, - jobData, - }; +function diffNowInDays(dateTime: DateTime): number { + return -(dateTime?.diffNow('days')?.days || 0); +} - return { - sourceLineage, - destinationLineage, - jobParam - }; +function describeDateTime(dateTime: DateTime): string { + if (!dateTime || !dateTime.isValid) { + return '-'; + } + + const diffNow = diffNowInDays(dateTime); + if (diffNow > 365) { + return 'over a year'; + } + + if (diffNow < 0) { + return '-'; + } + + return dateTime.toRelativeCalendar() || '-'; } function getSourceAndDestinationIds( @@ -80,7 +108,7 @@ function getSourceAndDestinationIds( const destinationIndex = hierarchyAction === 'move' ? 1 : 0; const destinationId = destinationLineage[destinationIndex]?.id; if (!destinationId) { - throw Error('Unexpected error: Hierarchy operation due to missing destination information'); + throw Error('Unexpected error: Hierarchy operation failed due to missing destination information'); } @@ -92,7 +120,7 @@ function getSourceAndDestinationIds( if (hierarchyAction === 'merge') { if (destinationId === sourceId) { - throw Error(`Cannot merge "${destinationId}" with self`); + throw Error(`Cannot merge place with self`); } } diff --git a/src/liquid/components/manage_hierarchy_form_content.html b/src/liquid/components/manage_hierarchy_form_content.html new file mode 100644 index 00000000..e5eedccc --- /dev/null +++ b/src/liquid/components/manage_hierarchy_form_content.html @@ -0,0 +1,54 @@ +{% if error %} +
+ {{ error }} +
+{% endif %} + +{% if success %} +
+{% endif %} + + + + +{% if confirm %} + {% include "components/manage_hierarchy_warning" %} +{% endif %} + +
+
+

{{ sourceDescription }}

+ {% for hierarchy in sourceHierarchy %} + {% + include "components/search_input.html" + type=contactType.name + hierarchy=hierarchy + data=data + required=hierarchy.required + prefix="source_" + %} + {% endfor %} +
+ + {% if destinationHierarchy.size > 0 %} +
+

{{ destinationDescription }}

+ {% for hierarchy in destinationHierarchy %} + {% + include "components/search_input.html" + type=contactType.name + hierarchy=hierarchy + data=data + required=hierarchy.required + prefix="destination_" + %} + {% endfor %} +
+ {% endif %} + +
+
+ +
+
+
\ No newline at end of file diff --git a/src/liquid/components/manage_hierarchy_warning.html b/src/liquid/components/manage_hierarchy_warning.html new file mode 100644 index 00000000..aa9b15ef --- /dev/null +++ b/src/liquid/components/manage_hierarchy_warning.html @@ -0,0 +1,49 @@ +
+

Confirm you want to {{ op }} this:

+ + + {% for hierarchy in sourceHierarchy %} + {% capture property_name %}source_{{ hierarchy.property_name }}{% endcapture %} + {%if data[property_name] and data[property_name] != empty %} + + + + + {% endif %} + {% endfor %} + + {% for hierarchy in destinationHierarchy %} + {% capture property_name %}destination_{{ hierarchy.property_name }}{% endcapture %} + {%if data[property_name] and data[property_name] != empty %} + + + + + {% endif %} + {% endfor %} + + + + + + + + + + + +
Source {{ hierarchy.friendly_name }}{{ data[property_name] }}
Destination {{ hierarchy.friendly_name }}{{ data[property_name] }}
# of Affected Contacts{{ warningInfo.affectedPlaceCount }}
User's Last Sync{{ warningInfo.lastSyncDescription }}
+ + {% if isPermanent %}Cannot be undone{% endif %} + {% if warningInfo.userIsActive %}User is active{% endif %} + {% if warningInfo.lotsOfPlaces %}Large amount of data{% endif %} + +
+
+ + Cancel +
+
+ + +
\ No newline at end of file diff --git a/src/liquid/place/manage_hierarchy_form.html b/src/liquid/place/manage_hierarchy_form.html index 37c5dcbc..dfaa35c1 100644 --- a/src/liquid/place/manage_hierarchy_form.html +++ b/src/liquid/place/manage_hierarchy_form.html @@ -1,80 +1,31 @@
- {% if error %} -
- {{ error }} -
- {% endif %} + {% include "components/manage_hierarchy_form_content" %} - {% if success %} -
- {% endif %} + \ No newline at end of file + } +
\ No newline at end of file diff --git a/src/routes/manage-hierarchy.ts b/src/routes/manage-hierarchy.ts index 1471a470..7dec55f5 100644 --- a/src/routes/manage-hierarchy.ts +++ b/src/routes/manage-hierarchy.ts @@ -32,32 +32,32 @@ export default async function sessionCache(fastify: FastifyInstance) { const contactType = Config.getContactType(formData.place_type); const chtApi = new ChtApi(req.chtSession); + const tmplData: any = { + view: 'manage-hierarchy', + op: formData.op, + logo: Config.getLogoBase64(), + contactType, + data: formData, + session: req.chtSession, + ...hierarchyViewModel(formData.op, contactType), + }; + try { - const result = await ManageHierarchyLib.scheduleJob(formData, contactType, sessionCache, chtApi); + const isConfirmed = formData.confirmed === 'true'; + const job = await ManageHierarchyLib.getJobDetails(formData, contactType, sessionCache, chtApi); + if (isConfirmed) { + await ManageHierarchyLib.scheduleJob(job); + tmplData.success = true; + } else { + const warningInfo = await ManageHierarchyLib.getWarningInfo(job, chtApi); + tmplData.warningInfo = warningInfo; + } - const tmplData = { - view: 'manage-hierarchy', - op: formData.op, - logo: Config.getLogoBase64(), - contactType, - session: req.chtSession, - ...hierarchyViewModel(formData.op, contactType), - ...result - }; - return resp.view('src/liquid/place/manage_hierarchy_form.html', tmplData); + tmplData.confirm = !isConfirmed; + return resp.view('src/liquid/components/manage_hierarchy_form_content.html', tmplData); } catch (e: any) { - const tmplData = { - view: 'manage-hierarchy', - op: formData.op, - contactTypes: Config.contactTypes(), - session: req.chtSession, - data: formData, - contactType, - ...hierarchyViewModel(formData.op, contactType), - error: e.toString(), - }; - - return resp.view('src/liquid/place/manage_hierarchy_form.html', tmplData); + tmplData.error = e.toString(); + return resp.view('src/liquid/components/manage_hierarchy_form_content.html', tmplData); } }); } diff --git a/src/services/hierarchy-view-model.ts b/src/services/hierarchy-view-model.ts index 82c5bb5d..41b5864b 100644 --- a/src/services/hierarchy-view-model.ts +++ b/src/services/hierarchy-view-model.ts @@ -5,9 +5,10 @@ import ManageHierarchyLib from '../lib/manage-hierarchy'; export function hierarchyViewModel(action: string, contactType: ContactType) { const parentTypeName = contactType.hierarchy.find(h => h.level === 1)?.contact_type; if (!parentTypeName) { - throw Error('parent type name'); + throw Error('Parent type name not found in config'); } + const isPermanent = ['merge', 'delete'].includes(action); const sourceHierarchy = Config.getHierarchyWithReplacement(contactType, 'desc'); sourceHierarchy[sourceHierarchy.length - 1].friendly_name = contactType.friendly; const hierarchyAction = ManageHierarchyLib.parseHierarchyAction(action); @@ -16,6 +17,7 @@ export function hierarchyViewModel(action: string, contactType: ContactType) { const destinationDescription = hierarchyAction === 'move' ? 'To Have This Parent' : 'After Moving Data Into'; return { + isPermanent, sourceDescription, destinationDescription, diff --git a/test/lib/cht-session.spec.ts b/test/lib/cht-session.spec.ts index e51674f3..3e5f8b7a 100644 --- a/test/lib/cht-session.spec.ts +++ b/test/lib/cht-session.spec.ts @@ -59,6 +59,7 @@ describe('lib/cht-session.ts', () => { expect(mockAxios.post.args[0][0]).to.be.a('string'); expect(session.sessionToken).to.eq('AuthSession=123'); expect(session.username).to.eq('user'); + expect(session.isAdmin).to.be.false; }); it('throw cht yields no authtoken', async () => { @@ -87,6 +88,7 @@ describe('lib/cht-session.ts', () => { const data = JSON.stringify(session); const actual = ChtSession.default.createFromDataString(data); expect(actual).to.deep.eq(session); + expect(session.isAdmin).to.be.false; }); describe('isPlaceAuthorized', () => { diff --git a/test/lib/manage-hierarchy.spec.ts b/test/lib/manage-hierarchy.spec.ts index a5b76a9e..f0a5fd3b 100644 --- a/test/lib/manage-hierarchy.spec.ts +++ b/test/lib/manage-hierarchy.spec.ts @@ -1,22 +1,21 @@ import Chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { DateTime } from 'luxon'; import sinon from 'sinon'; -import ManageHierarchyLib from '../../src/lib/manage-hierarchy'; +import Auth from '../../src/lib/authentication'; import { Config } from '../../src/config'; +import { JobParams } from '../../src/lib/queues'; +import ManageHierarchyLib from '../../src/lib/manage-hierarchy'; +import { mockChtApi, mockChtSession } from '../mocks'; +import RemotePlaceCache from '../../src/lib/remote-place-cache'; import SessionCache from '../../src/services/session-cache'; -import { mockChtApi } from '../mocks'; -import chaiAsPromised from 'chai-as-promised'; -import Auth from '../../src/lib/authentication'; -import { BullQueue } from '../../src/lib/queues'; -import RemotePlaceCache from '../../src/lib/remote-place-cache'; Chai.use(chaiAsPromised); const { expect } = Chai; describe('lib/manage-hierarchy.ts', () => { - let chtConfQueue: any; - const subcountyDocs = [ { _id: 'from-sub', name: 'From Sub' }, { _id: 'to-sub', name: 'To Sub' } @@ -29,7 +28,6 @@ describe('lib/manage-hierarchy.ts', () => { const chtApiWithDocs = () => mockChtApi(subcountyDocs, chuDocs); beforeEach(() => { - chtConfQueue = sinon.createStubInstance(BullQueue); sinon.stub(Auth, 'encodeTokenForWorker').returns('encoded-token'); RemotePlaceCache.clear({}); }); @@ -49,14 +47,7 @@ describe('lib/manage-hierarchy.ts', () => { const contactType = Config.getContactType('c_community_health_unit'); const sessionCache = new SessionCache(); - const actual = await ManageHierarchyLib.scheduleJob(formData, contactType, sessionCache, chtApiWithDocs(), chtConfQueue); - expect(actual.sourceLineage.map((l:any) => l.id)).to.deep.eq(['from-chu-id', 'from-sub']); - expect(actual.destinationLineage.map((l:any) => l.id)).to.deep.eq([undefined, 'to-sub']); - - // Verify the data passed to mockmoveContactQueue - expect(chtConfQueue.add.calledOnce).to.be.true; - const jobParams = chtConfQueue.add.getCall(0).args[0]; - + const jobParams = await ManageHierarchyLib.getJobDetails(formData, contactType, sessionCache, chtApiWithDocs()); expect(jobParams).to.have.property('jobName').that.equals('move_[From Sub.C-h-u]_to_[To Sub]'); expect(jobParams).to.have.property('jobData').that.deep.include({ action: 'move', @@ -76,7 +67,7 @@ describe('lib/manage-hierarchy.ts', () => { const contactType = Config.getContactType('c_community_health_unit'); const sessionCache = new SessionCache(); - const actual = ManageHierarchyLib.scheduleJob(formData, contactType, sessionCache, mockChtApi(chuDocs), chtConfQueue); + const actual = ManageHierarchyLib.getJobDetails(formData, contactType, sessionCache, mockChtApi(chuDocs)); await expect(actual).to.eventually.be.rejectedWith('search string is empty'); }); @@ -90,7 +81,7 @@ describe('lib/manage-hierarchy.ts', () => { const contactType = Config.getContactType('c_community_health_unit'); const sessionCache = new SessionCache(); - const actual = ManageHierarchyLib.scheduleJob(formData, contactType, sessionCache, chtApiWithDocs(), chtConfQueue); + const actual = ManageHierarchyLib.getJobDetails(formData, contactType, sessionCache, chtApiWithDocs()); await expect(actual).to.eventually.be.rejectedWith('Place "c-h-u" already has "From Sub" as parent'); }); @@ -104,7 +95,7 @@ describe('lib/manage-hierarchy.ts', () => { const contactType = Config.getContactType('c_community_health_unit'); const sessionCache = new SessionCache(); - const actual = ManageHierarchyLib.scheduleJob(formData, contactType, sessionCache, chtApiWithDocs(), chtConfQueue); + const actual = ManageHierarchyLib.getJobDetails(formData, contactType, sessionCache, chtApiWithDocs()); await expect(actual).to.eventually.be.rejectedWith('Cannot find \'b_sub_county\' matching \'invalid sub\''); }); }); @@ -121,14 +112,7 @@ describe('lib/manage-hierarchy.ts', () => { const contactType = Config.getContactType('c_community_health_unit'); const sessionCache = new SessionCache(); - const actual = await ManageHierarchyLib.scheduleJob(formData, contactType, sessionCache, chtApiWithDocs(), chtConfQueue); - expect(actual.sourceLineage.map((l:any) => l.id)).to.deep.eq(['from-chu-id', 'from-sub']); - expect(actual.destinationLineage.map((l:any) => l.id)).to.deep.eq(['to-chu-id', 'to-sub']); - - // Verify the data passed to mockmoveContactQueue - expect(chtConfQueue.add.calledOnce).to.be.true; - const jobParams = chtConfQueue.add.getCall(0).args[0]; - + const jobParams = await ManageHierarchyLib.getJobDetails(formData, contactType, sessionCache, chtApiWithDocs()); expect(jobParams).to.have.property('jobName').that.equals('merge_[From Sub.C-h-u]_to_[To Sub.Destination]'); expect(jobParams).to.have.property('jobData').that.deep.include({ action: 'merge', @@ -150,14 +134,7 @@ describe('lib/manage-hierarchy.ts', () => { const contactType = Config.getContactType('c_community_health_unit'); const sessionCache = new SessionCache(); - const actual = await ManageHierarchyLib.scheduleJob(formData, contactType, sessionCache, chtApiWithDocs(), chtConfQueue); - expect(actual.sourceLineage.map((l:any) => l.id)).to.deep.eq(['from-chu-id', 'from-sub']); - expect(actual.destinationLineage.map((l:any) => l.id)).to.deep.eq([]); - - // Verify the data passed to mockmoveContactQueue - expect(chtConfQueue.add.calledOnce).to.be.true; - const jobParams = chtConfQueue.add.getCall(0).args[0]; - + const jobParams = await ManageHierarchyLib.getJobDetails(formData, contactType, sessionCache, chtApiWithDocs()); expect(jobParams).to.have.property('jobName').that.equals('delete_[From Sub.C-h-u]'); expect(jobParams).to.have.property('jobData').that.deep.include({ action: 'delete', @@ -167,5 +144,72 @@ describe('lib/manage-hierarchy.ts', () => { }); }); }); + + describe('getWarningInfo', () => { + const fakeJob: JobParams = { jobName: 'foo', jobData: { sourceId: 'abc' }}; + + it('below thrsholds', async () => { + const chtApi = mockChtApi(); + chtApi.countContactsUnderPlace = sinon.stub().resolves(5); + chtApi.lastSyncAtPlace = sinon.stub().resolves(DateTime.now().minus({ days: 100 })); + const actual = await ManageHierarchyLib.getWarningInfo(fakeJob, chtApi); + expect(actual).to.deep.eq({ + affectedPlaceCount: 5, + lastSyncDescription: '3 months ago', + userIsActive: false, + lotsOfPlaces: false, + }); + }); + + it('above thresholds', async () => { + const chtApi = mockChtApi(); + chtApi.countContactsUnderPlace = sinon.stub().resolves(1000); + chtApi.lastSyncAtPlace = sinon.stub().resolves(DateTime.now().minus({ days: 10 })); + const actual = await ManageHierarchyLib.getWarningInfo(fakeJob, chtApi); + expect(actual).to.deep.eq({ + affectedPlaceCount: 1000, + lastSyncDescription: '10 days ago', + userIsActive: true, + lotsOfPlaces: true, + }); + }); + + it('no sync details for non-admins', async () => { + const chtApi = mockChtApi(); + chtApi.chtSession = mockChtSession('abc'); + chtApi.countContactsUnderPlace = sinon.stub().resolves(2); + chtApi.lastSyncAtPlace = sinon.stub().throws('only for admins'); + const actual = await ManageHierarchyLib.getWarningInfo(fakeJob, chtApi); + expect(actual).to.deep.eq({ + affectedPlaceCount: 2, + lastSyncDescription: '-', + userIsActive: false, + lotsOfPlaces: false, + }); + expect(chtApi.lastSyncAtPlace.called).to.be.false; + }); + + it('very old sync dates show as -', async () => { + const chtApi = mockChtApi(); + chtApi.countContactsUnderPlace = sinon.stub().resolves(2); + chtApi.lastSyncAtPlace = sinon.stub().resolves(DateTime.now().minus({ days: 1000 })); + const actual = await ManageHierarchyLib.getWarningInfo(fakeJob, chtApi); + expect(actual).to.deep.include({ + lastSyncDescription: 'over a year', + userIsActive: false, + }); + }); + + it('sync dates in future show as -', async () => { + const chtApi = mockChtApi(); + chtApi.countContactsUnderPlace = sinon.stub().resolves(2); + chtApi.lastSyncAtPlace = sinon.stub().resolves(DateTime.now().plus({ days: 1 })); + const actual = await ManageHierarchyLib.getWarningInfo(fakeJob, chtApi); + expect(actual).to.deep.include({ + lastSyncDescription: '-', + userIsActive: false, + }); + }); + }); });