first commit
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import { strict as esmock } from 'esmock'
|
||||
import * as Errors from '../../../../app/js/Errors.js'
|
||||
|
||||
const MODULE_PATH = '../../../../app/js/UpdatesProcessor.js'
|
||||
|
||||
describe('UpdatesProcessor', function () {
|
||||
before(async function () {
|
||||
this.extendLock = sinon.stub()
|
||||
this.BlobManager = {
|
||||
createBlobsForUpdates: sinon.stub(),
|
||||
}
|
||||
this.HistoryStoreManager = {
|
||||
getMostRecentVersion: sinon.stub(),
|
||||
sendChanges: sinon.stub().yields(),
|
||||
}
|
||||
this.LockManager = {
|
||||
runWithLock: sinon.spy((key, runner, callback) =>
|
||||
runner(this.extendLock, callback)
|
||||
),
|
||||
}
|
||||
this.RedisManager = {}
|
||||
this.UpdateCompressor = {
|
||||
compressRawUpdates: sinon.stub(),
|
||||
}
|
||||
this.UpdateTranslator = {
|
||||
convertToChanges: sinon.stub(),
|
||||
isProjectStructureUpdate: sinon.stub(),
|
||||
isTextUpdate: sinon.stub(),
|
||||
}
|
||||
this.WebApiManager = {
|
||||
getHistoryId: sinon.stub(),
|
||||
}
|
||||
this.SyncManager = {
|
||||
expandSyncUpdates: sinon.stub(),
|
||||
setResyncState: sinon.stub().yields(),
|
||||
skipUpdatesDuringSync: sinon.stub(),
|
||||
}
|
||||
this.ErrorRecorder = {
|
||||
getLastFailure: sinon.stub(),
|
||||
record: sinon.stub().yields(null, { attempts: 1 }),
|
||||
}
|
||||
this.RetryManager = {
|
||||
isFirstFailure: sinon.stub().returns(true),
|
||||
isHardFailure: sinon.stub().returns(false),
|
||||
}
|
||||
this.Profiler = {
|
||||
Profiler: class {
|
||||
log() {
|
||||
return this
|
||||
}
|
||||
|
||||
wrap(label, cb) {
|
||||
return cb
|
||||
}
|
||||
|
||||
getTimeDelta() {
|
||||
return 0
|
||||
}
|
||||
|
||||
end() {
|
||||
return 0
|
||||
}
|
||||
},
|
||||
}
|
||||
this.Metrics = {
|
||||
gauge: sinon.stub(),
|
||||
inc: sinon.stub(),
|
||||
timing: sinon.stub(),
|
||||
}
|
||||
this.Settings = {
|
||||
redis: {
|
||||
lock: {
|
||||
key_schema: {
|
||||
projectHistoryLock({ project_id: projectId }) {
|
||||
return `ProjectHistoryLock:${projectId}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
this.UpdatesProcessor = await esmock(MODULE_PATH, {
|
||||
'../../../../app/js/BlobManager.js': this.BlobManager,
|
||||
'../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
|
||||
'../../../../app/js/LockManager.js': this.LockManager,
|
||||
'../../../../app/js/RedisManager.js': this.RedisManager,
|
||||
'../../../../app/js/UpdateCompressor.js': this.UpdateCompressor,
|
||||
'../../../../app/js/UpdateTranslator.js': this.UpdateTranslator,
|
||||
'../../../../app/js/WebApiManager.js': this.WebApiManager,
|
||||
'../../../../app/js/SyncManager.js': this.SyncManager,
|
||||
'../../../../app/js/ErrorRecorder.js': this.ErrorRecorder,
|
||||
'../../../../app/js/Profiler.js': this.Profiler,
|
||||
'../../../../app/js/RetryManager.js': this.RetryManager,
|
||||
'../../../../app/js/Errors.js': Errors,
|
||||
'@overleaf/metrics': this.Metrics,
|
||||
'@overleaf/settings': this.Settings,
|
||||
})
|
||||
this.doc_id = 'doc-id-123'
|
||||
this.project_id = 'project-id-123'
|
||||
this.ol_project_id = 'ol-project-id-234'
|
||||
this.callback = sinon.stub()
|
||||
this.temporary = 'temp-mock'
|
||||
})
|
||||
|
||||
describe('processUpdatesForProject', function () {
|
||||
beforeEach(function () {
|
||||
this.error = new Error('error')
|
||||
this.queueSize = 445
|
||||
this.UpdatesProcessor._mocks._countAndProcessUpdates = sinon
|
||||
.stub()
|
||||
.callsArgWith(3, this.error, this.queueSize)
|
||||
})
|
||||
|
||||
describe('when there is no existing error', function () {
|
||||
beforeEach(function (done) {
|
||||
this.ErrorRecorder.getLastFailure.yields()
|
||||
this.UpdatesProcessor.processUpdatesForProject(this.project_id, err => {
|
||||
expect(err).to.equal(this.error)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('processes updates', function () {
|
||||
this.UpdatesProcessor._mocks._countAndProcessUpdates
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('records errors', function () {
|
||||
this.ErrorRecorder.record
|
||||
.calledWith(this.project_id, this.queueSize, this.error)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getHistoryId', function () {
|
||||
describe('projectHistoryId is not present', function () {
|
||||
beforeEach(function () {
|
||||
this.updates = [
|
||||
{ p: 0, i: 'a' },
|
||||
{ p: 1, i: 's' },
|
||||
]
|
||||
this.WebApiManager.getHistoryId.yields(null)
|
||||
})
|
||||
|
||||
it('returns null', function (done) {
|
||||
this.UpdatesProcessor._getHistoryId(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
(error, projectHistoryId) => {
|
||||
expect(error).to.be.null
|
||||
expect(projectHistoryId).to.be.null
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectHistoryId is not present in updates', function () {
|
||||
beforeEach(function () {
|
||||
this.updates = [
|
||||
{ p: 0, i: 'a' },
|
||||
{ p: 1, i: 's' },
|
||||
]
|
||||
})
|
||||
|
||||
it('returns the id from web', function (done) {
|
||||
this.projectHistoryId = '1234'
|
||||
this.WebApiManager.getHistoryId.yields(null, this.projectHistoryId)
|
||||
|
||||
this.UpdatesProcessor._getHistoryId(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
(error, projectHistoryId) => {
|
||||
expect(error).to.be.null
|
||||
expect(projectHistoryId).equal(this.projectHistoryId)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns errors from web', function (done) {
|
||||
this.error = new Error('oh no!')
|
||||
this.WebApiManager.getHistoryId.yields(this.error)
|
||||
|
||||
this.UpdatesProcessor._getHistoryId(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
error => {
|
||||
expect(error).to.equal(this.error)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectHistoryId is present in some updates', function () {
|
||||
beforeEach(function () {
|
||||
this.projectHistoryId = '1234'
|
||||
this.updates = [
|
||||
{ p: 0, i: 'a' },
|
||||
{ p: 1, i: 's', projectHistoryId: this.projectHistoryId },
|
||||
{ p: 2, i: 'd', projectHistoryId: this.projectHistoryId },
|
||||
]
|
||||
})
|
||||
|
||||
it('returns an error if the id is inconsistent between updates', function (done) {
|
||||
this.updates[1].projectHistoryId = 2345
|
||||
this.UpdatesProcessor._getHistoryId(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
error => {
|
||||
expect(error.message).to.equal(
|
||||
'inconsistent project history id between updates'
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an error if the id is inconsistent between updates and web', function (done) {
|
||||
this.WebApiManager.getHistoryId.yields(null, 2345)
|
||||
this.UpdatesProcessor._getHistoryId(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
error => {
|
||||
expect(error.message).to.equal(
|
||||
'inconsistent project history id between updates and web'
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the id if it is consistent between updates and web', function (done) {
|
||||
this.WebApiManager.getHistoryId.yields(null, this.projectHistoryId)
|
||||
this.UpdatesProcessor._getHistoryId(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
(error, projectHistoryId) => {
|
||||
expect(error).to.be.null
|
||||
expect(projectHistoryId).equal(this.projectHistoryId)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the id if it is consistent between updates but unavaiable in web', function (done) {
|
||||
this.WebApiManager.getHistoryId.yields(new Error('oh no!'))
|
||||
this.UpdatesProcessor._getHistoryId(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
(error, projectHistoryId) => {
|
||||
expect(error).to.be.null
|
||||
expect(projectHistoryId).equal(this.projectHistoryId)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_processUpdates', function () {
|
||||
beforeEach(function () {
|
||||
this.mostRecentVersionInfo = { version: 1 }
|
||||
this.rawUpdates = ['raw updates']
|
||||
this.expandedUpdates = ['expanded updates']
|
||||
this.filteredUpdates = ['filtered updates']
|
||||
this.compressedUpdates = ['compressed updates']
|
||||
this.updatesWithBlobs = ['updates with blob']
|
||||
this.changes = [
|
||||
{
|
||||
toRaw() {
|
||||
return 'change'
|
||||
},
|
||||
},
|
||||
]
|
||||
this.newSyncState = { resyncProjectStructure: false }
|
||||
|
||||
this.extendLock = sinon.stub().yields()
|
||||
this.mostRecentChunk = 'fake-chunk'
|
||||
|
||||
this.HistoryStoreManager.getMostRecentVersion.yields(
|
||||
null,
|
||||
this.mostRecentVersionInfo,
|
||||
null,
|
||||
'_lastChange',
|
||||
this.mostRecentChunk
|
||||
)
|
||||
this.SyncManager.skipUpdatesDuringSync.yields(
|
||||
null,
|
||||
this.filteredUpdates,
|
||||
this.newSyncState
|
||||
)
|
||||
this.SyncManager.expandSyncUpdates.callsArgWith(
|
||||
5,
|
||||
null,
|
||||
this.expandedUpdates
|
||||
)
|
||||
this.UpdateCompressor.compressRawUpdates.returns(this.compressedUpdates)
|
||||
this.BlobManager.createBlobsForUpdates.callsArgWith(
|
||||
4,
|
||||
null,
|
||||
this.updatesWithBlobs
|
||||
)
|
||||
this.UpdateTranslator.convertToChanges.returns(this.changes)
|
||||
})
|
||||
|
||||
describe('happy path', function () {
|
||||
beforeEach(function (done) {
|
||||
this.UpdatesProcessor._processUpdates(
|
||||
this.project_id,
|
||||
this.ol_project_id,
|
||||
this.rawUpdates,
|
||||
this.extendLock,
|
||||
err => {
|
||||
this.callback(err)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the latest version id', function () {
|
||||
this.HistoryStoreManager.getMostRecentVersion.should.have.been.calledWith(
|
||||
this.project_id,
|
||||
this.ol_project_id
|
||||
)
|
||||
})
|
||||
|
||||
it('should skip updates when resyncing', function () {
|
||||
this.SyncManager.skipUpdatesDuringSync.should.have.been.calledWith(
|
||||
this.project_id,
|
||||
this.rawUpdates
|
||||
)
|
||||
})
|
||||
|
||||
it('should expand sync updates', function () {
|
||||
this.SyncManager.expandSyncUpdates.should.have.been.calledWith(
|
||||
this.project_id,
|
||||
this.ol_project_id,
|
||||
this.mostRecentChunk,
|
||||
this.filteredUpdates,
|
||||
this.extendLock
|
||||
)
|
||||
})
|
||||
|
||||
it('should compress updates', function () {
|
||||
this.UpdateCompressor.compressRawUpdates.should.have.been.calledWith(
|
||||
this.expandedUpdates
|
||||
)
|
||||
})
|
||||
|
||||
it('should create any blobs for the updates', function () {
|
||||
this.BlobManager.createBlobsForUpdates.should.have.been.calledWith(
|
||||
this.project_id,
|
||||
this.ol_project_id,
|
||||
this.compressedUpdates
|
||||
)
|
||||
})
|
||||
|
||||
it('should convert the updates into a change requests', function () {
|
||||
this.UpdateTranslator.convertToChanges.should.have.been.calledWith(
|
||||
this.project_id,
|
||||
this.updatesWithBlobs
|
||||
)
|
||||
})
|
||||
|
||||
it('should send the change request to the history store', function () {
|
||||
this.HistoryStoreManager.sendChanges.should.have.been.calledWith(
|
||||
this.project_id,
|
||||
this.ol_project_id,
|
||||
['change']
|
||||
)
|
||||
})
|
||||
|
||||
it('should set the sync state', function () {
|
||||
this.SyncManager.setResyncState.should.have.been.calledWith(
|
||||
this.project_id,
|
||||
this.newSyncState
|
||||
)
|
||||
})
|
||||
|
||||
it('should call the callback with no error', function () {
|
||||
this.callback.should.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an error converting changes', function () {
|
||||
beforeEach(function (done) {
|
||||
this.err = new Error()
|
||||
this.UpdateTranslator.convertToChanges.throws(this.err)
|
||||
this.callback = sinon.stub()
|
||||
|
||||
this.UpdatesProcessor._processUpdates(
|
||||
this.project_id,
|
||||
this.ol_project_id,
|
||||
this.rawUpdates,
|
||||
this.extendLock,
|
||||
err => {
|
||||
this.callback(err)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should call the callback with the error', function () {
|
||||
this.callback.should.have.been.calledWith(this.err)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_skipAlreadyAppliedUpdates', function () {
|
||||
before(function () {
|
||||
this.UpdateTranslator.isProjectStructureUpdate.callsFake(
|
||||
update => update.version != null
|
||||
)
|
||||
this.UpdateTranslator.isTextUpdate.callsFake(update => update.v != null)
|
||||
})
|
||||
|
||||
describe('with all doc ops in order', function () {
|
||||
before(function () {
|
||||
this.updates = [
|
||||
{ doc: 'id', v: 1 },
|
||||
{ doc: 'id', v: 2 },
|
||||
{ doc: 'id', v: 3 },
|
||||
{ doc: 'id', v: 4 },
|
||||
]
|
||||
this.updatesToApply = this.UpdatesProcessor._skipAlreadyAppliedUpdates(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
{ docs: {} }
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the original updates', function () {
|
||||
expect(this.updatesToApply).to.eql(this.updates)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with all project ops in order', function () {
|
||||
before(function () {
|
||||
this.updates = [
|
||||
{ version: 1 },
|
||||
{ version: 2 },
|
||||
{ version: 3 },
|
||||
{ version: 4 },
|
||||
]
|
||||
this.updatesToApply = this.UpdatesProcessor._skipAlreadyAppliedUpdates(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
{ docs: {} }
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the original updates', function () {
|
||||
expect(this.updatesToApply).to.eql(this.updates)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with all multiple doc and ops in order', function () {
|
||||
before(function () {
|
||||
this.updates = [
|
||||
{ doc: 'id1', v: 1 },
|
||||
{ doc: 'id1', v: 2 },
|
||||
{ doc: 'id1', v: 3 },
|
||||
{ doc: 'id1', v: 4 },
|
||||
{ doc: 'id2', v: 1 },
|
||||
{ doc: 'id2', v: 2 },
|
||||
{ doc: 'id2', v: 3 },
|
||||
{ doc: 'id2', v: 4 },
|
||||
{ version: 1 },
|
||||
{ version: 2 },
|
||||
{ version: 3 },
|
||||
{ version: 4 },
|
||||
]
|
||||
this.updatesToApply = this.UpdatesProcessor._skipAlreadyAppliedUpdates(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
{ docs: {} }
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the original updates', function () {
|
||||
expect(this.updatesToApply).to.eql(this.updates)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with doc ops out of order', function () {
|
||||
before(function () {
|
||||
this.updates = [
|
||||
{ doc: 'id', v: 1 },
|
||||
{ doc: 'id', v: 2 },
|
||||
{ doc: 'id', v: 4 },
|
||||
{ doc: 'id', v: 3 },
|
||||
]
|
||||
this.skipFn = sinon.spy(
|
||||
this.UpdatesProcessor._mocks,
|
||||
'_skipAlreadyAppliedUpdates'
|
||||
)
|
||||
try {
|
||||
this.updatesToApply =
|
||||
this.UpdatesProcessor._skipAlreadyAppliedUpdates(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
{ docs: {} }
|
||||
)
|
||||
} catch (error) {}
|
||||
})
|
||||
|
||||
after(function () {
|
||||
this.skipFn.restore()
|
||||
})
|
||||
|
||||
it('should throw an exception', function () {
|
||||
this.skipFn.threw('OpsOutOfOrderError').should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with project ops out of order', function () {
|
||||
before(function () {
|
||||
this.updates = [
|
||||
{ version: 1 },
|
||||
{ version: 2 },
|
||||
{ version: 4 },
|
||||
{ version: 3 },
|
||||
]
|
||||
this.skipFn = sinon.spy(
|
||||
this.UpdatesProcessor._mocks,
|
||||
'_skipAlreadyAppliedUpdates'
|
||||
)
|
||||
try {
|
||||
this.updatesToApply =
|
||||
this.UpdatesProcessor._skipAlreadyAppliedUpdates(
|
||||
this.project_id,
|
||||
this.updates,
|
||||
{ docs: {} }
|
||||
)
|
||||
} catch (error) {}
|
||||
})
|
||||
|
||||
after(function () {
|
||||
this.skipFn.restore()
|
||||
})
|
||||
|
||||
it('should throw an exception', function () {
|
||||
this.skipFn.threw('OpsOutOfOrderError').should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Reference in New Issue
Block a user