mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
* [web] add preview payload to track-change notification types * [doc-updater] pass accepted/rejected change data through to web for previews * [web] snapshot change preview onto accepted/rejected notifications * [web] render resolved change previews instead of accepted/rejected count lines * [web] add unit coverage for accepted/rejected change preview flow * fix: notification carries multiple previews per accept/reject batch; extract shared group helper * fix: one preview per accept/reject action, drop adjacency grouping * fix: move buildResolvedChangePreviews out of the email builder class * fix: type error, make tests more explicit * fix: render resolver below preview * fix: overflow text, alignment * fix: test error * fix: resolved count * [document-updater/web] send sparse change preview instead of full doc lines * fix: store sparse preview, hydrate at render, drop CE gate * fix: advance slice cursor past tracked-delete text in sparse preview * fix: inline buildChangeGroupPreview * fix: changePreviewBuilder -> sparseChangePreview * fix: rm change_contributors * fix: copilot flagged testing issues * fix: hydrate and truncate change preview at write time * fix: avoid materializing full doc text when slicing preview window * fix: update reject preview test to hydrated shape * fix: accepted delete removing context * [document-updater/web] split resolved change previews into one per change cluster * fix: narrow cluster userIds to string[] for typecheck * feat: updating to use zod schema for previews --------- Co-authored-by: Jimmy Domagala-Tang <jimmy.domagala-tang@overleaf.com> GitOrigin-RevId: 0740713c715a61a220180f353e497b27da6a5b0f
991 lines
30 KiB
JavaScript
991 lines
30 KiB
JavaScript
const { createHash } = require('node:crypto')
|
|
const sinon = require('sinon')
|
|
const { expect } = require('chai')
|
|
const SandboxedModule = require('sandboxed-module')
|
|
|
|
const MODULE_PATH = '../../../../app/js/UpdateManager.js'
|
|
|
|
describe('UpdateManager', function () {
|
|
beforeEach(function () {
|
|
this.project_id = 'project-id-123'
|
|
this.projectHistoryId = 'history-id-123'
|
|
this.doc_id = 'document-id-123'
|
|
this.projectLockValue = 'mock-project-lock-value'
|
|
this.pathname = '/a/b/c.tex'
|
|
|
|
this.Metrics = {
|
|
inc: sinon.stub(),
|
|
Timer: class Timer {},
|
|
}
|
|
this.Metrics.Timer.prototype.done = sinon.stub()
|
|
|
|
this.Profiler = class Profiler {}
|
|
this.Profiler.prototype.log = sinon.stub().returns({ end: sinon.stub() })
|
|
this.Profiler.prototype.end = sinon.stub()
|
|
|
|
this.ProjectLockManager = {
|
|
promises: {
|
|
getLock: sinon.stub().resolves(this.projectLockValue),
|
|
extendLock: sinon.stub().resolves(),
|
|
releaseLock: sinon.stub().resolves(),
|
|
},
|
|
}
|
|
|
|
this.RedisManager = {
|
|
promises: {
|
|
setDocument: sinon.stub().resolves(),
|
|
updateDocument: sinon.stub(),
|
|
recordProjectNotificationTimestamp: sinon.stub().resolves(),
|
|
},
|
|
}
|
|
|
|
this.RealTimeRedisManager = {
|
|
sendData: sinon.stub(),
|
|
promises: {
|
|
getProjectUpdatesLength: sinon.stub().resolves(0),
|
|
getPendingProjectUpdates: sinon.stub().resolves([]),
|
|
},
|
|
}
|
|
|
|
this.HistoryOTUpdateManager = {
|
|
isHistoryOTEditOperationUpdate: sinon.stub().returns(false),
|
|
applyUpdate: sinon.stub().resolves(),
|
|
}
|
|
|
|
this.ShareJsUpdateManager = {
|
|
promises: {
|
|
applyUpdate: sinon.stub(),
|
|
},
|
|
}
|
|
|
|
this.HistoryManager = {
|
|
recordAndFlushHistoryOps: sinon.stub(),
|
|
}
|
|
|
|
this.DocumentManager = {
|
|
promises: {
|
|
getDoc: sinon.stub(),
|
|
},
|
|
}
|
|
|
|
this.RangesManager = {
|
|
applyUpdate: sinon.stub(),
|
|
}
|
|
|
|
this.SnapshotManager = {
|
|
promises: {
|
|
recordSnapshot: sinon.stub().resolves(),
|
|
},
|
|
}
|
|
|
|
this.WebApiManager = {
|
|
promises: {
|
|
notifyTrackChangesRejected: sinon.stub().resolves(),
|
|
},
|
|
}
|
|
|
|
this.ProjectHistoryRedisManager = {
|
|
promises: {
|
|
queueOps: sinon
|
|
.stub()
|
|
.callsFake(async (projectId, ...ops) => ops.length),
|
|
},
|
|
}
|
|
|
|
this.buildSparseChangePreviews = sinon.stub().returns([])
|
|
this.UpdateManager = SandboxedModule.require(MODULE_PATH, {
|
|
requires: {
|
|
'./ProjectLockManager': this.ProjectLockManager,
|
|
'./RedisManager': this.RedisManager,
|
|
'./RealTimeRedisManager': this.RealTimeRedisManager,
|
|
'./ShareJsUpdateManager': this.ShareJsUpdateManager,
|
|
'./HistoryManager': this.HistoryManager,
|
|
'./HistoryOTUpdateManager': this.HistoryOTUpdateManager,
|
|
'./Metrics': this.Metrics,
|
|
'./DocumentManager': this.DocumentManager,
|
|
'./RangesManager': this.RangesManager,
|
|
'./SnapshotManager': this.SnapshotManager,
|
|
'./WebApiManager': this.WebApiManager,
|
|
'./Profiler': this.Profiler,
|
|
'./ProjectHistoryRedisManager': this.ProjectHistoryRedisManager,
|
|
'./TrackedChangePreview': {
|
|
buildSparseChangePreviews: this.buildSparseChangePreviews,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
describe('processOutstandingUpdatesWithLock', function () {
|
|
describe('when the project lock is free', function () {
|
|
beforeEach(function () {
|
|
this.UpdateManager.promises.continueProcessingUpdatesWithLock = sinon
|
|
.stub()
|
|
.resolves()
|
|
this.UpdateManager.promises.fetchAndApplyProjectUpdates = sinon
|
|
.stub()
|
|
.resolves()
|
|
})
|
|
|
|
describe('successfully', function () {
|
|
beforeEach(async function () {
|
|
await this.UpdateManager.promises.processOutstandingUpdatesWithLock(
|
|
this.project_id
|
|
)
|
|
})
|
|
|
|
it('should acquire the project lock', function () {
|
|
this.ProjectLockManager.promises.getLock
|
|
.calledWith(this.project_id)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should free the project lock', function () {
|
|
this.ProjectLockManager.promises.releaseLock
|
|
.calledWith(this.project_id, this.projectLockValue)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should drain the per-project queue', function () {
|
|
this.UpdateManager.promises.fetchAndApplyProjectUpdates
|
|
.calledWith(this.project_id)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should do everything with the lock acquired', function () {
|
|
this.UpdateManager.promises.fetchAndApplyProjectUpdates
|
|
.calledAfter(this.ProjectLockManager.promises.getLock)
|
|
.should.equal(true)
|
|
this.UpdateManager.promises.fetchAndApplyProjectUpdates
|
|
.calledBefore(this.ProjectLockManager.promises.releaseLock)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should continue processing new updates that may have come in', function () {
|
|
this.UpdateManager.promises.continueProcessingUpdatesWithLock
|
|
.calledWith(this.project_id)
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('when fetchAndApplyProjectUpdates returns an error', function () {
|
|
beforeEach(async function () {
|
|
this.error = new Error('Something went wrong')
|
|
this.UpdateManager.promises.fetchAndApplyProjectUpdates = sinon
|
|
.stub()
|
|
.rejects(this.error)
|
|
await expect(
|
|
this.UpdateManager.promises.processOutstandingUpdatesWithLock(
|
|
this.project_id
|
|
)
|
|
).to.be.rejectedWith(this.error)
|
|
})
|
|
|
|
it('should free the project lock', function () {
|
|
this.ProjectLockManager.promises.releaseLock
|
|
.calledWith(this.project_id, this.projectLockValue)
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('fetchAndApplyProjectUpdates', function () {
|
|
beforeEach(function () {
|
|
this.UpdateManager.promises.applyUpdate = sinon.stub().resolves()
|
|
})
|
|
|
|
describe('with updates carrying their doc id', function () {
|
|
beforeEach(async function () {
|
|
this.updates = [
|
|
{ doc: 'doc-1', op: 'a' },
|
|
{ doc: 'doc-2', op: 'b' },
|
|
]
|
|
this.RealTimeRedisManager.promises.getPendingProjectUpdates.resolves(
|
|
this.updates
|
|
)
|
|
await this.UpdateManager.promises.fetchAndApplyProjectUpdates(
|
|
this.project_id,
|
|
new this.Profiler()
|
|
)
|
|
})
|
|
|
|
it('should read the per-project queue', function () {
|
|
this.RealTimeRedisManager.promises.getPendingProjectUpdates
|
|
.calledWith(this.project_id)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should apply each update to the doc it carries', function () {
|
|
this.UpdateManager.promises.applyUpdate.should.have.been.calledWith(
|
|
this.project_id,
|
|
'doc-1',
|
|
this.updates[0]
|
|
)
|
|
this.UpdateManager.promises.applyUpdate.should.have.been.calledWith(
|
|
this.project_id,
|
|
'doc-2',
|
|
this.updates[1]
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('with a history-OT update', function () {
|
|
beforeEach(async function () {
|
|
this.update = { doc: 'doc-1', op: 'a' }
|
|
this.RealTimeRedisManager.promises.getPendingProjectUpdates.resolves([
|
|
this.update,
|
|
])
|
|
this.HistoryOTUpdateManager.isHistoryOTEditOperationUpdate.returns(true)
|
|
await this.UpdateManager.promises.fetchAndApplyProjectUpdates(
|
|
this.project_id,
|
|
new this.Profiler()
|
|
)
|
|
})
|
|
|
|
it('should route it to the history-OT update manager', function () {
|
|
this.HistoryOTUpdateManager.applyUpdate.should.have.been.calledWith(
|
|
this.project_id,
|
|
'doc-1',
|
|
this.update
|
|
)
|
|
this.UpdateManager.promises.applyUpdate.called.should.equal(false)
|
|
})
|
|
})
|
|
|
|
describe('with no updates', function () {
|
|
beforeEach(async function () {
|
|
this.RealTimeRedisManager.promises.getPendingProjectUpdates.resolves([])
|
|
await this.UpdateManager.promises.fetchAndApplyProjectUpdates(
|
|
this.project_id,
|
|
new this.Profiler()
|
|
)
|
|
})
|
|
|
|
it('should not apply any update', function () {
|
|
this.UpdateManager.promises.applyUpdate.called.should.equal(false)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('continueProcessingUpdatesWithLock', function () {
|
|
describe('when there are outstanding updates', function () {
|
|
beforeEach(async function () {
|
|
this.RealTimeRedisManager.promises.getProjectUpdatesLength.resolves(3)
|
|
this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
|
|
.stub()
|
|
.resolves()
|
|
await this.UpdateManager.promises.continueProcessingUpdatesWithLock(
|
|
this.project_id
|
|
)
|
|
})
|
|
|
|
it('should process the outstanding updates', function () {
|
|
this.UpdateManager.promises.processOutstandingUpdatesWithLock
|
|
.calledWith(this.project_id)
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('when there are no outstanding updates', function () {
|
|
beforeEach(async function () {
|
|
this.RealTimeRedisManager.promises.getProjectUpdatesLength.resolves(0)
|
|
this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
|
|
.stub()
|
|
.resolves()
|
|
await this.UpdateManager.promises.continueProcessingUpdatesWithLock(
|
|
this.project_id
|
|
)
|
|
})
|
|
|
|
it('should not try to process the outstanding updates', function () {
|
|
this.UpdateManager.promises.processOutstandingUpdatesWithLock.called.should.equal(
|
|
false
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('applyUpdate', function () {
|
|
beforeEach(function () {
|
|
this.updateMeta = { user_id: 'last-author-fake-id' }
|
|
this.update = { op: [{ p: 42, i: 'foo' }], meta: this.updateMeta }
|
|
this.updatedDocLines = ['updated', 'lines']
|
|
this.version = 34
|
|
this.lines = ['original', 'lines']
|
|
this.ranges = { entries: 'mock', comments: 'mock' }
|
|
this.updated_ranges = { entries: 'updated', comments: 'updated' }
|
|
this.appliedOps = [
|
|
{ v: 42, op: 'mock-op-42' },
|
|
{ v: 45, op: 'mock-op-45' },
|
|
]
|
|
this.historyUpdates = [
|
|
'history-update-1',
|
|
'history-update-2',
|
|
'history-update-3',
|
|
]
|
|
this.project_ops_length = 123
|
|
this.DocumentManager.promises.getDoc.resolves({
|
|
lines: this.lines,
|
|
version: this.version,
|
|
ranges: this.ranges,
|
|
pathname: this.pathname,
|
|
projectHistoryId: this.projectHistoryId,
|
|
historyRangesSupport: false,
|
|
type: 'sharejs-text-ot',
|
|
})
|
|
this.RangesManager.applyUpdate.returns({
|
|
newRanges: this.updated_ranges,
|
|
rangesWereCollapsed: false,
|
|
historyUpdates: this.historyUpdates,
|
|
removedChangeIds: [],
|
|
})
|
|
this.ShareJsUpdateManager.promises.applyUpdate = sinon.stub().resolves({
|
|
updatedDocLines: this.updatedDocLines,
|
|
version: this.version,
|
|
appliedOps: this.appliedOps,
|
|
})
|
|
this.RedisManager.promises.updateDocument.resolves()
|
|
this.UpdateManager.promises._adjustHistoryUpdatesMetadata = sinon.stub()
|
|
})
|
|
|
|
describe('normally', function () {
|
|
beforeEach(async function () {
|
|
await this.UpdateManager.promises.applyUpdate(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update
|
|
)
|
|
})
|
|
|
|
it('should apply the updates via ShareJS', function () {
|
|
this.ShareJsUpdateManager.promises.applyUpdate
|
|
.calledWith(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update,
|
|
this.lines,
|
|
this.version
|
|
)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should update the ranges', function () {
|
|
this.RangesManager.applyUpdate
|
|
.calledWith(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.ranges,
|
|
this.appliedOps,
|
|
this.updatedDocLines
|
|
)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should save the document', function () {
|
|
this.RedisManager.promises.updateDocument
|
|
.calledWith(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.updatedDocLines,
|
|
this.version,
|
|
this.appliedOps,
|
|
this.updated_ranges,
|
|
this.updateMeta
|
|
)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should add metadata to the ops', function () {
|
|
this.UpdateManager.promises._adjustHistoryUpdatesMetadata.should.have.been.calledWith(
|
|
this.historyUpdates,
|
|
this.pathname,
|
|
this.projectHistoryId,
|
|
this.lines,
|
|
this.ranges,
|
|
this.updatedDocLines
|
|
)
|
|
})
|
|
|
|
it('should push the applied ops into the history queue', function () {
|
|
this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWith(
|
|
this.project_id,
|
|
...this.historyUpdates.map(op => JSON.stringify(op))
|
|
)
|
|
this.HistoryManager.recordAndFlushHistoryOps.should.have.been.calledWith(
|
|
this.project_id,
|
|
this.historyUpdates,
|
|
this.historyUpdates.length
|
|
)
|
|
})
|
|
|
|
it('should record the project notification timestamp', function () {
|
|
this.RedisManager.promises.recordProjectNotificationTimestamp.should.have.been.calledWith(
|
|
this.project_id,
|
|
sinon.match.number
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('when update has a timestamp in meta', function () {
|
|
beforeEach(async function () {
|
|
this.timestamp = 1234567890
|
|
this.update = {
|
|
op: [{ p: 42, i: 'foo' }],
|
|
meta: { ...this.updateMeta, ts: this.timestamp },
|
|
}
|
|
await this.UpdateManager.promises.applyUpdate(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update
|
|
)
|
|
})
|
|
|
|
it('should record the project notification timestamp with the provided timestamp', function () {
|
|
this.RedisManager.promises.recordProjectNotificationTimestamp.should.have.been.calledWith(
|
|
this.project_id,
|
|
this.timestamp
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('when there are no history updates', function () {
|
|
beforeEach(async function () {
|
|
this.RangesManager.applyUpdate.returns({
|
|
newRanges: this.updated_ranges,
|
|
rangesWereCollapsed: false,
|
|
historyUpdates: [],
|
|
removedChangeIds: [],
|
|
})
|
|
await this.UpdateManager.promises.applyUpdate(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update
|
|
)
|
|
})
|
|
|
|
it('should not record the project notification timestamp', function () {
|
|
this.RedisManager.promises.recordProjectNotificationTimestamp.should.not
|
|
.have.been.called
|
|
})
|
|
})
|
|
|
|
describe('with UTF-16 surrogate pairs in the update', function () {
|
|
beforeEach(async function () {
|
|
this.update = { op: [{ p: 42, i: '\uD835\uDC00' }] }
|
|
await this.UpdateManager.promises.applyUpdate(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update
|
|
)
|
|
})
|
|
|
|
it('should apply the update but with surrogate pairs removed', function () {
|
|
this.ShareJsUpdateManager.promises.applyUpdate
|
|
.calledWith(this.project_id, this.doc_id, this.update)
|
|
.should.equal(true)
|
|
|
|
// \uFFFD is 'replacement character'
|
|
this.update.op[0].i.should.equal('\uFFFD\uFFFD')
|
|
})
|
|
})
|
|
|
|
describe('with an error', function () {
|
|
beforeEach(async function () {
|
|
this.error = new Error('something went wrong')
|
|
this.ShareJsUpdateManager.promises.applyUpdate.rejects(this.error)
|
|
await expect(
|
|
this.UpdateManager.promises.applyUpdate(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update
|
|
)
|
|
).to.be.rejectedWith(this.error)
|
|
})
|
|
|
|
it('should call RealTimeRedisManager.sendData with the error', function () {
|
|
this.RealTimeRedisManager.sendData
|
|
.calledWith({
|
|
project_id: this.project_id,
|
|
doc_id: this.doc_id,
|
|
error: this.error.message,
|
|
})
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('when ranges get collapsed', function () {
|
|
beforeEach(async function () {
|
|
this.RangesManager.applyUpdate.returns({
|
|
newRanges: this.updated_ranges,
|
|
rangesWereCollapsed: true,
|
|
historyUpdates: this.historyUpdates,
|
|
removedChangeIds: [],
|
|
})
|
|
await this.UpdateManager.promises.applyUpdate(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update
|
|
)
|
|
})
|
|
|
|
it('should increment the doc-snapshot metric', function () {
|
|
this.Metrics.inc.calledWith('doc-snapshot').should.equal(true)
|
|
})
|
|
|
|
it('should call SnapshotManager.recordSnapshot', function () {
|
|
this.SnapshotManager.promises.recordSnapshot
|
|
.calledWith(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.version,
|
|
this.pathname,
|
|
this.lines,
|
|
this.ranges
|
|
)
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('when history ranges are supported', function () {
|
|
beforeEach(async function () {
|
|
this.DocumentManager.promises.getDoc.resolves({
|
|
lines: this.lines,
|
|
version: this.version,
|
|
ranges: this.ranges,
|
|
pathname: this.pathname,
|
|
projectHistoryId: this.projectHistoryId,
|
|
historyRangesSupport: true,
|
|
type: 'sharejs-text-ot',
|
|
})
|
|
await this.UpdateManager.promises.applyUpdate(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update
|
|
)
|
|
})
|
|
|
|
it('should push the history updates into the history queue', function () {
|
|
this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWith(
|
|
this.project_id,
|
|
...this.historyUpdates.map(op => JSON.stringify(op))
|
|
)
|
|
this.HistoryManager.recordAndFlushHistoryOps.should.have.been.calledWith(
|
|
this.project_id,
|
|
this.historyUpdates,
|
|
this.historyUpdates.length
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('when tracked changes are rejected', function () {
|
|
beforeEach(async function () {
|
|
this.rejectedChangeAuthorIds = ['author-1', 'author-2']
|
|
// The ranges that getDoc returned must include the changes whose IDs
|
|
// RangesManager reports as removed so UpdateManager can look up the
|
|
// authors locally.
|
|
this.ranges = {
|
|
changes: [
|
|
{
|
|
id: 'change-1',
|
|
metadata: { user_id: 'author-1' },
|
|
},
|
|
{
|
|
id: 'change-2',
|
|
metadata: { user_id: 'author-2' },
|
|
},
|
|
{
|
|
id: 'change-untouched',
|
|
metadata: { user_id: 'author-3' },
|
|
},
|
|
],
|
|
}
|
|
this.DocumentManager.promises.getDoc.resolves({
|
|
lines: this.lines,
|
|
version: this.version,
|
|
ranges: this.ranges,
|
|
pathname: this.pathname,
|
|
projectHistoryId: this.projectHistoryId,
|
|
historyRangesSupport: false,
|
|
type: 'sharejs-text-ot',
|
|
})
|
|
this.RangesManager.applyUpdate.returns({
|
|
newRanges: this.updated_ranges,
|
|
rangesWereCollapsed: false,
|
|
historyUpdates: this.historyUpdates,
|
|
removedChangeIds: ['change-1', 'change-2'],
|
|
})
|
|
this.mockPreviews = [
|
|
{
|
|
sectionPath: ['Intro'],
|
|
startLine: 1,
|
|
changes: [{ d: 'x', p: 0 }],
|
|
slice: 'x',
|
|
sliceStart: 0,
|
|
userIds: ['author-1', 'author-2'],
|
|
},
|
|
]
|
|
this.buildSparseChangePreviews.returns(this.mockPreviews)
|
|
await this.UpdateManager.promises.applyUpdate(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update
|
|
)
|
|
})
|
|
|
|
it('should call buildSparseChangePreviews with the rejected changes and lines', function () {
|
|
this.buildSparseChangePreviews.should.have.been.calledWith({
|
|
changes: [
|
|
{ id: 'change-1', metadata: { user_id: 'author-1' } },
|
|
{ id: 'change-2', metadata: { user_id: 'author-2' } },
|
|
],
|
|
lines: this.lines,
|
|
})
|
|
})
|
|
|
|
it('should notify web of the rejected tracked changes with the previews', function () {
|
|
this.WebApiManager.promises.notifyTrackChangesRejected.should.have.been.calledWith(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.rejectedChangeAuthorIds,
|
|
this.updateMeta.user_id,
|
|
this.mockPreviews
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('when no tracked changes are rejected', function () {
|
|
beforeEach(async function () {
|
|
await this.UpdateManager.promises.applyUpdate(
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.update
|
|
)
|
|
})
|
|
|
|
it('should not notify web', function () {
|
|
this.WebApiManager.promises.notifyTrackChangesRejected.called.should.equal(
|
|
false
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('_adjustHistoryUpdatesMetadata', function () {
|
|
beforeEach(function () {
|
|
this.lines = ['some', 'test', 'data']
|
|
this.updatedDocLines = ['after', 'updates']
|
|
this.historyUpdates = [
|
|
{
|
|
v: 42,
|
|
op: [
|
|
{ i: 'bing', p: 12, trackedDeleteRejection: true },
|
|
{ i: 'foo', p: 4 },
|
|
{ i: 'bar', p: 6 },
|
|
],
|
|
},
|
|
{
|
|
v: 45,
|
|
op: [
|
|
{ d: 'qux', p: 4 },
|
|
{ i: 'bazbaz', p: 14 },
|
|
{
|
|
d: 'bong',
|
|
p: 28,
|
|
trackedChanges: [{ type: 'insert', offset: 0, length: 4 }],
|
|
},
|
|
],
|
|
meta: {
|
|
tc: 'tracking-info',
|
|
},
|
|
},
|
|
{
|
|
v: 47,
|
|
op: [{ d: 'so', p: 0 }],
|
|
},
|
|
{ v: 49, op: [{ i: 'penguin', p: 18 }] },
|
|
]
|
|
this.ranges = {
|
|
changes: [
|
|
{ op: { d: 'bingbong', p: 12 } },
|
|
{ op: { i: 'test', p: 5 } },
|
|
],
|
|
}
|
|
})
|
|
|
|
it('should add projectHistoryId, pathname and doc_length metadata to the ops', function () {
|
|
this.UpdateManager._adjustHistoryUpdatesMetadata(
|
|
this.historyUpdates,
|
|
this.pathname,
|
|
this.projectHistoryId,
|
|
this.lines,
|
|
this.updatedDocLines,
|
|
this.ranges,
|
|
false
|
|
)
|
|
this.historyUpdates.should.deep.equal([
|
|
{
|
|
projectHistoryId: this.projectHistoryId,
|
|
v: 42,
|
|
op: [
|
|
{ i: 'bing', p: 12, trackedDeleteRejection: true },
|
|
{ i: 'foo', p: 4 },
|
|
{ i: 'bar', p: 6 },
|
|
],
|
|
meta: {
|
|
pathname: this.pathname,
|
|
doc_length: 14,
|
|
},
|
|
},
|
|
{
|
|
projectHistoryId: this.projectHistoryId,
|
|
v: 45,
|
|
op: [
|
|
{ d: 'qux', p: 4 },
|
|
{ i: 'bazbaz', p: 14 },
|
|
{
|
|
d: 'bong',
|
|
p: 28,
|
|
trackedChanges: [{ type: 'insert', offset: 0, length: 4 }],
|
|
},
|
|
],
|
|
meta: {
|
|
pathname: this.pathname,
|
|
doc_length: 24, // 14 + 'bing' + 'foo' + 'bar'
|
|
},
|
|
},
|
|
{
|
|
projectHistoryId: this.projectHistoryId,
|
|
v: 47,
|
|
op: [{ d: 'so', p: 0 }],
|
|
meta: {
|
|
pathname: this.pathname,
|
|
doc_length: 23, // 24 - 'qux' + 'bazbaz' - 'bong'
|
|
},
|
|
},
|
|
{
|
|
projectHistoryId: this.projectHistoryId,
|
|
v: 49,
|
|
op: [{ i: 'penguin', p: 18 }],
|
|
meta: {
|
|
pathname: this.pathname,
|
|
doc_length: 21, // 23 - 'so'
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
it('should add additional metadata when ranges support is enabled', function () {
|
|
this.UpdateManager._adjustHistoryUpdatesMetadata(
|
|
this.historyUpdates,
|
|
this.pathname,
|
|
this.projectHistoryId,
|
|
this.lines,
|
|
this.ranges,
|
|
this.updatedDocLines,
|
|
true
|
|
)
|
|
this.historyUpdates.should.deep.equal([
|
|
{
|
|
projectHistoryId: this.projectHistoryId,
|
|
v: 42,
|
|
op: [
|
|
{ i: 'bing', p: 12, trackedDeleteRejection: true },
|
|
{ i: 'foo', p: 4 },
|
|
{ i: 'bar', p: 6 },
|
|
],
|
|
meta: {
|
|
pathname: this.pathname,
|
|
doc_length: 14,
|
|
history_doc_length: 22,
|
|
},
|
|
},
|
|
{
|
|
projectHistoryId: this.projectHistoryId,
|
|
v: 45,
|
|
op: [
|
|
{ d: 'qux', p: 4 },
|
|
{ i: 'bazbaz', p: 14 },
|
|
{
|
|
d: 'bong',
|
|
p: 28,
|
|
trackedChanges: [{ type: 'insert', offset: 0, length: 4 }],
|
|
},
|
|
],
|
|
meta: {
|
|
pathname: this.pathname,
|
|
doc_length: 24, // 14 + 'bing' + 'foo' + 'bar'
|
|
history_doc_length: 28, // 22 + 'foo' + 'bar'
|
|
tc: 'tracking-info',
|
|
},
|
|
},
|
|
{
|
|
projectHistoryId: this.projectHistoryId,
|
|
v: 47,
|
|
op: [{ d: 'so', p: 0 }],
|
|
meta: {
|
|
pathname: this.pathname,
|
|
doc_length: 23, // 24 - 'qux' + 'bazbaz' - 'bong'
|
|
history_doc_length: 30, // 28 - 'bong' + 'bazbaz'
|
|
},
|
|
},
|
|
{
|
|
projectHistoryId: this.projectHistoryId,
|
|
v: 49,
|
|
op: [{ i: 'penguin', p: 18 }],
|
|
meta: {
|
|
pathname: this.pathname,
|
|
doc_length: 21, // 23 - 'so'
|
|
doc_hash: stringHash(this.updatedDocLines.join('\n')),
|
|
history_doc_length: 28, // 30 - 'so'
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
it('should calculate the right doc length for an empty document', function () {
|
|
this.historyUpdates = [{ v: 42, op: [{ i: 'foobar', p: 0 }] }]
|
|
this.UpdateManager._adjustHistoryUpdatesMetadata(
|
|
this.historyUpdates,
|
|
this.pathname,
|
|
this.projectHistoryId,
|
|
[],
|
|
{},
|
|
['foobar'],
|
|
false
|
|
)
|
|
this.historyUpdates.should.deep.equal([
|
|
{
|
|
projectHistoryId: this.projectHistoryId,
|
|
v: 42,
|
|
op: [{ i: 'foobar', p: 0 }],
|
|
meta: {
|
|
pathname: this.pathname,
|
|
doc_length: 0,
|
|
},
|
|
},
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('lockUpdatesAndDo', function () {
|
|
beforeEach(function () {
|
|
this.methodResult = 'method result'
|
|
this.method = sinon.stub().resolves(this.methodResult)
|
|
this.arg1 = 'argument 1'
|
|
})
|
|
|
|
describe('successfully', function () {
|
|
beforeEach(async function () {
|
|
this.UpdateManager.promises.continueProcessingUpdatesWithLock = sinon
|
|
.stub()
|
|
.resolves()
|
|
this.UpdateManager.promises.fetchAndApplyProjectUpdates = sinon
|
|
.stub()
|
|
.resolves()
|
|
this.response = await this.UpdateManager.promises.lockUpdatesAndDo(
|
|
this.method,
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.arg1
|
|
)
|
|
})
|
|
|
|
it('should lock the project', function () {
|
|
this.ProjectLockManager.promises.getLock
|
|
.calledWith(this.project_id)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should refresh the project lock before running the method', function () {
|
|
this.ProjectLockManager.promises.extendLock
|
|
.calledWith(this.project_id, this.projectLockValue)
|
|
.should.equal(true)
|
|
this.method
|
|
.calledAfter(this.ProjectLockManager.promises.extendLock)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should process any outstanding updates for the project', function () {
|
|
this.UpdateManager.promises.fetchAndApplyProjectUpdates.should.have.been.calledWith(
|
|
this.project_id
|
|
)
|
|
})
|
|
|
|
it('should call the method', function () {
|
|
this.method
|
|
.calledWith(this.project_id, this.doc_id, this.arg1)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should return the method response arguments', function () {
|
|
expect(this.response).to.equal(this.methodResult)
|
|
})
|
|
|
|
it('should release the project lock', function () {
|
|
this.ProjectLockManager.promises.releaseLock
|
|
.calledWith(this.project_id, this.projectLockValue)
|
|
.should.equal(true)
|
|
})
|
|
|
|
it('should continue processing updates', function () {
|
|
this.UpdateManager.promises.continueProcessingUpdatesWithLock
|
|
.calledWith(this.project_id)
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('when fetchAndApplyProjectUpdates returns an error', function () {
|
|
beforeEach(async function () {
|
|
this.error = new Error('Something went wrong')
|
|
this.UpdateManager.promises.fetchAndApplyProjectUpdates = sinon
|
|
.stub()
|
|
.rejects(this.error)
|
|
await expect(
|
|
this.UpdateManager.promises.lockUpdatesAndDo(
|
|
this.method,
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.arg1
|
|
)
|
|
).to.be.rejectedWith(this.error)
|
|
})
|
|
|
|
it('should free the project lock', function () {
|
|
this.ProjectLockManager.promises.releaseLock
|
|
.calledWith(this.project_id, this.projectLockValue)
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('when the method returns an error', function () {
|
|
beforeEach(async function () {
|
|
this.error = new Error('something went wrong')
|
|
this.UpdateManager.promises.fetchAndApplyProjectUpdates = sinon
|
|
.stub()
|
|
.resolves()
|
|
this.method = sinon.stub().rejects(this.error)
|
|
await expect(
|
|
this.UpdateManager.promises.lockUpdatesAndDo(
|
|
this.method,
|
|
this.project_id,
|
|
this.doc_id,
|
|
this.arg1
|
|
)
|
|
).to.be.rejectedWith(this.error)
|
|
})
|
|
|
|
it('should free the project lock', function () {
|
|
this.ProjectLockManager.promises.releaseLock
|
|
.calledWith(this.project_id, this.projectLockValue)
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
function stringHash(s) {
|
|
const hash = createHash('sha1')
|
|
hash.update(s)
|
|
return hash.digest('hex')
|
|
}
|