Files
overleaf-cep/services/document-updater/test/unit/js/UpdateManager/UpdateManagerTests.js
T
KristinaandJimmy Domagala-Tang 9c005e4e54 [web/document-updater] display preview for accepted/rejected tracked changes (#36240)
* [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
2026-09-17 11:58:14 +00:00

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')
}