first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

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