Skip to content

Commit

Permalink
Avoid breaking change with #652
Browse files Browse the repository at this point in the history
  • Loading branch information
kennsippell committed Dec 12, 2024
1 parent bdbead6 commit bd9bf60
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 27 deletions.
5 changes: 5 additions & 0 deletions src/fn/merge-contacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module.exports = {
const args = parseExtraArgs(environment.pathToProject, environment.extraArgs);
const db = pouch();
const options = {
disableUsers: args.disableUsers,
docDirectoryPath: args.docDirectoryPath,
force: args.force,
};
Expand Down Expand Up @@ -41,6 +42,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => {
return {
destinationId: args.destination,
sourceIds,
disableUsers: !!args['disable-users'],
docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'),
force: !!args.force,
};
Expand All @@ -63,6 +65,9 @@ ${bold('OPTIONS')}
--sources=<source_id1>,<source_id2>
A comma delimited list of IDs of contacts which will be deleted. The hierarchy of contacts and reports under it will be moved to be under the destination contact.
--disable-users
When flag is present, users at any deleted place will be updated and may be permanently disabled.
--docDirectoryPath=<path to stage docs>
Specifies the folder used to store the documents representing the changes in hierarchy.
`);
Expand Down
35 changes: 24 additions & 11 deletions src/fn/upload-docs.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
const path = require('path');
const minimist = require('minimist');
const semver = require('semver');

const api = require('../lib/api');
const environment = require('../lib/environment');
const fs = require('../lib/sync-fs');
const { getValidApiVersion } = require('../lib/get-api-version');
const log = require('../lib/log');
const pouch = require('../lib/db');
const progressBar = require('../lib/progress-bar');
Expand Down Expand Up @@ -37,10 +39,8 @@ async function execute() {

warnAndPrompt(`This operation will permanently write ${totalCount} docs. Are you sure you want to continue?`);

if (args['disable-users']) {
const deletedDocIds = analysis.map(result => result.delete).filter(Boolean);
await handleUsersAtDeletedFacilities(deletedDocIds);
}
const deletedDocIds = analysis.map(result => result.delete).filter(Boolean);
await handleUsersAtDeletedFacilities(deletedDocIds);

const results = { ok:[], failed:{} };
const progress = log.level > log.LEVEL_ERROR ? progressBar.init(totalCount, '{{n}}/{{N}} docs ', ' {{%}} {{m}}:{{s}}') : null;
Expand Down Expand Up @@ -117,32 +117,45 @@ function analyseFiles(filePaths) {
return { error: `File '${filePath}' sets _id:'${json._id}' but the file's expected _id is '${idFromFilename}'.` };
}

if (json._deleted) {
if (json._deleted && json.cht_disable_linked_users) {
return { delete: json._id };
}
})
.filter(Boolean);
}

async function handleUsersAtDeletedFacilities(deletedDocIds) {
await assertCoreVersion();

const affectedUsers = await getAffectedUsers(deletedDocIds);
const usernames = affectedUsers.map(userDoc => userDoc.username).join(', ');
if (affectedUsers.length === 0) {
trace('No users found needing an update.');
return;
}

warnAndPrompt(`This operation will update permissions for ${affectedUsers.length} user accounts: ${usernames}. Are you sure you want to continue?`);
await updateAffectedUsers(affectedUsers);
}

async function assertCoreVersion() {
const actualCoreVersion = await getValidApiVersion();
if (semver.lt(actualCoreVersion, '4.7.0-dev')) {
throw Error(`CHT Core Version 4.7.0 or newer is required to use --disable-users options. Version is ${actualCoreVersion}.`);
}

trace(`Core version is ${actualCoreVersion}. Proceeding to disable users.`);
}

async function getAffectedUsers(deletedDocIds) {
const toPostApiFormat = (apiResponse) => ({
_id: apiResponse.id,
_rev: apiResponse.rev,
username: apiResponse.username,
place: apiResponse.place?.filter(Boolean).map(place => place._id),
});
const toPostApiFormat = (apiResponse) => {
const places = Array.isArray(apiResponse.place) ? apiResponse.place.filter(Boolean) : [apiResponse.place];
const placeIds = places.map(place => place?._id);
return {
username: apiResponse.username,
place: placeIds,
};
};

const knownUserDocs = {};
for (const facilityId of deletedDocIds) {
Expand Down
1 change: 1 addition & 0 deletions src/lib/hierarchy-operations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async function moveHierarchy(db, options, sourceIds, destinationId) {
_id: sourceDoc._id,
_rev: sourceDoc._rev,
_deleted: true,
cht_disable_linked_users: !!options.disableUsers,
});
}

Expand Down
1 change: 1 addition & 0 deletions test/fn/merge-contacts.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('merge-contacts', () => {
expect(parseExtraArgs(__dirname, args)).to.deep.eq({
sourceIds: ['food', 'is', 'tasty'],
destinationId: 'bar',
disableUsers: false,
force: true,
docDirectoryPath: '/',
});
Expand Down
65 changes: 52 additions & 13 deletions test/fn/upload-docs.spec.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
const { expect, assert } = require('chai');
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');

const rewire = require('rewire');
const sinon = require('sinon');

const apiStub = require('../api-stub');
const environment = require('../../src/lib/environment');
let uploadDocs = rewire('../../src/fn/upload-docs');
const userPrompt = rewire('../../src/lib/user-prompt');

const { assert, expect } = chai;
chai.use(chaiAsPromised);
let readLine = { keyInYN: () => true };
userPrompt.__set__('readline', readLine);
uploadDocs.__set__('userPrompt', userPrompt);

let fs, expectedDocs;

const API_VERSION_RESPONSE = { status: 200, body: { version: '4.10.0' }};

describe('upload-docs', function() {
beforeEach(() => {
sinon.stub(environment, 'isArchiveMode').get(() => false);
Expand Down Expand Up @@ -41,6 +48,8 @@ describe('upload-docs', function() {
});

it('should upload docs to pouch', async () => {
apiStub.giveResponses(API_VERSION_RESPONSE);

await assertDbEmpty();
await uploadDocs.execute();
const res = await apiStub.db.allDocs();
Expand Down Expand Up @@ -82,6 +91,7 @@ describe('upload-docs', function() {
expectedDocs = new Array(10).fill('').map((x, i) => ({ _id: i.toString() }));
const clock = sinon.useFakeTimers(0);
const imported_date = new Date().toISOString();
apiStub.giveResponses(API_VERSION_RESPONSE);
return uploadDocs.__with__({
INITIAL_BATCH_SIZE: 4,
Date,
Expand Down Expand Up @@ -117,17 +127,12 @@ describe('upload-docs', function() {

it('should throw if user denies the warning', async () => {
userPrompt.__set__('readline', { keyInYN: () => false });
await assertDbEmpty();
await uploadDocs.execute()
.then(() => {
assert.fail('Expected error to be thrown');
})
.catch(err => {
expect(err.message).to.equal('User aborted execution.');
});
const actual = uploadDocs.execute();
await expect(actual).to.eventually.be.rejectedWith('User aborted execution.');
});

it('should not throw if force is set', async () => {
apiStub.giveResponses(API_VERSION_RESPONSE);
userPrompt.__set__('environment', { force: () => true });
await assertDbEmpty();
sinon.stub(process, 'exit');
Expand Down Expand Up @@ -156,11 +161,43 @@ describe('upload-docs', function() {
expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']);

assert.deepEqual(apiStub.requestLog(), [
{ method: 'GET', url: '/api/deploy-info', body: {} },
{ method: 'GET', url: '/api/v2/users?facility_id=one', body: {} },
{ method: 'DELETE', url: '/api/v1/users/user1', body: {} },
]);
});

it('user with single place gets deleted (old core api format)', async () => {
await setupDeletedFacilities('one');
setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: { _id: 'one' } }]);

await uploadDocs.execute();
const res = await apiStub.db.allDocs();
expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']);

assert.deepEqual(apiStub.requestLog(), [
{ method: 'GET', url: '/api/deploy-info', body: {} },
{ method: 'GET', url: '/api/v2/users?facility_id=one', body: {} },
{ method: 'DELETE', url: '/api/v1/users/user1', body: {} },
]);
});

it('users associated with docs without truthy deleteUser attribute are not deleted', async () => {
const writtenDoc = await apiStub.db.put({ _id: 'one' });
apiStub.giveResponses(API_VERSION_RESPONSE);

const oneDoc = expectedDocs[0];
oneDoc._rev = writtenDoc.rev;
oneDoc._deleted = true;

await uploadDocs.execute();
const res = await apiStub.db.allDocs();
expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']);
assert.deepEqual(apiStub.requestLog(), [
{ method: 'GET', url: '/api/deploy-info', body: {} }
]);
});

it('user with multiple places gets updated', async () => {
await setupDeletedFacilities('one');
setupApiResponses(1, [{ id: 'org.couchdb.user:user1', username: 'user1', place: twoPlaces }]);
Expand All @@ -170,11 +207,11 @@ describe('upload-docs', function() {
expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']);

const expectedBody = {
_id: 'org.couchdb.user:user1',
username: 'user1',
place: [ 'two' ],
};
assert.deepEqual(apiStub.requestLog(), [
{ method: 'GET', url: '/api/deploy-info', body: {} },
{ method: 'GET', url: '/api/v2/users?facility_id=one', body: {} },
{ method: 'POST', url: '/api/v1/users/user1', body: expectedBody },
]);
Expand All @@ -190,13 +227,14 @@ describe('upload-docs', function() {
expect(res.rows.map(doc => doc.id)).to.deep.eq(['three']);

assert.deepEqual(apiStub.requestLog(), [
{ method: 'GET', url: '/api/deploy-info', body: {} },
{ method: 'GET', url: '/api/v2/users?facility_id=one', body: {} },
{ method: 'GET', url: '/api/v2/users?facility_id=two', body: {} },
{ method: 'DELETE', url: '/api/v1/users/user1', body: {} },
]);
});

it('two users disabled when single place has multiple users', async () => {
it('one user disabled and one updated when single place has multiple users', async () => {
await setupDeletedFacilities('one');
setupApiResponses(2, [
{ id: 'org.couchdb.user:user1', username: 'user1', place: [{ _id: 'one' }] },
Expand All @@ -208,11 +246,11 @@ describe('upload-docs', function() {
expect(res.rows.map(doc => doc.id)).to.deep.eq(['three', 'two']);

const expectedUser2 = {
_id: 'org.couchdb.user:user2',
username: 'user2',
place: ['two'],
};
assert.deepEqual(apiStub.requestLog(), [
{ method: 'GET', url: '/api/deploy-info', body: {} },
{ method: 'GET', url: '/api/v2/users?facility_id=one', body: {} },
{ method: 'DELETE', url: '/api/v1/users/user1', body: {} },
{ method: 'POST', url: '/api/v1/users/user2', body: expectedUser2 },
Expand All @@ -225,6 +263,7 @@ function setupApiResponses(writeCount, ...userDocResponseRows) {
const responseBodies = userDocResponseRows.map(body => ({ body }));
const writeResponses = new Array(writeCount).fill({ status: 200 });
apiStub.giveResponses(
API_VERSION_RESPONSE,
...responseBodies,
...writeResponses,
);
Expand All @@ -236,7 +275,7 @@ async function setupDeletedFacilities(...docIds) {
const expected = expectedDocs.find(doc => doc._id === id);
expected._rev = writtenDoc.rev;
expected._deleted = true;
expected.disableUsers = true;
expected.cht_disable_linked_users = true;
}
}

Expand Down
8 changes: 5 additions & 3 deletions test/lib/hierarchy-operations/hierarchy-operations.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const reports_by_freetext = {
map: "function(doc) {\n var skip = [ '_id', '_rev', 'type', 'refid', 'content' ];\n\n var usedKeys = [];\n var emitMaybe = function(key, value) {\n if (usedKeys.indexOf(key) === -1 && // Not already used\n key.length > 2 // Not too short\n ) {\n usedKeys.push(key);\n emit([key], value);\n }\n };\n\n var emitField = function(key, value, reportedDate) {\n if (!key || !value) {\n return;\n }\n key = key.toLowerCase();\n if (skip.indexOf(key) !== -1 || /_date$/.test(key)) {\n return;\n }\n if (typeof value === 'string') {\n value = value.toLowerCase();\n value.split(/\\s+/).forEach(function(word) {\n emitMaybe(word, reportedDate);\n });\n }\n if (typeof value === 'number' || typeof value === 'string') {\n emitMaybe(key + ':' + value, reportedDate);\n }\n };\n\n if (doc.type === 'data_record' && doc.form) {\n Object.keys(doc).forEach(function(key) {\n emitField(key, doc[key], doc.reported_date);\n });\n if (doc.fields) {\n Object.keys(doc.fields).forEach(function(key) {\n emitField(key, doc.fields[key], doc.reported_date);\n });\n }\n if (doc.contact && doc.contact._id) {\n emitMaybe('contact:' + doc.contact._id.toLowerCase(), doc.reported_date);\n }\n }\n}"
};

describe('move-contacts', () => {
describe('hierarchy-operations', () => {
let pouchDb, scenarioCount = 0;
const writtenDocs = [];
const getWrittenDoc = docId => {
Expand All @@ -55,7 +55,7 @@ describe('move-contacts', () => {
const updateHierarchyRules = contact_types => upsert('settings', { settings: { contact_types } });

beforeEach(async () => {
pouchDb = new PouchDB(`move-contacts-${scenarioCount++}`);
pouchDb = new PouchDB(`hierarchy-operations-${scenarioCount++}`);

await mockHierarchy(pouchDb, {
district_1: {
Expand Down Expand Up @@ -407,7 +407,7 @@ describe('move-contacts', () => {
});

// action
await HierarchyOperations(pouchDb).merge(['district_2'], 'district_1');
await HierarchyOperations(pouchDb, { disableUsers: true }).merge(['district_2'], 'district_1');

// assert
expectWrittenDocs([
Expand All @@ -421,6 +421,7 @@ describe('move-contacts', () => {
expect(getWrittenDoc('district_2')).to.deep.eq({
_id: 'district_2',
_deleted: true,
cht_disable_linked_users: true,
});

expect(getWrittenDoc('health_center_2')).to.deep.eq({
Expand Down Expand Up @@ -498,6 +499,7 @@ describe('move-contacts', () => {
expect(getWrittenDoc('patient_2')).to.deep.eq({
_id: 'patient_2',
_deleted: true,
cht_disable_linked_users: false,
});

expect(getWrittenDoc('pat2')).to.deep.eq({
Expand Down

0 comments on commit bd9bf60

Please sign in to comment.