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,57 @@
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/js/DiffCodec.js'
const SandboxedModule = require('sandboxed-module')
describe('DiffCodec', function () {
beforeEach(function () {
this.callback = sinon.stub()
this.DiffCodec = SandboxedModule.require(modulePath)
})
describe('diffAsShareJsOps', function () {
it('should insert new text correctly', function () {
this.before = ['hello world']
this.after = ['hello beautiful world']
const ops = this.DiffCodec.diffAsShareJsOp(this.before, this.after)
expect(ops).to.deep.equal([
{
i: 'beautiful ',
p: 6,
},
])
})
it('should shift later inserts by previous inserts', function () {
this.before = ['the boy played with the ball']
this.after = ['the tall boy played with the red ball']
const ops = this.DiffCodec.diffAsShareJsOp(this.before, this.after)
expect(ops).to.deep.equal([
{ i: 'tall ', p: 4 },
{ i: 'red ', p: 29 },
])
})
it('should delete text correctly', function () {
this.before = ['hello beautiful world']
this.after = ['hello world']
const ops = this.DiffCodec.diffAsShareJsOp(this.before, this.after)
expect(ops).to.deep.equal([
{
d: 'beautiful ',
p: 6,
},
])
})
it('should shift later deletes by the first deletes', function () {
this.before = ['the tall boy played with the red ball']
this.after = ['the boy played with the ball']
const ops = this.DiffCodec.diffAsShareJsOp(this.before, this.after)
expect(ops).to.deep.equal([
{ d: 'tall ', p: 4 },
{ d: 'red ', p: 24 },
])
})
})
})

View File

@@ -0,0 +1,198 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const modulePath = '../../../../app/js/DispatchManager.js'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/js/Errors.js')
describe('DispatchManager', function () {
beforeEach(function () {
let Timer
this.timeout(3000)
this.DispatchManager = SandboxedModule.require(modulePath, {
requires: {
'./UpdateManager': (this.UpdateManager = {}),
'@overleaf/settings': (this.settings = {
redis: {
documentupdater: {},
},
}),
'@overleaf/redis-wrapper': (this.redis = {}),
'./RateLimitManager': {},
'./Errors': Errors,
'./Metrics': (this.Metrics = {
Timer: (Timer = (function () {
Timer = class Timer {
static initClass() {
this.prototype.done = sinon.stub()
}
}
Timer.initClass()
return Timer
})()),
}),
},
})
this.callback = sinon.stub()
return (this.RateLimiter = {
run(task, cb) {
return task(cb)
},
})
}) // run task without rate limit
return describe('each worker', function () {
beforeEach(function () {
this.client = { auth: sinon.stub() }
this.redis.createClient = sinon.stub().returns(this.client)
return (this.worker = this.DispatchManager.createDispatcher(
this.RateLimiter,
0
))
})
it('should create a new redis client', function () {
return this.redis.createClient.called.should.equal(true)
})
describe('_waitForUpdateThenDispatchWorker', function () {
beforeEach(function () {
this.project_id = 'project-id-123'
this.doc_id = 'doc-id-123'
this.doc_key = `${this.project_id}:${this.doc_id}`
return (this.client.blpop = sinon
.stub()
.callsArgWith(2, null, ['pending-updates-list', this.doc_key]))
})
describe('in the normal case', function () {
beforeEach(function () {
this.UpdateManager.processOutstandingUpdatesWithLock = sinon
.stub()
.callsArg(2)
return this.worker._waitForUpdateThenDispatchWorker(this.callback)
})
it('should call redis with BLPOP', function () {
return this.client.blpop
.calledWith('pending-updates-list', 0)
.should.equal(true)
})
it('should call processOutstandingUpdatesWithLock', function () {
return this.UpdateManager.processOutstandingUpdatesWithLock
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should not log any errors', function () {
this.logger.error.called.should.equal(false)
return this.logger.warn.called.should.equal(false)
})
return it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
describe('with an error', function () {
beforeEach(function () {
this.UpdateManager.processOutstandingUpdatesWithLock = sinon
.stub()
.callsArgWith(2, new Error('a generic error'))
return this.worker._waitForUpdateThenDispatchWorker(this.callback)
})
it('should log an error', function () {
return this.logger.error.called.should.equal(true)
})
return it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
describe("with a 'Delete component' error", function () {
beforeEach(function () {
this.UpdateManager.processOutstandingUpdatesWithLock = sinon
.stub()
.callsArgWith(2, new Errors.DeleteMismatchError())
return this.worker._waitForUpdateThenDispatchWorker(this.callback)
})
it('should log a debug message', function () {
return this.logger.debug.called.should.equal(true)
})
return it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
describe('pending updates list with shard key', function () {
beforeEach(function (done) {
this.client = {
auth: sinon.stub(),
blpop: sinon.stub().callsArgWith(2),
}
this.redis.createClient = sinon.stub().returns(this.client)
this.queueShardNumber = 7
this.worker = this.DispatchManager.createDispatcher(
this.RateLimiter,
this.queueShardNumber
)
this.worker._waitForUpdateThenDispatchWorker(done)
})
it('should call redis with BLPOP with the correct key', function () {
this.client.blpop
.calledWith(`pending-updates-list-${this.queueShardNumber}`, 0)
.should.equal(true)
})
})
})
return describe('run', function () {
return it('should call _waitForUpdateThenDispatchWorker until shutting down', function (done) {
let callCount = 0
this.worker._waitForUpdateThenDispatchWorker = callback => {
if (callback == null) {
callback = function () {}
}
callCount++
if (callCount === 3) {
this.settings.shuttingDown = true
}
return setTimeout(() => callback(), 10)
}
sinon.spy(this.worker, '_waitForUpdateThenDispatchWorker')
this.worker.run()
const checkStatus = () => {
if (!this.settings.shuttingDown) {
// retry until shutdown
setTimeout(checkStatus, 100)
} else {
this.worker._waitForUpdateThenDispatchWorker.callCount.should.equal(
3
)
return done()
}
}
return checkStatus()
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
const _ = require('lodash')
const { expect } = require('chai')
const HistoryConversions = require('../../../app/js/HistoryConversions')
describe('HistoryConversions', function () {
describe('toHistoryRanges', function () {
it('handles empty ranges', function () {
expect(HistoryConversions.toHistoryRanges({})).to.deep.equal({})
})
it("doesn't modify comments when there are no tracked changes", function () {
const ranges = {
comments: [makeComment('comment1', 5, 12)],
}
const historyRanges = HistoryConversions.toHistoryRanges(ranges)
expect(historyRanges).to.deep.equal(ranges)
})
it('adjusts comments and tracked changes to account for tracked deletes', function () {
const comments = [
makeComment('comment0', 0, 1),
makeComment('comment1', 10, 12),
makeComment('comment2', 20, 10),
makeComment('comment3', 15, 3),
]
const changes = [
makeTrackedDelete('change0', 2, 5),
makeTrackedInsert('change1', 4, 5),
makeTrackedDelete('change2', 10, 10),
makeTrackedDelete('change3', 21, 6),
makeTrackedDelete('change4', 50, 7),
]
const ranges = { comments, changes }
const historyRanges = HistoryConversions.toHistoryRanges(ranges)
expect(historyRanges.comments).to.have.deep.members([
comments[0],
// shifted by change0 and change2, extended by change3
enrichOp(comments[1], {
hpos: 25, // 10 + 5 + 10
hlen: 18, // 12 + 6
}),
// shifted by change0 and change2, extended by change3
enrichOp(comments[2], {
hpos: 35, // 20 + 5 + 10
hlen: 16, // 10 + 6
}),
// shifted by change0 and change2
enrichOp(comments[3], {
hpos: 30, // 15 + 5 + 10
}),
])
expect(historyRanges.changes).to.deep.equal([
changes[0],
enrichOp(changes[1], {
hpos: 9, // 4 + 5
}),
enrichOp(changes[2], {
hpos: 15, // 10 + 5
}),
enrichOp(changes[3], {
hpos: 36, // 21 + 5 + 10
}),
enrichOp(changes[4], {
hpos: 71, // 50 + 5 + 10 + 6
}),
])
})
})
})
function makeComment(id, pos, length) {
return {
id,
op: {
c: 'c'.repeat(length),
p: pos,
t: id,
},
metadata: makeMetadata(),
}
}
function makeTrackedInsert(id, pos, length) {
return {
id,
op: {
i: 'i'.repeat(length),
p: pos,
},
metadata: makeMetadata(),
}
}
function makeTrackedDelete(id, pos, length) {
return {
id,
op: {
d: 'd'.repeat(length),
p: pos,
},
metadata: makeMetadata(),
}
}
function makeMetadata() {
return {
user_id: 'user-id',
ts: new Date().toISOString(),
}
}
function enrichOp(commentOrChange, extraFields) {
const result = _.cloneDeep(commentOrChange)
Object.assign(result.op, extraFields)
return result
}

View File

@@ -0,0 +1,291 @@
/* eslint-disable
mocha/no-nested-tests,
*/
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const modulePath = require('node:path').join(
__dirname,
'../../../../app/js/HistoryManager'
)
describe('HistoryManager', function () {
beforeEach(function () {
this.HistoryManager = SandboxedModule.require(modulePath, {
requires: {
request: (this.request = {}),
'@overleaf/settings': (this.Settings = {
apis: {
project_history: {
url: 'http://project_history.example.com',
},
},
}),
'./DocumentManager': (this.DocumentManager = {}),
'./RedisManager': (this.RedisManager = {}),
'./ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
'./Metrics': (this.metrics = { inc: sinon.stub() }),
},
})
this.project_id = 'mock-project-id'
this.callback = sinon.stub()
})
describe('flushProjectChangesAsync', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 })
this.HistoryManager.flushProjectChangesAsync(this.project_id)
})
it('should send a request to the project history api', function () {
this.request.post
.calledWith({
url: `${this.Settings.apis.project_history.url}/project/${this.project_id}/flush`,
qs: { background: true },
})
.should.equal(true)
})
})
describe('flushProjectChanges', function () {
describe('in the normal case', function () {
beforeEach(function (done) {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 })
this.HistoryManager.flushProjectChanges(
this.project_id,
{
background: true,
},
done
)
})
it('should send a request to the project history api', function () {
this.request.post
.calledWith({
url: `${this.Settings.apis.project_history.url}/project/${this.project_id}/flush`,
qs: { background: true },
})
.should.equal(true)
})
})
describe('with the skip_history_flush option', function () {
beforeEach(function (done) {
this.request.post = sinon.stub()
this.HistoryManager.flushProjectChanges(
this.project_id,
{
skip_history_flush: true,
},
done
)
})
it('should not send a request to the project history api', function () {
this.request.post.called.should.equal(false)
})
})
})
describe('recordAndFlushHistoryOps', function () {
beforeEach(function () {
this.ops = ['mock-ops']
this.project_ops_length = 10
this.HistoryManager.flushProjectChangesAsync = sinon.stub()
})
describe('with no ops', function () {
beforeEach(function () {
this.HistoryManager.recordAndFlushHistoryOps(
this.project_id,
[],
this.project_ops_length
)
})
it('should not flush project changes', function () {
this.HistoryManager.flushProjectChangesAsync.called.should.equal(false)
})
})
describe('with enough ops to flush project changes', function () {
beforeEach(function () {
this.HistoryManager.shouldFlushHistoryOps = sinon.stub()
this.HistoryManager.shouldFlushHistoryOps
.withArgs(this.project_ops_length)
.returns(true)
this.HistoryManager.recordAndFlushHistoryOps(
this.project_id,
this.ops,
this.project_ops_length
)
})
it('should flush project changes', function () {
this.HistoryManager.flushProjectChangesAsync
.calledWith(this.project_id)
.should.equal(true)
})
})
describe('with enough ops to flush doc changes', function () {
beforeEach(function () {
this.HistoryManager.shouldFlushHistoryOps = sinon.stub()
this.HistoryManager.shouldFlushHistoryOps
.withArgs(this.project_ops_length)
.returns(false)
this.HistoryManager.recordAndFlushHistoryOps(
this.project_id,
this.ops,
this.project_ops_length
)
})
it('should not flush project changes', function () {
this.HistoryManager.flushProjectChangesAsync.called.should.equal(false)
})
})
describe('shouldFlushHistoryOps', function () {
it('should return false if the number of ops is not known', function () {
this.HistoryManager.shouldFlushHistoryOps(
null,
['a', 'b', 'c'].length,
1
).should.equal(false)
})
it("should return false if the updates didn't take us past the threshold", function () {
// Currently there are 14 ops
// Previously we were on 11 ops
// We didn't pass over a multiple of 5
this.HistoryManager.shouldFlushHistoryOps(
14,
['a', 'b', 'c'].length,
5
).should.equal(false)
it('should return true if the updates took to the threshold', function () {})
// Currently there are 15 ops
// Previously we were on 12 ops
// We've reached a new multiple of 5
this.HistoryManager.shouldFlushHistoryOps(
15,
['a', 'b', 'c'].length,
5
).should.equal(true)
})
it('should return true if the updates took past the threshold', function () {
// Currently there are 19 ops
// Previously we were on 16 ops
// We didn't pass over a multiple of 5
this.HistoryManager.shouldFlushHistoryOps(
17,
['a', 'b', 'c'].length,
5
).should.equal(true)
})
})
})
describe('resyncProjectHistory', function () {
beforeEach(function () {
this.projectHistoryId = 'history-id-1234'
this.docs = [
{
doc: this.doc_id,
path: 'main.tex',
},
]
this.files = [
{
file: 'mock-file-id',
path: 'universe.png',
url: `www.filestore.test/${this.project_id}/mock-file-id`,
},
]
this.ProjectHistoryRedisManager.queueResyncProjectStructure = sinon
.stub()
.yields()
this.DocumentManager.resyncDocContentsWithLock = sinon.stub().yields()
})
describe('full sync', function () {
beforeEach(function () {
this.HistoryManager.resyncProjectHistory(
this.project_id,
this.projectHistoryId,
this.docs,
this.files,
{},
this.callback
)
})
it('should queue a project structure reync', function () {
this.ProjectHistoryRedisManager.queueResyncProjectStructure
.calledWith(
this.project_id,
this.projectHistoryId,
this.docs,
this.files
)
.should.equal(true)
})
it('should queue doc content reyncs', function () {
this.DocumentManager.resyncDocContentsWithLock
.calledWith(this.project_id, this.docs[0].doc, this.docs[0].path)
.should.equal(true)
})
it('should call the callback', function () {
this.callback.called.should.equal(true)
})
})
describe('resyncProjectStructureOnly=true', function () {
beforeEach(function () {
this.HistoryManager.resyncProjectHistory(
this.project_id,
this.projectHistoryId,
this.docs,
this.files,
{ resyncProjectStructureOnly: true },
this.callback
)
})
it('should queue a project structure reync', function () {
this.ProjectHistoryRedisManager.queueResyncProjectStructure
.calledWith(
this.project_id,
this.projectHistoryId,
this.docs,
this.files,
{ resyncProjectStructureOnly: true }
)
.should.equal(true)
})
it('should not queue doc content reyncs', function () {
this.DocumentManager.resyncDocContentsWithLock.called.should.equal(
false
)
})
it('should call the callback', function () {
this.callback.called.should.equal(true)
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
const { expect } = require('chai')
const modulePath = '../../../../app/js/Limits.js'
const SandboxedModule = require('sandboxed-module')
describe('Limits', function () {
beforeEach(function () {
return (this.Limits = SandboxedModule.require(modulePath))
})
describe('getTotalSizeOfLines', function () {
it('should compute the character count for a document with multiple lines', function () {
const count = this.Limits.getTotalSizeOfLines(['123', '4567'])
expect(count).to.equal(9)
})
it('should compute the character count for a document with a single line', function () {
const count = this.Limits.getTotalSizeOfLines(['123'])
expect(count).to.equal(4)
})
it('should compute the character count for an empty document', function () {
const count = this.Limits.getTotalSizeOfLines([])
expect(count).to.equal(0)
})
})
describe('docIsTooLarge', function () {
describe('when the estimated size is below the limit', function () {
it('should return false when the estimated size is below the limit', function () {
const result = this.Limits.docIsTooLarge(128, ['hello', 'world'], 1024)
expect(result).to.be.false
})
})
describe('when the estimated size is at the limit', function () {
it('should return false when the estimated size is at the limit', function () {
const result = this.Limits.docIsTooLarge(1024, ['hello', 'world'], 1024)
expect(result).to.be.false
})
})
describe('when the estimated size is above the limit', function () {
it('should return false when the actual character count is below the limit', function () {
const result = this.Limits.docIsTooLarge(2048, ['hello', 'world'], 1024)
expect(result).to.be.false
})
it('should return false when the actual character count is at the limit', function () {
const result = this.Limits.docIsTooLarge(2048, ['x'.repeat(1023)], 1024)
expect(result).to.be.false
})
it('should return true when the actual character count is above the limit by 1', function () {
const count = this.Limits.docIsTooLarge(2048, ['x'.repeat(1024)], 1024)
expect(count).to.be.true
})
it('should return true when the actual character count is above the limit', function () {
const count = this.Limits.docIsTooLarge(2048, ['x'.repeat(2000)], 1024)
expect(count).to.be.true
})
})
describe('when the document has many lines', function () {
it('should return false when the actual character count is below the limit ', function () {
const count = this.Limits.docIsTooLarge(
2048,
'1234567890'.repeat(100).split('0'),
1024
)
expect(count).to.be.false
})
it('should return true when the actual character count is above the limit', function () {
const count = this.Limits.docIsTooLarge(
2048,
'1234567890'.repeat(2000).split('0'),
1024
)
expect(count).to.be.true
})
})
})
})

View File

@@ -0,0 +1,65 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const assert = require('node:assert')
const path = require('node:path')
const modulePath = path.join(__dirname, '../../../../app/js/LockManager.js')
const projectId = 1234
const docId = 5678
const blockingKey = `Blocking:${docId}`
const SandboxedModule = require('sandboxed-module')
describe('LockManager - checking the lock', function () {
let Profiler
const existsStub = sinon.stub()
const mocks = {
'@overleaf/redis-wrapper': {
createClient() {
return {
auth() {},
exists: existsStub,
}
},
},
'@overleaf/metrics': { inc() {} },
'./Profiler': (Profiler = (function () {
Profiler = class Profiler {
static initClass() {
this.prototype.log = sinon.stub().returns({ end: sinon.stub() })
this.prototype.end = sinon.stub()
}
}
Profiler.initClass()
return Profiler
})()),
}
const LockManager = SandboxedModule.require(modulePath, { requires: mocks })
it('should return true if the key does not exists', function (done) {
existsStub.yields(null, '0')
return LockManager.checkLock(docId, (err, free) => {
if (err) return done(err)
free.should.equal(true)
return done()
})
})
return it('should return false if the key does exists', function (done) {
existsStub.yields(null, '1')
return LockManager.checkLock(docId, (err, free) => {
if (err) return done(err)
free.should.equal(false)
return done()
})
})
})

View File

@@ -0,0 +1,94 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const assert = require('node:assert')
const path = require('node:path')
const modulePath = path.join(__dirname, '../../../../app/js/LockManager.js')
const projectId = 1234
const docId = 5678
const SandboxedModule = require('sandboxed-module')
describe('LockManager - releasing the lock', function () {
beforeEach(function () {
let Profiler
this.client = {
auth() {},
eval: sinon.stub(),
}
const mocks = {
'@overleaf/redis-wrapper': {
createClient: () => this.client,
},
'@overleaf/settings': {
redis: {
lock: {
key_schema: {
blockingKey({ doc_id: docId }) {
return `Blocking:${docId}`
},
},
},
},
},
'@overleaf/metrics': { inc() {} },
'./Profiler': (Profiler = (function () {
Profiler = class Profiler {
static initClass() {
this.prototype.log = sinon.stub().returns({ end: sinon.stub() })
this.prototype.end = sinon.stub()
}
}
Profiler.initClass()
return Profiler
})()),
}
this.LockManager = SandboxedModule.require(modulePath, { requires: mocks })
this.lockValue = 'lock-value-stub'
return (this.callback = sinon.stub())
})
describe('when the lock is current', function () {
beforeEach(function () {
this.client.eval = sinon.stub().yields(null, 1)
return this.LockManager.releaseLock(docId, this.lockValue, this.callback)
})
it('should clear the data from redis', function () {
return this.client.eval
.calledWith(
this.LockManager.unlockScript,
1,
`Blocking:${docId}`,
this.lockValue
)
.should.equal(true)
})
return it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
return describe('when the lock has expired', function () {
beforeEach(function () {
this.client.eval = sinon.stub().yields(null, 0)
return this.LockManager.releaseLock(docId, this.lockValue, this.callback)
})
return it('should return an error if the lock has expired', function () {
return this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
})

View File

@@ -0,0 +1,126 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const modulePath = '../../../../app/js/LockManager.js'
const SandboxedModule = require('sandboxed-module')
describe('LockManager - getting the lock', function () {
beforeEach(function () {
let Profiler
this.LockManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/redis-wrapper': {
createClient: () => {
return { auth() {} }
},
},
'@overleaf/metrics': { inc() {} },
'./Profiler': (Profiler = (function () {
Profiler = class Profiler {
static initClass() {
this.prototype.log = sinon.stub().returns({ end: sinon.stub() })
this.prototype.end = sinon.stub()
}
}
Profiler.initClass()
return Profiler
})()),
},
})
this.callback = sinon.stub()
return (this.doc_id = 'doc-id-123')
})
describe('when the lock is not set', function () {
beforeEach(function (done) {
this.lockValue = 'mock-lock-value'
this.LockManager.tryLock = sinon
.stub()
.callsArgWith(1, null, true, this.lockValue)
return this.LockManager.getLock(this.doc_id, (...args) => {
this.callback(...Array.from(args || []))
return done()
})
})
it('should try to get the lock', function () {
return this.LockManager.tryLock.calledWith(this.doc_id).should.equal(true)
})
it('should only need to try once', function () {
return this.LockManager.tryLock.callCount.should.equal(1)
})
return it('should return the callback with the lock value', function () {
return this.callback.calledWith(null, this.lockValue).should.equal(true)
})
})
describe('when the lock is initially set', function () {
beforeEach(function (done) {
this.lockValue = 'mock-lock-value'
const startTime = Date.now()
let tries = 0
this.LockManager.LOCK_TEST_INTERVAL = 5
this.LockManager.tryLock = (docId, callback) => {
if (callback == null) {
callback = function () {}
}
if (Date.now() - startTime < 20 || tries < 2) {
tries = tries + 1
return callback(null, false)
} else {
return callback(null, true, this.lockValue)
}
}
sinon.spy(this.LockManager, 'tryLock')
return this.LockManager.getLock(this.doc_id, (...args) => {
this.callback(...Array.from(args || []))
return done()
})
})
it('should call tryLock multiple times until free', function () {
return (this.LockManager.tryLock.callCount > 1).should.equal(true)
})
return it('should return the callback with the lock value', function () {
return this.callback.calledWith(null, this.lockValue).should.equal(true)
})
})
return describe('when the lock times out', function () {
beforeEach(function (done) {
const time = Date.now()
this.LockManager.MAX_LOCK_WAIT_TIME = 5
this.LockManager.tryLock = sinon.stub().callsArgWith(1, null, false)
return this.LockManager.getLock(this.doc_id, (...args) => {
this.callback(...Array.from(args || []))
return done()
})
})
return it('should return the callback with an error', function () {
return this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(sinon.match.has('doc_id', this.doc_id))
)
.should.equal(true)
})
})
})

View File

@@ -0,0 +1,155 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const modulePath = '../../../../app/js/LockManager.js'
const SandboxedModule = require('sandboxed-module')
const tk = require('timekeeper')
describe('LockManager - trying the lock', function () {
beforeEach(function () {
let Profiler
this.LockManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/redis-wrapper': {
createClient: () => {
return {
auth() {},
set: (this.set = sinon.stub()),
}
},
},
'@overleaf/metrics': { inc() {} },
'@overleaf/settings': {
redis: {
lock: {
key_schema: {
blockingKey({ doc_id: docId }) {
return `Blocking:${docId}`
},
},
},
},
},
'./Profiler':
(this.Profiler = Profiler =
(function () {
Profiler = class Profiler {
static initClass() {
this.prototype.log = sinon
.stub()
.returns({ end: sinon.stub() })
this.prototype.end = sinon.stub()
}
}
Profiler.initClass()
return Profiler
})()),
},
})
this.callback = sinon.stub()
return (this.doc_id = 'doc-id-123')
})
describe('when the lock is not set', function () {
beforeEach(function () {
this.lockValue = 'mock-lock-value'
this.LockManager.randomLock = sinon.stub().returns(this.lockValue)
this.set.callsArgWith(5, null, 'OK')
return this.LockManager.tryLock(this.doc_id, this.callback)
})
it('should set the lock key with an expiry if it is not set', function () {
return this.set
.calledWith(`Blocking:${this.doc_id}`, this.lockValue, 'EX', 30, 'NX')
.should.equal(true)
})
return it('should return the callback with true and the lock value', function () {
return this.callback
.calledWith(null, true, this.lockValue)
.should.equal(true)
})
})
describe('when the lock is already set', function () {
beforeEach(function () {
this.set.callsArgWith(5, null, null)
return this.LockManager.tryLock(this.doc_id, this.callback)
})
return it('should return the callback with false', function () {
return this.callback.calledWith(null, false).should.equal(true)
})
})
return describe('when it takes a long time for redis to set the lock', function () {
beforeEach(function () {
tk.freeze(Date.now())
this.lockValue = 'mock-lock-value'
this.LockManager.randomLock = sinon.stub().returns(this.lockValue)
this.LockManager.releaseLock = sinon.stub().callsArgWith(2, null)
this.set.callsFake((_key, _v, _ex, _ttl, _nx, cb) => {
tk.freeze(Date.now() + 7000)
cb(null, 'OK')
})
})
after(function () {
tk.reset()
})
describe('in all cases', function () {
beforeEach(function () {
return this.LockManager.tryLock(this.doc_id, this.callback)
})
it('should set the lock key with an expiry if it is not set', function () {
return this.set
.calledWith(`Blocking:${this.doc_id}`, this.lockValue, 'EX', 30, 'NX')
.should.equal(true)
})
return it('should try to release the lock', function () {
return this.LockManager.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
})
})
describe('if the lock is released successfully', function () {
beforeEach(function () {
this.LockManager.releaseLock = sinon.stub().callsArgWith(2, null)
return this.LockManager.tryLock(this.doc_id, this.callback)
})
return it('should return the callback with false', function () {
return this.callback.calledWith(null, false).should.equal(true)
})
})
return describe('if the lock has already timed out', function () {
beforeEach(function () {
this.LockManager.releaseLock = sinon
.stub()
.callsArgWith(2, new Error('tried to release timed out lock'))
return this.LockManager.tryLock(this.doc_id, this.callback)
})
return it('should return the callback with an error', function () {
return this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,524 @@
const sinon = require('sinon')
const modulePath = '../../../../app/js/PersistenceManager.js'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/js/Errors')
describe('PersistenceManager', function () {
beforeEach(function () {
this.request = sinon.stub()
this.request.defaults = () => this.request
this.Metrics = {
Timer: class Timer {},
inc: sinon.stub(),
}
this.Metrics.Timer.prototype.done = sinon.stub()
this.Settings = {}
this.PersistenceManager = SandboxedModule.require(modulePath, {
requires: {
requestretry: this.request,
'@overleaf/settings': this.Settings,
'./Metrics': this.Metrics,
'./Errors': Errors,
},
})
this.project_id = 'project-id-123'
this.projectHistoryId = 'history-id-123'
this.doc_id = 'doc-id-123'
this.lines = ['one', 'two', 'three']
this.version = 42
this.callback = sinon.stub()
this.ranges = { comments: 'mock', entries: 'mock' }
this.pathname = '/a/b/c.tex'
this.lastUpdatedAt = Date.now()
this.lastUpdatedBy = 'last-author-id'
this.historyRangesSupport = false
this.Settings.apis = {
web: {
url: (this.url = 'www.example.com'),
user: (this.user = 'overleaf'),
pass: (this.pass = 'password'),
},
}
})
describe('getDoc', function () {
beforeEach(function () {
this.webResponse = {
lines: this.lines,
version: this.version,
ranges: this.ranges,
pathname: this.pathname,
projectHistoryId: this.projectHistoryId,
historyRangesSupport: this.historyRangesSupport,
}
})
describe('with a successful response from the web api', function () {
beforeEach(function () {
this.request.callsArgWith(
1,
null,
{ statusCode: 200 },
JSON.stringify(this.webResponse)
)
this.PersistenceManager.getDoc(
this.project_id,
this.doc_id,
this.callback
)
})
it('should call the web api', function () {
this.request
.calledWith({
url: `${this.url}/project/${this.project_id}/doc/${this.doc_id}`,
method: 'GET',
headers: {
accept: 'application/json',
},
auth: {
user: this.user,
pass: this.pass,
sendImmediately: true,
},
jar: false,
timeout: 5000,
})
.should.equal(true)
})
it('should call the callback with the doc lines, version and ranges', function () {
this.callback
.calledWith(
null,
this.lines,
this.version,
this.ranges,
this.pathname,
this.projectHistoryId,
this.historyRangesSupport
)
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('getDoc', 1, { status: 200 })
.should.equal(true)
})
})
describe('with the peek option', function () {
beforeEach(function () {
this.request.yields(
null,
{ statusCode: 200 },
JSON.stringify(this.webResponse)
)
this.PersistenceManager.getDoc(
this.project_id,
this.doc_id,
{ peek: true },
this.callback
)
})
it('should call the web api with a peek param', function () {
this.request
.calledWith({
url: `${this.url}/project/${this.project_id}/doc/${this.doc_id}`,
qs: { peek: 'true' },
method: 'GET',
headers: {
accept: 'application/json',
},
auth: {
user: this.user,
pass: this.pass,
sendImmediately: true,
},
jar: false,
timeout: 5000,
})
.should.equal(true)
})
})
describe('when request returns an error', function () {
beforeEach(function () {
this.error = new Error('oops')
this.error.code = 'EOOPS'
this.request.callsArgWith(1, this.error, null, null)
this.PersistenceManager.getDoc(
this.project_id,
this.doc_id,
this.callback
)
})
it('should return a generic connection error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(sinon.match.has('message', 'error connecting to web API'))
)
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('getDoc', 1, { status: 'EOOPS' })
.should.equal(true)
})
})
describe('when the request returns 404', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 404 }, '')
this.PersistenceManager.getDoc(
this.project_id,
this.doc_id,
this.callback
)
})
it('should return a NotFoundError', function () {
this.callback
.calledWith(sinon.match.instanceOf(Errors.NotFoundError))
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('getDoc', 1, { status: 404 })
.should.equal(true)
})
})
describe('when the request returns 413', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 413 }, '')
this.PersistenceManager.getDoc(
this.project_id,
this.doc_id,
this.callback
)
})
it('should return a FileTooLargeError', function () {
this.callback
.calledWith(sinon.match.instanceOf(Errors.FileTooLargeError))
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('getDoc', 1, { status: 413 })
.should.equal(true)
})
})
describe('when the request returns an error status code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.PersistenceManager.getDoc(
this.project_id,
this.doc_id,
this.callback
)
})
it('should return an error', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('getDoc', 1, { status: 500 })
.should.equal(true)
})
})
describe('when request returns an doc without lines', function () {
beforeEach(function () {
delete this.webResponse.lines
this.request.callsArgWith(
1,
null,
{ statusCode: 200 },
JSON.stringify(this.webResponse)
)
this.PersistenceManager.getDoc(
this.project_id,
this.doc_id,
this.callback
)
})
it('should return and error', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when request returns an doc without a version', function () {
beforeEach(function () {
delete this.webResponse.version
this.request.callsArgWith(
1,
null,
{ statusCode: 200 },
JSON.stringify(this.webResponse)
)
this.PersistenceManager.getDoc(
this.project_id,
this.doc_id,
this.callback
)
})
it('should return and error', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
describe('when request returns an doc without a pathname', function () {
beforeEach(function () {
delete this.webResponse.pathname
this.request.callsArgWith(
1,
null,
{ statusCode: 200 },
JSON.stringify(this.webResponse)
)
this.PersistenceManager.getDoc(
this.project_id,
this.doc_id,
this.callback
)
})
it('should return and error', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
})
describe('setDoc', function () {
describe('with a successful response from the web api', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 200 })
this.PersistenceManager.setDoc(
this.project_id,
this.doc_id,
this.lines,
this.version,
this.ranges,
this.lastUpdatedAt,
this.lastUpdatedBy,
this.callback
)
})
it('should call the web api', function () {
this.request
.calledWith({
url: `${this.url}/project/${this.project_id}/doc/${this.doc_id}`,
json: {
lines: this.lines,
version: this.version,
ranges: this.ranges,
lastUpdatedAt: this.lastUpdatedAt,
lastUpdatedBy: this.lastUpdatedBy,
},
method: 'POST',
auth: {
user: this.user,
pass: this.pass,
sendImmediately: true,
},
jar: false,
timeout: 5000,
})
.should.equal(true)
})
it('should call the callback without error', function () {
this.callback.calledWith(null).should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('setDoc', 1, { status: 200 })
.should.equal(true)
})
})
describe('when request returns an error', function () {
beforeEach(function () {
this.error = new Error('oops')
this.error.code = 'EOOPS'
this.request.callsArgWith(1, this.error, null, null)
this.PersistenceManager.setDoc(
this.project_id,
this.doc_id,
this.lines,
this.version,
this.ranges,
this.lastUpdatedAt,
this.lastUpdatedBy,
this.callback
)
})
it('should return a generic connection error', function () {
this.callback
.calledWith(
sinon.match
.instanceOf(Error)
.and(sinon.match.has('message', 'error connecting to web API'))
)
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('setDoc', 1, { status: 'EOOPS' })
.should.equal(true)
})
})
describe('when the request returns 404', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 404 }, '')
this.PersistenceManager.setDoc(
this.project_id,
this.doc_id,
this.lines,
this.version,
this.ranges,
this.lastUpdatedAt,
this.lastUpdatedBy,
this.callback
)
})
it('should return a NotFoundError', function () {
this.callback
.calledWith(sinon.match.instanceOf(Errors.NotFoundError))
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('setDoc', 1, { status: 404 })
.should.equal(true)
})
})
describe('when the request returns 413', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 413 }, '')
this.PersistenceManager.setDoc(
this.project_id,
this.doc_id,
this.lines,
this.version,
this.ranges,
this.lastUpdatedAt,
this.lastUpdatedBy,
this.callback
)
})
it('should return a FileTooLargeError', function () {
this.callback
.calledWith(sinon.match.instanceOf(Errors.FileTooLargeError))
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('setDoc', 1, { status: 413 })
.should.equal(true)
})
})
describe('when the request returns an error status code', function () {
beforeEach(function () {
this.request.callsArgWith(1, null, { statusCode: 500 }, '')
this.PersistenceManager.setDoc(
this.project_id,
this.doc_id,
this.lines,
this.version,
this.ranges,
this.lastUpdatedAt,
this.lastUpdatedBy,
this.callback
)
})
it('should return an error', function () {
this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
it('should increment the metric', function () {
this.Metrics.inc
.calledWith('setDoc', 1, { status: 500 })
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,610 @@
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/js/ProjectHistoryRedisManager.js'
const SandboxedModule = require('sandboxed-module')
const tk = require('timekeeper')
describe('ProjectHistoryRedisManager', function () {
beforeEach(function () {
this.project_id = 'project-id-123'
this.projectHistoryId = 'history-id-123'
this.user_id = 'user-id-123'
this.rclient = {}
this.source = 'editor'
tk.freeze(new Date())
this.Limits = {
docIsTooLarge: sinon.stub().returns(false),
}
this.ProjectHistoryRedisManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {
max_doc_length: 123,
redis: {
project_history: {
key_schema: {
projectHistoryOps({ project_id: projectId }) {
return `ProjectHistory:Ops:${projectId}`
},
projectHistoryFirstOpTimestamp({ project_id: projectId }) {
return `ProjectHistory:FirstOpTimestamp:${projectId}`
},
},
},
},
}),
'@overleaf/redis-wrapper': {
createClient: () => this.rclient,
},
'./Metrics': (this.metrics = { summary: sinon.stub() }),
'./Limits': this.Limits,
},
})
})
afterEach(function () {
tk.reset()
})
describe('queueOps', function () {
beforeEach(async function () {
this.ops = ['mock-op-1', 'mock-op-2']
this.multi = { exec: sinon.stub().resolves([1]) }
this.multi.rpush = sinon.stub()
this.multi.setnx = sinon.stub()
this.rclient.multi = () => this.multi
await this.ProjectHistoryRedisManager.promises.queueOps(
this.project_id,
...this.ops
)
})
it('should queue an update', function () {
this.multi.rpush
.calledWithExactly(
`ProjectHistory:Ops:${this.project_id}`,
this.ops[0],
this.ops[1]
)
.should.equal(true)
})
it('should set the queue timestamp if not present', function () {
this.multi.setnx
.calledWithExactly(
`ProjectHistory:FirstOpTimestamp:${this.project_id}`,
Date.now()
)
.should.equal(true)
})
})
describe('queueRenameEntity', function () {
beforeEach(async function () {
this.file_id = 1234
this.rawUpdate = {
pathname: (this.pathname = '/old'),
newPathname: (this.newPathname = '/new'),
version: (this.version = 2),
}
this.ProjectHistoryRedisManager.promises.queueOps = sinon
.stub()
.resolves()
await this.ProjectHistoryRedisManager.promises.queueRenameEntity(
this.project_id,
this.projectHistoryId,
'file',
this.file_id,
this.user_id,
this.rawUpdate,
this.source
)
})
it('should queue an update', function () {
const update = {
pathname: this.pathname,
new_pathname: this.newPathname,
meta: {
user_id: this.user_id,
ts: new Date(),
source: this.source,
},
version: this.version,
projectHistoryId: this.projectHistoryId,
file: this.file_id,
}
this.ProjectHistoryRedisManager.promises.queueOps
.calledWithExactly(this.project_id, JSON.stringify(update))
.should.equal(true)
})
})
describe('queueAddEntity', function () {
beforeEach(function () {
this.doc_id = 1234
this.rawUpdate = {
pathname: (this.pathname = '/old'),
docLines: (this.docLines = 'a\nb'),
version: (this.version = 2),
}
this.ProjectHistoryRedisManager.promises.queueOps = sinon
.stub()
.resolves()
})
it('should queue an update', async function () {
this.rawUpdate.url = this.url = 'filestore.example.com'
await this.ProjectHistoryRedisManager.promises.queueAddEntity(
this.project_id,
this.projectHistoryId,
'doc',
this.doc_id,
this.user_id,
this.rawUpdate,
this.source
)
const update = {
pathname: this.pathname,
docLines: this.docLines,
url: this.url,
meta: {
user_id: this.user_id,
ts: new Date(),
source: this.source,
},
version: this.version,
projectHistoryId: this.projectHistoryId,
createdBlob: false,
doc: this.doc_id,
}
this.ProjectHistoryRedisManager.promises.queueOps
.calledWithExactly(this.project_id, JSON.stringify(update))
.should.equal(true)
})
it('should queue an update with file metadata', async function () {
const metadata = {
importedAt: '2024-07-30T09:14:45.928Z',
provider: 'references-provider',
}
const projectId = 'project-id'
const fileId = 'file-id'
const url = `http://filestore/project/${projectId}/file/${fileId}`
await this.ProjectHistoryRedisManager.promises.queueAddEntity(
projectId,
this.projectHistoryId,
'file',
fileId,
this.user_id,
{
pathname: 'foo.png',
url,
version: 42,
hash: '1337',
metadata,
},
this.source
)
const update = {
pathname: 'foo.png',
docLines: undefined,
url,
meta: {
user_id: this.user_id,
ts: new Date(),
source: this.source,
},
version: 42,
hash: '1337',
metadata,
projectHistoryId: this.projectHistoryId,
createdBlob: false,
file: fileId,
}
expect(
this.ProjectHistoryRedisManager.promises.queueOps.args[0][1]
).to.equal(JSON.stringify(update))
this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
projectId,
JSON.stringify(update)
)
})
it('should forward history compatible ranges if history ranges support is enabled', async function () {
this.rawUpdate.historyRangesSupport = true
this.docLines = 'the quick fox jumps over the lazy dog'
const ranges = {
changes: [
{
op: { p: 4, i: 'quick' },
metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
},
{
op: { p: 9, d: ' brown' },
metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
},
{
op: { p: 14, i: 'jumps' },
metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
},
],
comments: [
{
op: { p: 29, c: 'lazy', t: 'comment-1' },
metadata: { resolved: false },
},
],
}
this.rawUpdate.ranges = ranges
this.rawUpdate.docLines = this.docLines
await this.ProjectHistoryRedisManager.promises.queueAddEntity(
this.project_id,
this.projectHistoryId,
'doc',
this.doc_id,
this.user_id,
this.rawUpdate,
this.source
)
const historyCompatibleRanges = {
comments: [
{
op: { p: 29, c: 'lazy', t: 'comment-1', hpos: 35 },
metadata: { resolved: false },
},
],
changes: [
{
op: { p: 4, i: 'quick' },
metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
},
{
op: { p: 9, d: ' brown' },
metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
},
{
op: { p: 14, i: 'jumps', hpos: 20 },
metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
},
],
}
const update = {
pathname: this.pathname,
docLines: 'the quick brown fox jumps over the lazy dog',
meta: {
user_id: this.user_id,
ts: new Date(),
source: this.source,
},
version: this.version,
projectHistoryId: this.projectHistoryId,
createdBlob: false,
ranges: historyCompatibleRanges,
doc: this.doc_id,
}
expect(
this.ProjectHistoryRedisManager.promises.queueOps
).to.have.been.calledWithExactly(this.project_id, JSON.stringify(update))
})
it('should not forward ranges if history ranges support is disabled', async function () {
this.rawUpdate.historyRangesSupport = false
const ranges = {
changes: [
{
op: { p: 0, i: 'foo' },
metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
},
{
op: { p: 7, d: ' baz' },
metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
},
],
comments: [
{
op: { p: 4, c: 'bar', t: 'comment-1' },
metadata: { resolved: false },
},
],
}
this.rawUpdate.ranges = ranges
await this.ProjectHistoryRedisManager.promises.queueAddEntity(
this.project_id,
this.projectHistoryId,
'doc',
this.doc_id,
this.user_id,
this.rawUpdate,
this.source
)
const update = {
pathname: this.pathname,
docLines: this.docLines,
meta: {
user_id: this.user_id,
ts: new Date(),
source: this.source,
},
version: this.version,
projectHistoryId: this.projectHistoryId,
createdBlob: false,
doc: this.doc_id,
}
this.ProjectHistoryRedisManager.promises.queueOps
.calledWithExactly(this.project_id, JSON.stringify(update))
.should.equal(true)
})
it('should not forward ranges if history ranges support is undefined', async function () {
this.rawUpdate.historyRangesSupport = false
const ranges = {
changes: [
{
op: { p: 0, i: 'foo' },
metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
},
{
op: { p: 7, d: ' baz' },
metadata: { ts: '2024-02-01T00:00:00.000Z', user_id: 'user-1' },
},
],
comments: [
{
op: { p: 4, c: 'bar', t: 'comment-1' },
metadata: { resolved: false },
},
],
}
this.rawUpdate.ranges = ranges
await this.ProjectHistoryRedisManager.promises.queueAddEntity(
this.project_id,
this.projectHistoryId,
'doc',
this.doc_id,
this.user_id,
this.rawUpdate,
this.source
)
const update = {
pathname: this.pathname,
docLines: this.docLines,
meta: {
user_id: this.user_id,
ts: new Date(),
source: this.source,
},
version: this.version,
projectHistoryId: this.projectHistoryId,
createdBlob: false,
doc: this.doc_id,
}
this.ProjectHistoryRedisManager.promises.queueOps
.calledWithExactly(this.project_id, JSON.stringify(update))
.should.equal(true)
})
it('should pass "false" as the createdBlob field if not provided', async function () {
await this.ProjectHistoryRedisManager.promises.queueAddEntity(
this.project_id,
this.projectHistoryId,
'doc',
this.doc_id,
this.user_id,
this.rawUpdate,
this.source
)
const update = {
pathname: this.pathname,
docLines: this.docLines,
meta: {
user_id: this.user_id,
ts: new Date(),
source: this.source,
},
version: this.version,
projectHistoryId: this.projectHistoryId,
createdBlob: false,
doc: this.doc_id,
}
this.ProjectHistoryRedisManager.promises.queueOps
.calledWithExactly(this.project_id, JSON.stringify(update))
.should.equal(true)
})
it('should pass through the value of the createdBlob field', async function () {
this.rawUpdate.createdBlob = true
await this.ProjectHistoryRedisManager.promises.queueAddEntity(
this.project_id,
this.projectHistoryId,
'doc',
this.doc_id,
this.user_id,
this.rawUpdate,
this.source
)
const update = {
pathname: this.pathname,
docLines: this.docLines,
meta: {
user_id: this.user_id,
ts: new Date(),
source: this.source,
},
version: this.version,
projectHistoryId: this.projectHistoryId,
createdBlob: true,
doc: this.doc_id,
}
this.ProjectHistoryRedisManager.promises.queueOps
.calledWithExactly(this.project_id, JSON.stringify(update))
.should.equal(true)
})
})
describe('queueResyncProjectStructure', function () {
it('should queue an update', function () {})
})
describe('queueResyncDocContent', function () {
beforeEach(function () {
this.doc_id = 1234
this.lines = ['one', 'two']
this.ranges = {
changes: [{ op: { i: 'ne', p: 1 } }, { op: { d: 'deleted', p: 3 } }],
}
this.resolvedCommentIds = ['comment-1']
this.version = 2
this.pathname = '/path'
this.ProjectHistoryRedisManager.promises.queueOps = sinon
.stub()
.resolves()
})
describe('with a good doc', function () {
beforeEach(async function () {
this.update = {
resyncDocContent: {
content: 'one\ntwo',
version: this.version,
},
projectHistoryId: this.projectHistoryId,
path: this.pathname,
doc: this.doc_id,
meta: { ts: new Date() },
}
await this.ProjectHistoryRedisManager.promises.queueResyncDocContent(
this.project_id,
this.projectHistoryId,
this.doc_id,
this.lines,
this.ranges,
this.resolvedCommentIds,
this.version,
this.pathname,
false
)
})
it('should check if the doc is too large', function () {
this.Limits.docIsTooLarge
.calledWith(
JSON.stringify(this.update).length,
this.lines,
this.settings.max_doc_length
)
.should.equal(true)
})
it('should queue an update', function () {
this.ProjectHistoryRedisManager.promises.queueOps
.calledWithExactly(this.project_id, JSON.stringify(this.update))
.should.equal(true)
})
})
describe('with a doc that is too large', function () {
beforeEach(async function () {
this.Limits.docIsTooLarge.returns(true)
await expect(
this.ProjectHistoryRedisManager.promises.queueResyncDocContent(
this.project_id,
this.projectHistoryId,
this.doc_id,
this.lines,
this.ranges,
this.resolvedCommentIds,
this.version,
this.pathname,
false
)
).to.be.rejected
})
it('should not queue an update if the doc is too large', function () {
this.ProjectHistoryRedisManager.promises.queueOps.called.should.equal(
false
)
})
})
describe('when history ranges support is enabled', function () {
beforeEach(async function () {
this.update = {
resyncDocContent: {
content: 'onedeleted\ntwo',
version: this.version,
ranges: this.ranges,
resolvedCommentIds: this.resolvedCommentIds,
},
projectHistoryId: this.projectHistoryId,
path: this.pathname,
doc: this.doc_id,
meta: { ts: new Date() },
}
await this.ProjectHistoryRedisManager.promises.queueResyncDocContent(
this.project_id,
this.projectHistoryId,
this.doc_id,
this.lines,
this.ranges,
this.resolvedCommentIds,
this.version,
this.pathname,
true
)
})
it('should include tracked deletes in the update', function () {
this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWithExactly(
this.project_id,
JSON.stringify(this.update)
)
})
it('should check the doc length without tracked deletes', function () {
this.Limits.docIsTooLarge.should.have.been.calledWith(
JSON.stringify(this.update).length,
this.lines,
this.settings.max_doc_length
)
})
it('should queue an update', function () {
this.ProjectHistoryRedisManager.promises.queueOps
.calledWithExactly(this.project_id, JSON.stringify(this.update))
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,157 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const modulePath = '../../../../app/js/ProjectManager.js'
const SandboxedModule = require('sandboxed-module')
describe('ProjectManager - flushAndDeleteProject', function () {
beforeEach(function () {
let Timer
this.LockManager = {
getLock: sinon.stub().yields(),
releaseLock: sinon.stub().yields(),
}
this.ProjectManager = SandboxedModule.require(modulePath, {
requires: {
'./RedisManager': (this.RedisManager = {}),
'./ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
'./DocumentManager': (this.DocumentManager = {}),
'./HistoryManager': (this.HistoryManager = {
flushProjectChanges: sinon.stub().callsArg(2),
}),
'./LockManager': this.LockManager,
'./Metrics': (this.Metrics = {
Timer: (Timer = (function () {
Timer = class Timer {
static initClass() {
this.prototype.done = sinon.stub()
}
}
Timer.initClass()
return Timer
})()),
}),
},
})
this.project_id = 'project-id-123'
return (this.callback = sinon.stub())
})
describe('successfully', function () {
beforeEach(function (done) {
this.doc_ids = ['doc-id-1', 'doc-id-2', 'doc-id-3']
this.RedisManager.getDocIdsInProject = sinon
.stub()
.callsArgWith(1, null, this.doc_ids)
this.DocumentManager.flushAndDeleteDocWithLock = sinon.stub().callsArg(3)
return this.ProjectManager.flushAndDeleteProjectWithLocks(
this.project_id,
{},
error => {
this.callback(error)
return done()
}
)
})
it('should get the doc ids in the project', function () {
return this.RedisManager.getDocIdsInProject
.calledWith(this.project_id)
.should.equal(true)
})
it('should delete each doc in the project', function () {
return Array.from(this.doc_ids).map(docId =>
this.DocumentManager.flushAndDeleteDocWithLock
.calledWith(this.project_id, docId, {})
.should.equal(true)
)
})
it('should flush project history', function () {
return this.HistoryManager.flushProjectChanges
.calledWith(this.project_id, {})
.should.equal(true)
})
it('should call the callback without error', function () {
return this.callback.calledWith(null).should.equal(true)
})
return it('should time the execution', function () {
return this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
return describe('when a doc errors', function () {
beforeEach(function (done) {
this.doc_ids = ['doc-id-1', 'doc-id-2', 'doc-id-3']
this.RedisManager.getDocIdsInProject = sinon
.stub()
.callsArgWith(1, null, this.doc_ids)
this.DocumentManager.flushAndDeleteDocWithLock = sinon.spy(
(projectId, docId, options, callback) => {
if (docId === 'doc-id-1') {
return callback(
(this.error = new Error('oops, something went wrong'))
)
} else {
return callback()
}
}
)
return this.ProjectManager.flushAndDeleteProjectWithLocks(
this.project_id,
{},
error => {
this.callback(error)
return done()
}
)
})
it('should still flush each doc in the project', function () {
return Array.from(this.doc_ids).map(docId =>
this.DocumentManager.flushAndDeleteDocWithLock
.calledWith(this.project_id, docId, {})
.should.equal(true)
)
})
it('should still flush project history', function () {
return this.HistoryManager.flushProjectChanges
.calledWith(this.project_id, {})
.should.equal(true)
})
it('should record the error', function () {
return this.logger.error
.calledWith(
{ err: this.error, projectId: this.project_id, docId: 'doc-id-1' },
'error deleting doc'
)
.should.equal(true)
})
it('should call the callback with an error', function () {
return this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
return it('should time the execution', function () {
return this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
})

View File

@@ -0,0 +1,145 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const modulePath = '../../../../app/js/ProjectManager.js'
const SandboxedModule = require('sandboxed-module')
describe('ProjectManager - flushProject', function () {
beforeEach(function () {
let Timer
this.LockManager = {
getLock: sinon.stub().yields(),
releaseLock: sinon.stub().yields(),
}
this.ProjectManager = SandboxedModule.require(modulePath, {
requires: {
'./RedisManager': (this.RedisManager = {}),
'./ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
'./DocumentManager': (this.DocumentManager = {}),
'./HistoryManager': (this.HistoryManager = {}),
'./LockManager': this.LockManager,
'./Metrics': (this.Metrics = {
Timer: (Timer = (function () {
Timer = class Timer {
static initClass() {
this.prototype.done = sinon.stub()
}
}
Timer.initClass()
return Timer
})()),
}),
},
})
this.project_id = 'project-id-123'
return (this.callback = sinon.stub())
})
describe('successfully', function () {
beforeEach(function (done) {
this.doc_ids = ['doc-id-1', 'doc-id-2', 'doc-id-3']
this.RedisManager.getDocIdsInProject = sinon
.stub()
.callsArgWith(1, null, this.doc_ids)
this.DocumentManager.flushDocIfLoadedWithLock = sinon.stub().callsArg(2)
return this.ProjectManager.flushProjectWithLocks(
this.project_id,
error => {
this.callback(error)
return done()
}
)
})
it('should get the doc ids in the project', function () {
return this.RedisManager.getDocIdsInProject
.calledWith(this.project_id)
.should.equal(true)
})
it('should flush each doc in the project', function () {
return Array.from(this.doc_ids).map(docId =>
this.DocumentManager.flushDocIfLoadedWithLock
.calledWith(this.project_id, docId)
.should.equal(true)
)
})
it('should call the callback without error', function () {
return this.callback.calledWith(null).should.equal(true)
})
return it('should time the execution', function () {
return this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
return describe('when a doc errors', function () {
beforeEach(function (done) {
this.doc_ids = ['doc-id-1', 'doc-id-2', 'doc-id-3']
this.RedisManager.getDocIdsInProject = sinon
.stub()
.callsArgWith(1, null, this.doc_ids)
this.DocumentManager.flushDocIfLoadedWithLock = sinon.spy(
(projectId, docId, callback) => {
if (callback == null) {
callback = function () {}
}
if (docId === 'doc-id-1') {
return callback(
(this.error = new Error('oops, something went wrong'))
)
} else {
return callback()
}
}
)
return this.ProjectManager.flushProjectWithLocks(
this.project_id,
error => {
this.callback(error)
return done()
}
)
})
it('should still flush each doc in the project', function () {
return Array.from(this.doc_ids).map(docId =>
this.DocumentManager.flushDocIfLoadedWithLock
.calledWith(this.project_id, docId)
.should.equal(true)
)
})
it('should record the error', function () {
return this.logger.error
.calledWith(
{ err: this.error, projectId: this.project_id, docId: 'doc-id-1' },
'error flushing doc'
)
.should.equal(true)
})
it('should call the callback with an error', function () {
return this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
return it('should time the execution', function () {
return this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
})

View File

@@ -0,0 +1,224 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const modulePath = '../../../../app/js/ProjectManager.js'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/js/Errors.js')
describe('ProjectManager - getProjectDocsAndFlushIfOld', function () {
beforeEach(function () {
let Timer
this.LockManager = {
getLock: sinon.stub().yields(),
releaseLock: sinon.stub().yields(),
}
this.ProjectManager = SandboxedModule.require(modulePath, {
requires: {
'./RedisManager': (this.RedisManager = {}),
'./ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
'./DocumentManager': (this.DocumentManager = {}),
'./HistoryManager': (this.HistoryManager = {}),
'./LockManager': this.LockManager,
'./Metrics': (this.Metrics = {
Timer: (Timer = (function () {
Timer = class Timer {
static initClass() {
this.prototype.done = sinon.stub()
}
}
Timer.initClass()
return Timer
})()),
}),
'./Errors': Errors,
},
})
this.project_id = 'project-id-123'
this.callback = sinon.stub()
return (this.doc_versions = [111, 222, 333])
})
describe('successfully', function () {
beforeEach(function (done) {
this.doc_ids = ['doc-id-1', 'doc-id-2', 'doc-id-3']
this.doc_lines = [
['aaa', 'aaa'],
['bbb', 'bbb'],
['ccc', 'ccc'],
]
this.docs = [
{
_id: this.doc_ids[0],
lines: this.doc_lines[0],
v: this.doc_versions[0],
},
{
_id: this.doc_ids[1],
lines: this.doc_lines[1],
v: this.doc_versions[1],
},
{
_id: this.doc_ids[2],
lines: this.doc_lines[2],
v: this.doc_versions[2],
},
]
this.RedisManager.checkOrSetProjectState = sinon
.stub()
.callsArgWith(2, null)
this.RedisManager.getDocIdsInProject = sinon
.stub()
.callsArgWith(1, null, this.doc_ids)
this.DocumentManager.getDocAndFlushIfOldWithLock = sinon.stub()
this.DocumentManager.getDocAndFlushIfOldWithLock
.withArgs(this.project_id, this.doc_ids[0])
.callsArgWith(2, null, this.doc_lines[0], this.doc_versions[0])
this.DocumentManager.getDocAndFlushIfOldWithLock
.withArgs(this.project_id, this.doc_ids[1])
.callsArgWith(2, null, this.doc_lines[1], this.doc_versions[1])
this.DocumentManager.getDocAndFlushIfOldWithLock
.withArgs(this.project_id, this.doc_ids[2])
.callsArgWith(2, null, this.doc_lines[2], this.doc_versions[2])
return this.ProjectManager.getProjectDocsAndFlushIfOld(
this.project_id,
this.projectStateHash,
this.excludeVersions,
(error, docs) => {
this.callback(error, docs)
return done()
}
)
})
it('should check the project state', function () {
return this.RedisManager.checkOrSetProjectState
.calledWith(this.project_id, this.projectStateHash)
.should.equal(true)
})
it('should get the doc ids in the project', function () {
return this.RedisManager.getDocIdsInProject
.calledWith(this.project_id)
.should.equal(true)
})
it('should call the callback without error', function () {
return this.callback.calledWith(null, this.docs).should.equal(true)
})
return it('should time the execution', function () {
return this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when the state does not match', function () {
beforeEach(function (done) {
this.doc_ids = ['doc-id-1', 'doc-id-2', 'doc-id-3']
this.RedisManager.checkOrSetProjectState = sinon
.stub()
.callsArgWith(2, null, true)
return this.ProjectManager.getProjectDocsAndFlushIfOld(
this.project_id,
this.projectStateHash,
this.excludeVersions,
(error, docs) => {
this.callback(error, docs)
return done()
}
)
})
it('should check the project state', function () {
return this.RedisManager.checkOrSetProjectState
.calledWith(this.project_id, this.projectStateHash)
.should.equal(true)
})
it('should call the callback with an error', function () {
return this.callback
.calledWith(sinon.match.instanceOf(Errors.ProjectStateChangedError))
.should.equal(true)
})
return it('should time the execution', function () {
return this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when a doc errors', function () {
beforeEach(function (done) {
this.doc_ids = ['doc-id-1', 'doc-id-2', 'doc-id-3']
this.RedisManager.checkOrSetProjectState = sinon
.stub()
.callsArgWith(2, null)
this.RedisManager.getDocIdsInProject = sinon
.stub()
.callsArgWith(1, null, this.doc_ids)
this.DocumentManager.getDocAndFlushIfOldWithLock = sinon.stub()
this.DocumentManager.getDocAndFlushIfOldWithLock
.withArgs(this.project_id, 'doc-id-1')
.callsArgWith(2, null, ['test doc content'], this.doc_versions[1])
this.DocumentManager.getDocAndFlushIfOldWithLock
.withArgs(this.project_id, 'doc-id-2')
.callsArgWith(2, (this.error = new Error('oops'))) // trigger an error
return this.ProjectManager.getProjectDocsAndFlushIfOld(
this.project_id,
this.projectStateHash,
this.excludeVersions,
(error, docs) => {
this.callback(error)
return done()
}
)
})
it('should record the error', function () {
return this.logger.error
.calledWith(
{ err: this.error, projectId: this.project_id, docId: 'doc-id-2' },
'error getting project doc lines in getProjectDocsAndFlushIfOld'
)
.should.equal(true)
})
it('should call the callback with an error', function () {
return this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
return it('should time the execution', function () {
return this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
return describe('clearing the project state with clearProjectState', function () {
beforeEach(function (done) {
this.RedisManager.clearProjectState = sinon.stub().callsArg(1)
return this.ProjectManager.clearProjectState(this.project_id, error => {
this.callback(error)
return done()
})
})
it('should clear the project state', function () {
return this.RedisManager.clearProjectState
.calledWith(this.project_id)
.should.equal(true)
})
return it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
})

View File

@@ -0,0 +1,417 @@
const sinon = require('sinon')
const modulePath = '../../../../app/js/ProjectManager.js'
const SandboxedModule = require('sandboxed-module')
const _ = require('lodash')
describe('ProjectManager', function () {
beforeEach(function () {
this.RedisManager = {}
this.ProjectHistoryRedisManager = {
queueRenameEntity: sinon.stub().yields(),
queueAddEntity: sinon.stub().yields(),
}
this.DocumentManager = {
renameDocWithLock: sinon.stub().yields(),
}
this.HistoryManager = {
flushProjectChangesAsync: sinon.stub(),
shouldFlushHistoryOps: sinon.stub().returns(false),
}
this.LockManager = {
getLock: sinon.stub().yields(),
releaseLock: sinon.stub().yields(),
}
this.Metrics = {
Timer: class Timer {},
}
this.Metrics.Timer.prototype.done = sinon.stub()
this.ProjectManager = SandboxedModule.require(modulePath, {
requires: {
'./RedisManager': this.RedisManager,
'./ProjectHistoryRedisManager': this.ProjectHistoryRedisManager,
'./DocumentManager': this.DocumentManager,
'./HistoryManager': this.HistoryManager,
'./LockManager': this.LockManager,
'./Metrics': this.Metrics,
},
})
this.project_id = 'project-id-123'
this.projectHistoryId = 'history-id-123'
this.user_id = 'user-id-123'
this.version = 1234567
this.source = 'editor'
this.callback = sinon.stub()
})
describe('updateProjectWithLocks', function () {
describe('rename operations', function () {
beforeEach(function () {
this.firstDocUpdate = {
type: 'rename-doc',
id: 1,
pathname: 'foo',
newPathname: 'foo',
}
this.secondDocUpdate = {
type: 'rename-doc',
id: 2,
pathname: 'bar',
newPathname: 'bar2',
}
this.firstFileUpdate = {
type: 'rename-file',
id: 2,
pathname: 'bar',
newPathname: 'bar2',
}
this.updates = [
this.firstDocUpdate,
this.secondDocUpdate,
this.firstFileUpdate,
]
})
describe('successfully', function () {
beforeEach(function () {
this.ProjectManager.updateProjectWithLocks(
this.project_id,
this.projectHistoryId,
this.user_id,
this.updates,
this.version,
this.source,
this.callback
)
})
it('should rename the docs in the updates', function () {
const firstDocUpdateWithVersion = _.extend({}, this.firstDocUpdate, {
version: `${this.version}.0`,
})
const secondDocUpdateWithVersion = _.extend(
{},
this.secondDocUpdate,
{ version: `${this.version}.1` }
)
this.DocumentManager.renameDocWithLock
.calledWith(
this.project_id,
this.firstDocUpdate.id,
this.user_id,
firstDocUpdateWithVersion,
this.projectHistoryId
)
.should.equal(true)
this.DocumentManager.renameDocWithLock
.calledWith(
this.project_id,
this.secondDocUpdate.id,
this.user_id,
secondDocUpdateWithVersion,
this.projectHistoryId
)
.should.equal(true)
})
it('should rename the files in the updates', function () {
const firstFileUpdateWithVersion = _.extend(
{},
this.firstFileUpdate,
{ version: `${this.version}.2` }
)
this.ProjectHistoryRedisManager.queueRenameEntity
.calledWith(
this.project_id,
this.projectHistoryId,
'file',
this.firstFileUpdate.id,
this.user_id,
firstFileUpdateWithVersion,
this.source
)
.should.equal(true)
})
it('should not flush the history', function () {
this.HistoryManager.flushProjectChangesAsync
.calledWith(this.project_id)
.should.equal(false)
})
it('should call the callback', function () {
this.callback.called.should.equal(true)
})
})
describe('when renaming a doc fails', function () {
beforeEach(function () {
this.error = new Error('error')
this.DocumentManager.renameDocWithLock.yields(this.error)
this.ProjectManager.updateProjectWithLocks(
this.project_id,
this.projectHistoryId,
this.user_id,
this.updates,
this.version,
this.source,
this.callback
)
})
it('should call the callback with the error', function () {
this.callback.calledWith(this.error).should.equal(true)
})
})
describe('when renaming a file fails', function () {
beforeEach(function () {
this.error = new Error('error')
this.ProjectHistoryRedisManager.queueRenameEntity.yields(this.error)
this.ProjectManager.updateProjectWithLocks(
this.project_id,
this.projectHistoryId,
this.user_id,
this.updates,
this.version,
this.source,
this.callback
)
})
it('should call the callback with the error', function () {
this.callback.calledWith(this.error).should.equal(true)
})
})
describe('with enough ops to flush', function () {
beforeEach(function () {
this.HistoryManager.shouldFlushHistoryOps.returns(true)
this.ProjectManager.updateProjectWithLocks(
this.project_id,
this.projectHistoryId,
this.user_id,
this.updates,
this.version,
this.source,
this.callback
)
})
it('should flush the history', function () {
this.HistoryManager.flushProjectChangesAsync
.calledWith(this.project_id)
.should.equal(true)
})
})
})
describe('add operations', function () {
beforeEach(function () {
this.firstDocUpdate = {
type: 'add-doc',
id: 1,
docLines: 'a\nb',
}
this.secondDocUpdate = {
type: 'add-doc',
id: 2,
docLines: 'a\nb',
}
this.firstFileUpdate = {
type: 'add-file',
id: 3,
url: 'filestore.example.com/2',
}
this.secondFileUpdate = {
type: 'add-file',
id: 4,
url: 'filestore.example.com/3',
}
this.updates = [
this.firstDocUpdate,
this.secondDocUpdate,
this.firstFileUpdate,
this.secondFileUpdate,
]
})
describe('successfully', function () {
beforeEach(function () {
this.ProjectManager.updateProjectWithLocks(
this.project_id,
this.projectHistoryId,
this.user_id,
this.updates,
this.version,
this.source,
this.callback
)
})
it('should add the docs in the updates', function () {
const firstDocUpdateWithVersion = _.extend({}, this.firstDocUpdate, {
version: `${this.version}.0`,
})
const secondDocUpdateWithVersion = _.extend(
{},
this.secondDocUpdate,
{ version: `${this.version}.1` }
)
this.ProjectHistoryRedisManager.queueAddEntity
.getCall(0)
.calledWith(
this.project_id,
this.projectHistoryId,
'doc',
this.firstDocUpdate.id,
this.user_id,
firstDocUpdateWithVersion,
this.source
)
.should.equal(true)
this.ProjectHistoryRedisManager.queueAddEntity
.getCall(1)
.calledWith(
this.project_id,
this.projectHistoryId,
'doc',
this.secondDocUpdate.id,
this.user_id,
secondDocUpdateWithVersion,
this.source
)
.should.equal(true)
})
it('should add the files in the updates', function () {
const firstFileUpdateWithVersion = _.extend(
{},
this.firstFileUpdate,
{ version: `${this.version}.2` }
)
const secondFileUpdateWithVersion = _.extend(
{},
this.secondFileUpdate,
{ version: `${this.version}.3` }
)
this.ProjectHistoryRedisManager.queueAddEntity
.getCall(2)
.calledWith(
this.project_id,
this.projectHistoryId,
'file',
this.firstFileUpdate.id,
this.user_id,
firstFileUpdateWithVersion,
this.source
)
.should.equal(true)
this.ProjectHistoryRedisManager.queueAddEntity
.getCall(3)
.calledWith(
this.project_id,
this.projectHistoryId,
'file',
this.secondFileUpdate.id,
this.user_id,
secondFileUpdateWithVersion,
this.source
)
.should.equal(true)
})
it('should not flush the history', function () {
this.HistoryManager.flushProjectChangesAsync
.calledWith(this.project_id)
.should.equal(false)
})
it('should call the callback', function () {
this.callback.called.should.equal(true)
})
})
describe('when adding a doc fails', function () {
beforeEach(function () {
this.error = new Error('error')
this.ProjectHistoryRedisManager.queueAddEntity.yields(this.error)
this.ProjectManager.updateProjectWithLocks(
this.project_id,
this.projectHistoryId,
this.user_id,
this.updates,
this.version,
this.source,
this.callback
)
})
it('should call the callback with the error', function () {
this.callback.calledWith(this.error).should.equal(true)
})
})
describe('when adding a file fails', function () {
beforeEach(function () {
this.error = new Error('error')
this.ProjectHistoryRedisManager.queueAddEntity.yields(this.error)
this.ProjectManager.updateProjectWithLocks(
this.project_id,
this.projectHistoryId,
this.user_id,
this.updates,
this.version,
this.source,
this.callback
)
})
it('should call the callback with the error', function () {
this.callback.calledWith(this.error).should.equal(true)
})
})
describe('with enough ops to flush', function () {
beforeEach(function () {
this.HistoryManager.shouldFlushHistoryOps.returns(true)
this.ProjectManager.updateProjectWithLocks(
this.project_id,
this.projectHistoryId,
this.user_id,
this.updates,
this.version,
this.source,
this.callback
)
})
it('should flush the history', function () {
this.HistoryManager.flushProjectChangesAsync
.calledWith(this.project_id)
.should.equal(true)
})
})
})
describe('when given an unknown operation type', function () {
beforeEach(function () {
this.updates = [{ type: 'brew-coffee' }]
this.ProjectManager.updateProjectWithLocks(
this.project_id,
this.projectHistoryId,
this.user_id,
this.updates,
this.version,
this.source,
this.callback
)
})
it('should call back with an error', function () {
this.callback.calledWith(sinon.match.instanceOf(Error)).should.be.true
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/js/RateLimitManager.js'
const SandboxedModule = require('sandboxed-module')
describe('RateLimitManager', function () {
beforeEach(function () {
let Timer
this.RateLimitManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {}),
'./Metrics': (this.Metrics = {
Timer: (Timer = (function () {
Timer = class Timer {
static initClass() {
this.prototype.done = sinon.stub()
}
}
Timer.initClass()
return Timer
})()),
gauge: sinon.stub(),
}),
},
})
this.callback = sinon.stub()
return (this.RateLimiter = new this.RateLimitManager(1))
})
describe('for a single task', function () {
beforeEach(function () {
this.task = sinon.stub()
return this.RateLimiter.run(this.task, this.callback)
})
it('should execute the task in the background', function () {
return this.task.called.should.equal(true)
})
it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
return it('should finish with a worker count of one', function () {
// because it's in the background
return expect(this.RateLimiter.ActiveWorkerCount).to.equal(1)
})
})
describe('for multiple tasks', function () {
beforeEach(function (done) {
this.task = sinon.stub()
this.finalTask = sinon.stub()
const task = cb => {
this.task()
return setTimeout(cb, 100)
}
const finalTask = cb => {
this.finalTask()
return setTimeout(cb, 100)
}
this.RateLimiter.run(task, this.callback)
this.RateLimiter.run(task, this.callback)
this.RateLimiter.run(task, this.callback)
return this.RateLimiter.run(finalTask, err => {
this.callback(err)
return done()
})
})
it('should execute the first three tasks', function () {
return this.task.calledThrice.should.equal(true)
})
it('should execute the final task', function () {
return this.finalTask.called.should.equal(true)
})
it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
return it('should finish with worker count of zero', function () {
return expect(this.RateLimiter.ActiveWorkerCount).to.equal(0)
})
})
return describe('for a mixture of long-running tasks', function () {
beforeEach(function (done) {
this.task = sinon.stub()
this.finalTask = sinon.stub()
const finalTask = cb => {
this.finalTask()
return setTimeout(cb, 100)
}
this.RateLimiter.run(this.task, this.callback)
this.RateLimiter.run(this.task, this.callback)
this.RateLimiter.run(this.task, this.callback)
return this.RateLimiter.run(finalTask, err => {
this.callback(err)
return done()
})
})
it('should execute the first three tasks', function () {
return this.task.calledThrice.should.equal(true)
})
it('should execute the final task', function () {
return this.finalTask.called.should.equal(true)
})
it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
return it('should finish with worker count of three', function () {
return expect(this.RateLimiter.ActiveWorkerCount).to.equal(3)
})
})
})

View File

@@ -0,0 +1,167 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const modulePath = '../../../../app/js/RealTimeRedisManager.js'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/js/Errors')
describe('RealTimeRedisManager', function () {
beforeEach(function () {
this.rclient = {
auth() {},
exec: sinon.stub(),
}
this.rclient.multi = () => this.rclient
this.pubsubClient = { publish: sinon.stub() }
this.RealTimeRedisManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/redis-wrapper': {
createClient: config =>
config.name === 'pubsub' ? this.pubsubClient : this.rclient,
},
'@overleaf/settings': {
redis: {
documentupdater: (this.settings = {
key_schema: {
pendingUpdates({ doc_id: docId }) {
return `PendingUpdates:${docId}`
},
},
}),
pubsub: {
name: 'pubsub',
},
},
},
crypto: (this.crypto = {
randomBytes: sinon
.stub()
.withArgs(4)
.returns(Buffer.from([0x1, 0x2, 0x3, 0x4])),
}),
os: (this.os = { hostname: sinon.stub().returns('somehost') }),
'./Metrics': (this.metrics = {
summary: sinon.stub(),
histogram: sinon.stub(),
}),
},
})
this.doc_id = 'doc-id-123'
this.project_id = 'project-id-123'
return (this.callback = sinon.stub())
})
describe('getPendingUpdatesForDoc', function () {
beforeEach(function () {
this.rclient.llen = sinon.stub()
this.rclient.lrange = sinon.stub()
return (this.rclient.ltrim = sinon.stub())
})
describe('successfully', function () {
beforeEach(function () {
this.updates = [
{ op: [{ i: 'foo', p: 4 }] },
{ op: [{ i: 'foo', p: 4 }] },
]
this.jsonUpdates = this.updates.map(update => JSON.stringify(update))
this.rclient.exec = sinon.stub().yields(null, [2, this.jsonUpdates])
return this.RealTimeRedisManager.getPendingUpdatesForDoc(
this.doc_id,
this.callback
)
})
it('should get the pending updates', function () {
return this.rclient.lrange
.calledWith(`PendingUpdates:${this.doc_id}`, 0, 7)
.should.equal(true)
})
it('should delete the pending updates', function () {
return this.rclient.ltrim
.calledWith(`PendingUpdates:${this.doc_id}`, 8, -1)
.should.equal(true)
})
return it('should call the callback with the updates', function () {
return this.callback.calledWith(null, this.updates).should.equal(true)
})
})
return describe("when the JSON doesn't parse", function () {
beforeEach(function () {
this.jsonUpdates = [
JSON.stringify({ op: [{ i: 'foo', p: 4 }] }),
'broken json',
]
this.rclient.exec = sinon.stub().yields(null, [2, this.jsonUpdates])
return this.RealTimeRedisManager.getPendingUpdatesForDoc(
this.doc_id,
this.callback
)
})
return it('should return an error to the callback', function () {
return this.callback
.calledWith(sinon.match.has('name', 'SyntaxError'))
.should.equal(true)
})
})
})
describe('getUpdatesLength', function () {
beforeEach(function () {
this.rclient.llen = sinon.stub().yields(null, (this.length = 3))
return this.RealTimeRedisManager.getUpdatesLength(
this.doc_id,
this.callback
)
})
it('should look up the length', function () {
return this.rclient.llen
.calledWith(`PendingUpdates:${this.doc_id}`)
.should.equal(true)
})
return it('should return the length', function () {
return this.callback.calledWith(null, this.length).should.equal(true)
})
})
return describe('sendData', function () {
beforeEach(function () {
this.message_id = 'doc:somehost:01020304-0'
return this.RealTimeRedisManager.sendData({ op: 'thisop' })
})
it('should send the op with a message id', function () {
return this.pubsubClient.publish
.calledWith(
'applied-ops',
JSON.stringify({ op: 'thisop', _id: this.message_id })
)
.should.equal(true)
})
return it('should track the payload size', function () {
return this.metrics.summary
.calledWith(
'redis.publish.applied-ops',
JSON.stringify({ op: 'thisop', _id: this.message_id }).length
)
.should.equal(true)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,444 @@
/* eslint-disable
mocha/no-identical-title,
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS202: Simplify dynamic range loops
* DS205: Consider reworking code to avoid use of IIFEs
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const text = require('../../../../app/js/sharejs/types/text')
const RangesTracker = require('@overleaf/ranges-tracker')
describe('ShareJS text type', function () {
beforeEach(function () {
return (this.t = 'mock-thread-id')
})
describe('transform', function () {
describe('insert / insert', function () {
it('with an insert before', function () {
const dest = []
text._tc(dest, { i: 'foo', p: 9 }, { i: 'bar', p: 3 })
return dest.should.deep.equal([{ i: 'foo', p: 12 }])
})
it('with an insert after', function () {
const dest = []
text._tc(dest, { i: 'foo', p: 3 }, { i: 'bar', p: 9 })
return dest.should.deep.equal([{ i: 'foo', p: 3 }])
})
it("with an insert at the same place with side == 'right'", function () {
const dest = []
text._tc(dest, { i: 'foo', p: 3 }, { i: 'bar', p: 3 }, 'right')
return dest.should.deep.equal([{ i: 'foo', p: 6 }])
})
return it("with an insert at the same place with side == 'left'", function () {
const dest = []
text._tc(dest, { i: 'foo', p: 3 }, { i: 'bar', p: 3 }, 'left')
return dest.should.deep.equal([{ i: 'foo', p: 3 }])
})
})
describe('insert / delete', function () {
it('with a delete before', function () {
const dest = []
text._tc(dest, { i: 'foo', p: 9 }, { d: 'bar', p: 3 })
return dest.should.deep.equal([{ i: 'foo', p: 6 }])
})
it('with a delete after', function () {
const dest = []
text._tc(dest, { i: 'foo', p: 3 }, { d: 'bar', p: 9 })
return dest.should.deep.equal([{ i: 'foo', p: 3 }])
})
it("with a delete at the same place with side == 'right'", function () {
const dest = []
text._tc(dest, { i: 'foo', p: 3 }, { d: 'bar', p: 3 }, 'right')
return dest.should.deep.equal([{ i: 'foo', p: 3 }])
})
return it("with a delete at the same place with side == 'left'", function () {
const dest = []
text._tc(dest, { i: 'foo', p: 3 }, { d: 'bar', p: 3 }, 'left')
return dest.should.deep.equal([{ i: 'foo', p: 3 }])
})
})
describe('delete / insert', function () {
it('with an insert before', function () {
const dest = []
text._tc(dest, { d: 'foo', p: 9 }, { i: 'bar', p: 3 })
return dest.should.deep.equal([{ d: 'foo', p: 12 }])
})
it('with an insert after', function () {
const dest = []
text._tc(dest, { d: 'foo', p: 3 }, { i: 'bar', p: 9 })
return dest.should.deep.equal([{ d: 'foo', p: 3 }])
})
it("with an insert at the same place with side == 'right'", function () {
const dest = []
text._tc(dest, { d: 'foo', p: 3 }, { i: 'bar', p: 3 }, 'right')
return dest.should.deep.equal([{ d: 'foo', p: 6 }])
})
it("with an insert at the same place with side == 'left'", function () {
const dest = []
text._tc(dest, { d: 'foo', p: 3 }, { i: 'bar', p: 3 }, 'left')
return dest.should.deep.equal([{ d: 'foo', p: 6 }])
})
return it('with a delete that overlaps the insert location', function () {
const dest = []
text._tc(dest, { d: 'foo', p: 3 }, { i: 'bar', p: 4 })
return dest.should.deep.equal([
{ d: 'f', p: 3 },
{ d: 'oo', p: 6 },
])
})
})
describe('delete / delete', function () {
it('with a delete before', function () {
const dest = []
text._tc(dest, { d: 'foo', p: 9 }, { d: 'bar', p: 3 })
return dest.should.deep.equal([{ d: 'foo', p: 6 }])
})
it('with a delete after', function () {
const dest = []
text._tc(dest, { d: 'foo', p: 3 }, { d: 'bar', p: 9 })
return dest.should.deep.equal([{ d: 'foo', p: 3 }])
})
it('with deleting the same content', function () {
const dest = []
text._tc(dest, { d: 'foo', p: 3 }, { d: 'foo', p: 3 }, 'right')
return dest.should.deep.equal([])
})
it('with the delete overlapping before', function () {
const dest = []
text._tc(dest, { d: 'foobar', p: 3 }, { d: 'abcfoo', p: 0 }, 'right')
return dest.should.deep.equal([{ d: 'bar', p: 0 }])
})
it('with the delete overlapping after', function () {
const dest = []
text._tc(dest, { d: 'abcfoo', p: 3 }, { d: 'foobar', p: 6 })
return dest.should.deep.equal([{ d: 'abc', p: 3 }])
})
it('with the delete overlapping the whole delete', function () {
const dest = []
text._tc(dest, { d: 'abcfoo123', p: 3 }, { d: 'foo', p: 6 })
return dest.should.deep.equal([{ d: 'abc123', p: 3 }])
})
return it('with the delete inside the whole delete', function () {
const dest = []
text._tc(dest, { d: 'foo', p: 6 }, { d: 'abcfoo123', p: 3 })
return dest.should.deep.equal([])
})
})
describe('comment / insert', function () {
it('with an insert before', function () {
const dest = []
text._tc(dest, { c: 'foo', p: 9, t: this.t }, { i: 'bar', p: 3 })
return dest.should.deep.equal([{ c: 'foo', p: 12, t: this.t }])
})
it('with an insert after', function () {
const dest = []
text._tc(dest, { c: 'foo', p: 3, t: this.t }, { i: 'bar', p: 9 })
return dest.should.deep.equal([{ c: 'foo', p: 3, t: this.t }])
})
it('with an insert at the left edge', function () {
const dest = []
text._tc(dest, { c: 'foo', p: 3, t: this.t }, { i: 'bar', p: 3 })
// RangesTracker doesn't inject inserts into comments on edges, so neither should we
return dest.should.deep.equal([{ c: 'foo', p: 6, t: this.t }])
})
it('with an insert at the right edge', function () {
const dest = []
text._tc(dest, { c: 'foo', p: 3, t: this.t }, { i: 'bar', p: 6 })
// RangesTracker doesn't inject inserts into comments on edges, so neither should we
return dest.should.deep.equal([{ c: 'foo', p: 3, t: this.t }])
})
return it('with an insert in the middle', function () {
const dest = []
text._tc(dest, { c: 'foo', p: 3, t: this.t }, { i: 'bar', p: 5 })
return dest.should.deep.equal([{ c: 'fobaro', p: 3, t: this.t }])
})
})
describe('comment / delete', function () {
it('with a delete before', function () {
const dest = []
text._tc(dest, { c: 'foo', p: 9, t: this.t }, { d: 'bar', p: 3 })
return dest.should.deep.equal([{ c: 'foo', p: 6, t: this.t }])
})
it('with a delete after', function () {
const dest = []
text._tc(dest, { c: 'foo', p: 3, t: this.t }, { i: 'bar', p: 9 })
return dest.should.deep.equal([{ c: 'foo', p: 3, t: this.t }])
})
it('with a delete overlapping the comment content before', function () {
const dest = []
text._tc(dest, { c: 'foobar', p: 6, t: this.t }, { d: '123foo', p: 3 })
return dest.should.deep.equal([{ c: 'bar', p: 3, t: this.t }])
})
it('with a delete overlapping the comment content after', function () {
const dest = []
text._tc(dest, { c: 'foobar', p: 6, t: this.t }, { d: 'bar123', p: 9 })
return dest.should.deep.equal([{ c: 'foo', p: 6, t: this.t }])
})
it('with a delete overlapping the comment content in the middle', function () {
const dest = []
text._tc(dest, { c: 'foo123bar', p: 6, t: this.t }, { d: '123', p: 9 })
return dest.should.deep.equal([{ c: 'foobar', p: 6, t: this.t }])
})
return it('with a delete overlapping the whole comment', function () {
const dest = []
text._tc(dest, { c: 'foo', p: 6, t: this.t }, { d: '123foo456', p: 3 })
return dest.should.deep.equal([{ c: '', p: 3, t: this.t }])
})
})
describe('comment / insert', function () {
return it('should not do anything', function () {
const dest = []
text._tc(dest, { i: 'foo', p: 6 }, { c: 'bar', p: 3 })
return dest.should.deep.equal([{ i: 'foo', p: 6 }])
})
})
describe('comment / delete', function () {
return it('should not do anything', function () {
const dest = []
text._tc(dest, { d: 'foo', p: 6 }, { c: 'bar', p: 3 })
return dest.should.deep.equal([{ d: 'foo', p: 6 }])
})
})
return describe('comment / comment', function () {
return it('should not do anything', function () {
const dest = []
text._tc(dest, { c: 'foo', p: 6 }, { c: 'bar', p: 3 })
return dest.should.deep.equal([{ c: 'foo', p: 6 }])
})
})
})
describe('apply', function () {
it('should apply an insert', function () {
return text.apply('foo', [{ i: 'bar', p: 2 }]).should.equal('fobaro')
})
it('should apply a delete', function () {
return text
.apply('foo123bar', [{ d: '123', p: 3 }])
.should.equal('foobar')
})
it('should do nothing with a comment', function () {
return text
.apply('foo123bar', [{ c: '123', p: 3 }])
.should.equal('foo123bar')
})
it('should throw an error when deleted content does not match', function () {
return (() => text.apply('foo123bar', [{ d: '456', p: 3 }])).should.throw(
Error
)
})
return it('should throw an error when comment content does not match', function () {
return (() => text.apply('foo123bar', [{ c: '456', p: 3 }])).should.throw(
Error
)
})
})
return describe('applying ops and comments in different orders', function () {
return it('should not matter which op or comment is applied first', function () {
let length, p
let asc, end
let asc1, end1
let asc3, end3
const transform = function (op1, op2, side) {
const d = []
text._tc(d, op1, op2, side)
return d
}
const applySnapshot = (snapshot, op) => text.apply(snapshot, op)
const applyRanges = function (rangesTracker, ops) {
for (const op of Array.from(ops)) {
rangesTracker.applyOp(op, {})
}
return rangesTracker
}
const commentsEqual = function (comments1, comments2) {
if (comments1.length !== comments2.length) {
return false
}
comments1.sort((a, b) => {
if (a.offset - b.offset === 0) {
return a.length - b.length
} else {
return a.offset - b.offset
}
})
comments2.sort((a, b) => {
if (a.offset - b.offset === 0) {
return a.length - b.length
} else {
return a.offset - b.offset
}
})
for (let i = 0; i < comments1.length; i++) {
const comment1 = comments1[i]
const comment2 = comments2[i]
if (
comment1.offset !== comment2.offset ||
comment1.length !== comment2.length
) {
return false
}
}
return true
}
const SNAPSHOT = '123'
const OPS = []
// Insert ops
for (
p = 0, end = SNAPSHOT.length, asc = end >= 0;
asc ? p <= end : p >= end;
asc ? p++ : p--
) {
OPS.push({ i: 'a', p })
OPS.push({ i: 'bc', p })
}
for (
p = 0, end1 = SNAPSHOT.length - 1, asc1 = end1 >= 0;
asc1 ? p <= end1 : p >= end1;
asc1 ? p++ : p--
) {
let asc2, end2
for (
length = 1, end2 = SNAPSHOT.length - p, asc2 = end2 >= 1;
asc2 ? length <= end2 : length >= end2;
asc2 ? length++ : length--
) {
OPS.push({ d: SNAPSHOT.slice(p, p + length), p })
}
}
for (
p = 0, end3 = SNAPSHOT.length - 1, asc3 = end3 >= 0;
asc3 ? p <= end3 : p >= end3;
asc3 ? p++ : p--
) {
let asc4, end4
for (
length = 1, end4 = SNAPSHOT.length - p, asc4 = end4 >= 1;
asc4 ? length <= end4 : length >= end4;
asc4 ? length++ : length--
) {
OPS.push({ c: SNAPSHOT.slice(p, p + length), p, t: this.t })
}
}
return (() => {
const result = []
for (const op1 of Array.from(OPS)) {
result.push(
(() => {
const result1 = []
for (const op2 of Array.from(OPS)) {
const op1T = transform(op1, op2, 'left')
const op2T = transform(op2, op1, 'right')
const rt12 = new RangesTracker()
const snapshot12 = applySnapshot(
applySnapshot(SNAPSHOT, [op1]),
op2T
)
applyRanges(rt12, [op1])
applyRanges(rt12, op2T)
const rt21 = new RangesTracker()
const snapshot21 = applySnapshot(
applySnapshot(SNAPSHOT, [op2]),
op1T
)
applyRanges(rt21, [op2])
applyRanges(rt21, op1T)
if (snapshot12 !== snapshot21) {
console.error(
{
op1,
op2,
op1T,
op2T,
snapshot12,
snapshot21,
},
'Ops are not consistent'
)
throw new Error('OT is inconsistent')
}
if (!commentsEqual(rt12.comments, rt21.comments)) {
console.log(rt12.comments)
console.log(rt21.comments)
console.error(
{
op1,
op2,
op1T,
op2T,
rt12_comments: rt12.comments,
rt21_comments: rt21.comments,
},
'Comments are not consistent'
)
throw new Error('OT is inconsistent')
} else {
result1.push(undefined)
}
}
return result1
})()
)
}
return result
})()
})
})
})

View File

@@ -0,0 +1,181 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const { expect } = require('chai')
const modulePath = '../../../../app/js/ShareJsDB.js'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/js/Errors')
describe('ShareJsDB', function () {
beforeEach(function () {
this.doc_id = 'document-id'
this.project_id = 'project-id'
this.doc_key = `${this.project_id}:${this.doc_id}`
this.callback = sinon.stub()
this.ShareJsDB = SandboxedModule.require(modulePath, {
requires: {
'./RedisManager': (this.RedisManager = {
getPreviousDocOps: sinon.stub(),
}),
'./Errors': Errors,
'@overleaf/metrics': {
inc: sinon.stub(),
histogram: sinon.stub(),
Timer: class Timer {
done() {}
},
},
},
})
this.version = 42
this.lines = ['one', 'two', 'three']
return (this.db = new this.ShareJsDB(
this.project_id,
this.doc_id,
this.lines,
this.version
))
})
describe('getSnapshot', function () {
describe('successfully', function () {
beforeEach(function () {
return this.db.getSnapshot(this.doc_key, this.callback)
})
it('should return the doc lines', function () {
return this.callback.args[0][1].snapshot.should.equal(
this.lines.join('\n')
)
})
it('should return the doc version', function () {
return this.callback.args[0][1].v.should.equal(this.version)
})
return it('should return the type as text', function () {
return this.callback.args[0][1].type.should.equal('text')
})
})
return describe('when the key does not match', function () {
beforeEach(function () {
return this.db.getSnapshot('bad:key', this.callback)
})
return it('should return the callback with a NotFoundError', function () {
return this.callback
.calledWith(sinon.match.instanceOf(Errors.NotFoundError))
.should.equal(true)
})
})
})
describe('getOps', function () {
describe('with start == end', function () {
beforeEach(function () {
this.start = this.end = 42
return this.db.getOps(this.doc_key, this.start, this.end, this.callback)
})
it('should not talk to redis', function () {
this.RedisManager.getPreviousDocOps.should.not.have.been.called
})
return it('should return an empty array', function () {
return this.callback.calledWith(null, []).should.equal(true)
})
})
describe('with start == redis-version and end unset', function () {
beforeEach(function () {
const start = this.version
const end = null
this.db.getOps(this.doc_key, start, end, this.callback)
})
it('should not talk to redis', function () {
this.RedisManager.getPreviousDocOps.should.not.have.been.called
})
it('should return an empty array', function () {
this.callback.should.have.been.calledWith(null, [])
})
})
describe('with a non empty range', function () {
beforeEach(function () {
this.start = 35
this.end = 42
this.ops = new Array(this.end - this.start)
this.RedisManager.getPreviousDocOps = sinon
.stub()
.callsArgWith(3, null, this.ops)
return this.db.getOps(this.doc_key, this.start, this.end, this.callback)
})
it('should get the range from redis', function () {
return this.RedisManager.getPreviousDocOps
.calledWith(this.doc_id, this.start, this.end - 1)
.should.equal(true)
})
return it('should return the ops', function () {
return this.callback.calledWith(null, this.ops).should.equal(true)
})
})
return describe('with no specified end', function () {
beforeEach(function () {
this.start = 35
this.end = null
this.ops = []
this.RedisManager.getPreviousDocOps = sinon
.stub()
.callsArgWith(3, null, this.ops)
return this.db.getOps(this.doc_key, this.start, this.end, this.callback)
})
return it('should get until the end of the list', function () {
return this.RedisManager.getPreviousDocOps
.calledWith(this.doc_id, this.start, -1)
.should.equal(true)
})
})
})
return describe('writeOps', function () {
return describe('writing an op', function () {
beforeEach(function () {
this.opData = {
op: { p: 20, t: 'foo' },
meta: { source: 'bar' },
v: this.version,
}
return this.db.writeOp(this.doc_key, this.opData, this.callback)
})
it('should write into appliedOps', function () {
return expect(this.db.appliedOps[this.doc_key]).to.deep.equal([
this.opData,
])
})
return it('should call the callback without an error', function () {
this.callback.called.should.equal(true)
return (this.callback.args[0][0] != null).should.equal(false)
})
})
})
})

View File

@@ -0,0 +1,237 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const modulePath = '../../../../app/js/ShareJsUpdateManager.js'
const SandboxedModule = require('sandboxed-module')
const crypto = require('node:crypto')
describe('ShareJsUpdateManager', function () {
beforeEach(function () {
let Model
this.project_id = 'project-id-123'
this.doc_id = 'document-id-123'
this.callback = sinon.stub()
return (this.ShareJsUpdateManager = SandboxedModule.require(modulePath, {
requires: {
'./sharejs/server/model': (Model = class Model {
constructor(db) {
this.db = db
}
}),
'./ShareJsDB': (this.ShareJsDB = { mockDB: true }),
'@overleaf/redis-wrapper': {
createClient: () => {
return (this.rclient = { auth() {} })
},
},
'./RealTimeRedisManager': (this.RealTimeRedisManager = {
sendCanaryAppliedOp: sinon.stub(),
}),
'./Metrics': (this.metrics = { inc: sinon.stub() }),
},
globals: {
clearTimeout: (this.clearTimeout = sinon.stub()),
},
}))
})
describe('applyUpdate', function () {
beforeEach(function () {
this.lines = ['one', 'two']
this.version = 34
this.updatedDocLines = ['onefoo', 'two']
const content = this.updatedDocLines.join('\n')
this.hash = crypto
.createHash('sha1')
.update('blob ' + content.length + '\x00')
.update(content, 'utf8')
.digest('hex')
this.update = { p: 4, t: 'foo', v: this.version, hash: this.hash }
this.model = {
applyOp: sinon.stub().callsArg(2),
getSnapshot: sinon.stub(),
db: {
appliedOps: {},
},
}
this.ShareJsUpdateManager.getNewShareJsModel = sinon
.stub()
.returns(this.model)
this.ShareJsUpdateManager._listenForOps = sinon.stub()
return (this.ShareJsUpdateManager.removeDocFromCache = sinon
.stub()
.callsArg(1))
})
describe('successfully', function () {
beforeEach(function (done) {
this.model.getSnapshot.callsArgWith(1, null, {
snapshot: this.updatedDocLines.join('\n'),
v: this.version,
})
this.model.db.appliedOps[`${this.project_id}:${this.doc_id}`] =
this.appliedOps = ['mock-ops']
return this.ShareJsUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
this.update,
this.lines,
this.version,
(err, docLines, version, appliedOps) => {
this.callback(err, docLines, version, appliedOps)
return done()
}
)
})
it('should create a new ShareJs model', function () {
return this.ShareJsUpdateManager.getNewShareJsModel
.calledWith(this.project_id, this.doc_id, this.lines, this.version)
.should.equal(true)
})
it('should listen for ops on the model', function () {
return this.ShareJsUpdateManager._listenForOps
.calledWith(this.model)
.should.equal(true)
})
it('should send the update to ShareJs', function () {
return this.model.applyOp
.calledWith(`${this.project_id}:${this.doc_id}`, this.update)
.should.equal(true)
})
it('should get the updated doc lines', function () {
return this.model.getSnapshot
.calledWith(`${this.project_id}:${this.doc_id}`)
.should.equal(true)
})
return it('should return the updated doc lines, version and ops', function () {
return this.callback
.calledWith(null, this.updatedDocLines, this.version, this.appliedOps)
.should.equal(true)
})
})
describe('when applyOp fails', function () {
beforeEach(function (done) {
this.error = new Error('Something went wrong')
this.model.applyOp = sinon.stub().callsArgWith(2, this.error)
return this.ShareJsUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
this.update,
this.lines,
this.version,
(err, docLines, version) => {
this.callback(err, docLines, version)
return done()
}
)
})
return it('should call the callback with the error', function () {
return this.callback.calledWith(this.error).should.equal(true)
})
})
describe('when getSnapshot fails', function () {
beforeEach(function (done) {
this.error = new Error('Something went wrong')
this.model.getSnapshot.callsArgWith(1, this.error)
return this.ShareJsUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
this.update,
this.lines,
this.version,
(err, docLines, version) => {
this.callback(err, docLines, version)
return done()
}
)
})
return it('should call the callback with the error', function () {
return this.callback.calledWith(this.error).should.equal(true)
})
})
return describe('with an invalid hash', function () {
beforeEach(function (done) {
this.error = new Error('invalid hash')
this.model.getSnapshot.callsArgWith(1, null, {
snapshot: 'unexpected content',
v: this.version,
})
this.model.db.appliedOps[`${this.project_id}:${this.doc_id}`] =
this.appliedOps = ['mock-ops']
return this.ShareJsUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
this.update,
this.lines,
this.version,
(err, docLines, version, appliedOps) => {
this.callback(err, docLines, version, appliedOps)
return done()
}
)
})
return it('should call the callback with the error', function () {
return this.callback
.calledWith(sinon.match.instanceOf(Error))
.should.equal(true)
})
})
})
return describe('_listenForOps', function () {
beforeEach(function () {
this.model = {
on: (event, callback) => {
return (this.callback = callback)
},
}
sinon.spy(this.model, 'on')
return this.ShareJsUpdateManager._listenForOps(this.model)
})
it('should listen to the model for updates', function () {
return this.model.on.calledWith('applyOp').should.equal(true)
})
return describe('the callback', function () {
beforeEach(function () {
this.opData = {
op: { t: 'foo', p: 1 },
meta: { source: 'bar' },
}
this.RealTimeRedisManager.sendData = sinon.stub()
return this.callback(`${this.project_id}:${this.doc_id}`, this.opData)
})
return it('should publish the op to redis', function () {
return this.RealTimeRedisManager.sendData
.calledWith({
project_id: this.project_id,
doc_id: this.doc_id,
op: this.opData,
})
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,836 @@
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.lockValue = 'mock-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.LockManager = {
promises: {
tryLock: sinon.stub().resolves(this.lockValue),
getLock: sinon.stub().resolves(this.lockValue),
releaseLock: sinon.stub().resolves(),
},
}
this.RedisManager = {
promises: {
setDocument: sinon.stub().resolves(),
updateDocument: sinon.stub(),
},
}
this.RealTimeRedisManager = {
sendData: sinon.stub(),
promises: {
getUpdatesLength: sinon.stub(),
getPendingUpdatesForDoc: sinon.stub(),
},
}
this.ShareJsUpdateManager = {
promises: {
applyUpdate: sinon.stub(),
},
}
this.HistoryManager = {
recordAndFlushHistoryOps: sinon.stub(),
}
this.Settings = {}
this.DocumentManager = {
promises: {
getDoc: sinon.stub(),
},
}
this.RangesManager = {
applyUpdate: sinon.stub(),
}
this.SnapshotManager = {
promises: {
recordSnapshot: sinon.stub().resolves(),
},
}
this.ProjectHistoryRedisManager = {
promises: {
queueOps: sinon
.stub()
.callsFake(async (projectId, ...ops) => ops.length),
},
}
this.UpdateManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'./LockManager': this.LockManager,
'./RedisManager': this.RedisManager,
'./RealTimeRedisManager': this.RealTimeRedisManager,
'./ShareJsUpdateManager': this.ShareJsUpdateManager,
'./HistoryManager': this.HistoryManager,
'./Metrics': this.Metrics,
'@overleaf/settings': this.Settings,
'./DocumentManager': this.DocumentManager,
'./RangesManager': this.RangesManager,
'./SnapshotManager': this.SnapshotManager,
'./Profiler': this.Profiler,
'./ProjectHistoryRedisManager': this.ProjectHistoryRedisManager,
},
})
})
describe('processOutstandingUpdates', function () {
beforeEach(async function () {
this.UpdateManager.promises.fetchAndApplyUpdates = sinon.stub().resolves()
await this.UpdateManager.promises.processOutstandingUpdates(
this.project_id,
this.doc_id
)
})
it('should apply the updates', function () {
this.UpdateManager.promises.fetchAndApplyUpdates
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should time the execution', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('processOutstandingUpdatesWithLock', function () {
describe('when the lock is free', function () {
beforeEach(function () {
this.UpdateManager.promises.continueProcessingUpdatesWithLock = sinon
.stub()
.resolves()
this.UpdateManager.promises.processOutstandingUpdates = sinon
.stub()
.resolves()
})
describe('successfully', function () {
beforeEach(async function () {
await this.UpdateManager.promises.processOutstandingUpdatesWithLock(
this.project_id,
this.doc_id
)
})
it('should acquire the lock', function () {
this.LockManager.promises.tryLock
.calledWith(this.doc_id)
.should.equal(true)
})
it('should free the lock', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
})
it('should process the outstanding updates', function () {
this.UpdateManager.promises.processOutstandingUpdates
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should do everything with the lock acquired', function () {
this.UpdateManager.promises.processOutstandingUpdates
.calledAfter(this.LockManager.promises.tryLock)
.should.equal(true)
this.UpdateManager.promises.processOutstandingUpdates
.calledBefore(this.LockManager.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, this.doc_id)
.should.equal(true)
})
})
describe('when processOutstandingUpdates returns an error', function () {
beforeEach(async function () {
this.error = new Error('Something went wrong')
this.UpdateManager.promises.processOutstandingUpdates = sinon
.stub()
.rejects(this.error)
await expect(
this.UpdateManager.promises.processOutstandingUpdatesWithLock(
this.project_id,
this.doc_id
)
).to.be.rejectedWith(this.error)
})
it('should free the lock', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
})
})
})
describe('when the lock is taken', function () {
beforeEach(async function () {
this.LockManager.promises.tryLock.resolves(null)
this.UpdateManager.promises.processOutstandingUpdates = sinon
.stub()
.resolves()
await this.UpdateManager.promises.processOutstandingUpdatesWithLock(
this.project_id,
this.doc_id
)
})
it('should not process the updates', function () {
this.UpdateManager.promises.processOutstandingUpdates.called.should.equal(
false
)
})
})
})
describe('continueProcessingUpdatesWithLock', function () {
describe('when there are outstanding updates', function () {
beforeEach(async function () {
this.RealTimeRedisManager.promises.getUpdatesLength.resolves(3)
this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
.stub()
.resolves()
await this.UpdateManager.promises.continueProcessingUpdatesWithLock(
this.project_id,
this.doc_id
)
})
it('should process the outstanding updates', function () {
this.UpdateManager.promises.processOutstandingUpdatesWithLock
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
})
describe('when there are no outstanding updates', function () {
beforeEach(async function () {
this.RealTimeRedisManager.promises.getUpdatesLength.resolves(0)
this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
.stub()
.resolves()
await this.UpdateManager.promises.continueProcessingUpdatesWithLock(
this.project_id,
this.doc_id
)
})
it('should not try to process the outstanding updates', function () {
this.UpdateManager.promises.processOutstandingUpdatesWithLock.called.should.equal(
false
)
})
})
})
describe('fetchAndApplyUpdates', function () {
describe('with updates', function () {
beforeEach(async function () {
this.updates = [{ p: 1, t: 'foo' }]
this.updatedDocLines = ['updated', 'lines']
this.version = 34
this.RealTimeRedisManager.promises.getPendingUpdatesForDoc.resolves(
this.updates
)
this.UpdateManager.promises.applyUpdate = sinon.stub().resolves()
await this.UpdateManager.promises.fetchAndApplyUpdates(
this.project_id,
this.doc_id
)
})
it('should get the pending updates', function () {
this.RealTimeRedisManager.promises.getPendingUpdatesForDoc
.calledWith(this.doc_id)
.should.equal(true)
})
it('should apply the updates', function () {
this.updates.map(update =>
this.UpdateManager.promises.applyUpdate
.calledWith(this.project_id, this.doc_id, update)
.should.equal(true)
)
})
})
describe('when there are no updates', function () {
beforeEach(async function () {
this.updates = []
this.RealTimeRedisManager.promises.getPendingUpdatesForDoc.resolves(
this.updates
)
this.UpdateManager.promises.applyUpdate = sinon.stub().resolves()
await this.UpdateManager.promises.fetchAndApplyUpdates(
this.project_id,
this.doc_id
)
})
it('should not call applyUpdate', function () {
this.UpdateManager.promises.applyUpdate.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,
})
this.RangesManager.applyUpdate.returns({
newRanges: this.updated_ranges,
rangesWereCollapsed: false,
historyUpdates: this.historyUpdates,
})
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
)
})
})
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,
})
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,
})
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('_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.processOutstandingUpdates = sinon
.stub()
.resolves()
this.response = await this.UpdateManager.promises.lockUpdatesAndDo(
this.method,
this.project_id,
this.doc_id,
this.arg1
)
})
it('should lock the doc', function () {
this.LockManager.promises.getLock
.calledWith(this.doc_id)
.should.equal(true)
})
it('should process any outstanding updates', function () {
this.UpdateManager.promises.processOutstandingUpdates.should.have.been.calledWith(
this.project_id,
this.doc_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 lock', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
})
it('should continue processing updates', function () {
this.UpdateManager.promises.continueProcessingUpdatesWithLock
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
})
describe('when processOutstandingUpdates returns an error', function () {
beforeEach(async function () {
this.error = new Error('Something went wrong')
this.UpdateManager.promises.processOutstandingUpdates = 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 lock', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
})
})
describe('when the method returns an error', function () {
beforeEach(async function () {
this.error = new Error('something went wrong')
this.UpdateManager.promises.processOutstandingUpdates = 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 lock', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
})
})
})
})
function stringHash(s) {
const hash = createHash('sha1')
hash.update(s)
return hash.digest('hex')
}

View File

@@ -0,0 +1,54 @@
// @ts-check
const { createHash } = require('node:crypto')
const { expect } = require('chai')
const Utils = require('../../../app/js/Utils')
describe('Utils', function () {
describe('addTrackedDeletesToContent', function () {
it("doesn't modify text without tracked deletes", function () {
const content = 'the quick brown fox'
const trackedChanges = []
const result = Utils.addTrackedDeletesToContent(content, trackedChanges)
expect(result).to.equal(content)
})
it('adds tracked deletes to text but skips tracked inserts', function () {
const content = 'the brown fox jumps over the dog'
const metadata = { user_id: 'user1', ts: new Date().toString() }
const trackedChanges = [
{ id: 'tc1', op: { d: 'quick ', p: 4 }, metadata },
{ id: 'tc2', op: { i: 'brown ', p: 5 }, metadata },
{ id: 'tc3', op: { d: 'lazy ', p: 29 }, metadata },
]
const result = Utils.addTrackedDeletesToContent(content, trackedChanges)
expect(result).to.equal('the quick brown fox jumps over the lazy dog')
})
})
describe('computeDocHash', function () {
it('computes the hash for an empty doc', function () {
const actual = Utils.computeDocHash([])
const expected = stringHash('')
expect(actual).to.equal(expected)
})
it('computes the hash for a single-line doc', function () {
const actual = Utils.computeDocHash(['hello'])
const expected = stringHash('hello')
expect(actual).to.equal(expected)
})
it('computes the hash for a multiline doc', function () {
const actual = Utils.computeDocHash(['hello', 'there', 'world'])
const expected = stringHash('hello\nthere\nworld')
expect(actual).to.equal(expected)
})
})
})
function stringHash(s) {
const hash = createHash('sha1')
hash.update(s)
return hash.digest('hex')
}