diff --git a/lib/data/entity.js b/lib/data/entity.js index 3be7d9932..13c0d3f3f 100644 --- a/lib/data/entity.js +++ b/lib/data/entity.js @@ -395,6 +395,64 @@ const diffEntityData = (defData) => { return diffs; }; +// Copied from frontend though it may not all be necessary +// Offline branch +class Branch { + // firstUpdate is the first offline update (not create) to be processed from + // the branch. entityRoot is the first version of the entity. + constructor(firstUpdate, entityRoot) { + if (firstUpdate.trunkVersion != null) { + // The first version from the branch to be processed (not necessarily the + // first in the original branch order) + this.first = firstUpdate; + + // How many versions that have been processed are from the branch? + this.length = 1; + + // Was this.first processed in branch order, or was it processed before an + // earlier change in the branch? + const { trunkVersion } = firstUpdate; + this.firstInOrder = firstUpdate.branchBaseVersion === trunkVersion; + + /* this.lastContiguousWithTrunk is the version number of the last version + from the branch that is contiguous with the trunk version. In other words, + it is the version number of the last version where there has been no + update from outside the branch between the version and the trunk version. + this.lastContiguousWithTrunk is not related to branch order: as long as + there hasn't been an update from outside the branch, the branch is + contiguous, regardless of the order of the updates within it. */ + this.lastContiguousWithTrunk = firstUpdate.version === trunkVersion + 1 + ? firstUpdate.version + : 0; + } else { + // If the entity was both created and updated offline before being sent to + // the server, then we treat the creation as part of the same branch as + // the update(s). The creation doesn't have a branch ID, but we treat it + // as part of the branch anyway. + this.first = entityRoot; + // If the submission for the entity creation was received late and + // processed out of order, then firstUpdate.version === 1. In that case, + // we can't reliably determine which entity version corresponds to the + // entity creation, so we don't treat the creation as part of the branch. + this.length = firstUpdate.version === 1 ? 1 : 2; + this.firstInOrder = this.length === 2; + this.lastContiguousWithTrunk = firstUpdate.version === 2 ? 2 : 1; + } + + this.id = firstUpdate.branchId; + // The last version from the branch to be processed + this.last = firstUpdate; + } + + add(version) { + this.length += 1; + this.last = version; + if (version.baseVersion === this.lastContiguousWithTrunk && + version.version === version.baseVersion + 1) + this.lastContiguousWithTrunk = version.version; + } +} + // Returns an array of properties which are different between // `dataReceived` and `otherVersionData` const getDiffProp = (dataReceived, otherVersionData) => @@ -417,6 +475,26 @@ const getWithConflictDetails = (defs, audits, relevantToConflict) => { const relevantBaseVersions = new Set(); + // build up branches + const branches = new Map(); + for (const version of defs) { + const { branchId } = version; + if (branchId != null && version.branchBaseVersion != null) { + const existingBranch = branches.get(branchId); + if (existingBranch == null) { + const newBranch = new Branch(version, defs[0]); + branches.set(branchId, newBranch); + version.branch = newBranch; + // If the entity was created offline, then add the branch to the + // entity creation. + newBranch.first.branch = newBranch; + } else { + existingBranch.add(version); + version.branch = existingBranch; + } + } + } + for (const def of defs) { const v = mergeLeft(def.forApi(), @@ -436,7 +514,12 @@ const getWithConflictDetails = (defs, audits, relevantToConflict) => { v.source = event.source; if (v.version > 1) { // v.root is false here - can use either - const conflict = v.version !== (v.baseVersion + 1); + let notContiguousWithTrunk = false; + if (v.branchId != null) { + notContiguousWithTrunk = branches.get(v.branchId).lastContiguousWithTrunk < v.baseVersion; + } + + const conflict = v.version !== (v.baseVersion + 1) || notContiguousWithTrunk; v.baseDiff = getDiffProp(v.dataReceived, { ...defs[v.baseVersion - 1].data, label: defs[v.baseVersion - 1].label }); diff --git a/test/integration/api/offline-entities.js b/test/integration/api/offline-entities.js index a10a6301d..09d82a604 100644 --- a/test/integration/api/offline-entities.js +++ b/test/integration/api/offline-entities.js @@ -1270,4 +1270,90 @@ describe('Offline Entities', () => { })); }); }); + + describe('conflict cases', () => { + it('should mark an update that is not contiguous with its trunk version as a soft conflict', testOfflineEntities(async (service, container) => { + const asAlice = await service.login('alice'); + const branchId = uuid(); + + // Update existing entity on server (change age from 22 to 24) + await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc?baseVersion=1') + .send({ data: { age: '24' } }) + .expect(200); + + // Send update (change status from null to arrived) + await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions') + .send(testData.instances.offlineEntity.one + .replace('branchId=""', `branchId="${branchId}"`) + ) + .set('Content-Type', 'application/xml') + .expect(200); + + // Send second update (change age from 22 to 26) + await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions') + .send(testData.instances.offlineEntity.one + .replace('branchId=""', `branchId="${branchId}"`) + .replace('one', 'one-update2') + .replace('baseVersion="1"', 'baseVersion="2"') + .replace('arrived', '26') + ) + .set('Content-Type', 'application/xml') + .expect(200); + + await exhaust(container); + + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/versions') + .then(({ body: versions }) => { + versions.map(v => v.conflict).should.eql([null, null, 'soft', 'soft']); + }); + })); + + it('should mark an update that is not contiguous with its trunk version as a soft conflict', testOfflineEntities(async (service, container) => { + const asAlice = await service.login('alice'); + const branchId = uuid(); + + // Send second update first + await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions') + .send(testData.instances.offlineEntity.one + .replace('branchId=""', `branchId="${branchId}"`) + .replace('one', 'one-update2') + .replace('baseVersion="1"', 'baseVersion="2"') + .replace('arrived', 'checked in') + ) + .set('Content-Type', 'application/xml') + .expect(200); + + await exhaust(container); + await container.Entities.processBacklog(true); + + // Send first update now (it will be applied right away) + await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions') + .send(testData.instances.offlineEntity.one + .replace('branchId=""', `branchId="${branchId}"`) + ) + .set('Content-Type', 'application/xml') + .expect(200); + + await exhaust(container); + + // Send fourth update + await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions') + .send(testData.instances.offlineEntity.one + .replace('branchId=""', `branchId="${branchId}"`) + .replace('one', 'one-update4') + .replace('baseVersion="1"', 'baseVersion="4"') + .replace('arrived', 'departed') + ) + .set('Content-Type', 'application/xml') + .expect(200); + + await exhaust(container); + await container.Entities.processBacklog(true); + + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/versions') + .then(({ body: versions }) => { + versions.map(v => v.conflict).should.eql([null, null, 'hard', 'soft']); + }); + })); + }); });