first commit
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const async = require('async')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const rclientProjectHistory = require('@overleaf/redis-wrapper').createClient(
|
||||
Settings.redis.project_history
|
||||
)
|
||||
const rclientDU = require('@overleaf/redis-wrapper').createClient(
|
||||
Settings.redis.documentupdater
|
||||
)
|
||||
const Keys = Settings.redis.documentupdater.key_schema
|
||||
const ProjectHistoryKeys = Settings.redis.project_history.key_schema
|
||||
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('Applying updates to a doc', function () {
|
||||
before(function (done) {
|
||||
this.lines = ['one', 'two', 'three']
|
||||
this.version = 42
|
||||
this.op = {
|
||||
i: 'one and a half\n',
|
||||
p: 4,
|
||||
}
|
||||
this.update = {
|
||||
doc: this.doc_id,
|
||||
op: [this.op],
|
||||
v: this.version,
|
||||
}
|
||||
this.result = ['one', 'one and a half', 'two', 'three']
|
||||
DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
describe('when the document is not loaded', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
this.startTime = Date.now()
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should load the document from the web API', function () {
|
||||
MockWebApi.getDocument
|
||||
.calledWith(this.project_id, this.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should update the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) done(error)
|
||||
doc.lines.should.deep.equal(this.result)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the applied updates to the project history changes api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
JSON.parse(updates[0]).op.should.deep.equal([this.op])
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should set the first op timestamp', function (done) {
|
||||
rclientProjectHistory.get(
|
||||
ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
|
||||
project_id: this.project_id,
|
||||
}),
|
||||
(error, result) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
result = parseInt(result, 10)
|
||||
result.should.be.within(this.startTime, Date.now())
|
||||
this.firstOpTimestamp = result
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should yield last updated time', function (done) {
|
||||
DocUpdaterClient.getProjectLastUpdatedAt(
|
||||
this.project_id,
|
||||
(error, res, body) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
res.statusCode.should.equal(200)
|
||||
body.lastUpdatedAt.should.be.within(this.startTime, Date.now())
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should yield no last updated time for another project', function (done) {
|
||||
DocUpdaterClient.getProjectLastUpdatedAt(
|
||||
DocUpdaterClient.randomId(),
|
||||
(error, res, body) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
res.statusCode.should.equal(200)
|
||||
body.should.deep.equal({})
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('when sending another update', function () {
|
||||
before(function (done) {
|
||||
this.timeout(10000)
|
||||
this.second_update = Object.assign({}, this.update)
|
||||
this.second_update.v = this.version + 1
|
||||
this.secondStartTime = Date.now()
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.second_update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should update the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) done(error)
|
||||
doc.lines.should.deep.equal([
|
||||
'one',
|
||||
'one and a half',
|
||||
'one and a half',
|
||||
'two',
|
||||
'three',
|
||||
])
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not change the first op timestamp', function (done) {
|
||||
rclientProjectHistory.get(
|
||||
ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
|
||||
project_id: this.project_id,
|
||||
}),
|
||||
(error, result) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
result = parseInt(result, 10)
|
||||
result.should.equal(this.firstOpTimestamp)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should yield last updated time', function (done) {
|
||||
DocUpdaterClient.getProjectLastUpdatedAt(
|
||||
this.project_id,
|
||||
(error, res, body) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
res.statusCode.should.equal(200)
|
||||
body.lastUpdatedAt.should.be.within(
|
||||
this.secondStartTime,
|
||||
Date.now()
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document is loaded', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should not need to call the web api', function () {
|
||||
MockWebApi.getDocument.called.should.equal(false)
|
||||
})
|
||||
|
||||
it('should update the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
doc.lines.should.deep.equal(this.result)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the applied updates to the project history changes api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) return done(error)
|
||||
JSON.parse(updates[0]).op.should.deep.equal([this.op])
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document is loaded and is using project-history only', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should update the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
doc.lines.should.deep.equal(this.result)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the applied updates to the project history changes api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) return done(error)
|
||||
JSON.parse(updates[0]).op.should.deep.equal([this.op])
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document has been deleted', function () {
|
||||
describe('when the ops come in a single linear order', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
const lines = ['', '', '']
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines,
|
||||
version: 0,
|
||||
})
|
||||
this.updates = [
|
||||
{ doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
|
||||
{ doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
|
||||
{ doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
|
||||
{ doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
|
||||
{ doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
|
||||
{ doc_id: this.doc_id, v: 5, op: [{ i: ' ', p: 5 }] },
|
||||
{ doc_id: this.doc_id, v: 6, op: [{ i: 'w', p: 6 }] },
|
||||
{ doc_id: this.doc_id, v: 7, op: [{ i: 'o', p: 7 }] },
|
||||
{ doc_id: this.doc_id, v: 8, op: [{ i: 'r', p: 8 }] },
|
||||
{ doc_id: this.doc_id, v: 9, op: [{ i: 'l', p: 9 }] },
|
||||
{ doc_id: this.doc_id, v: 10, op: [{ i: 'd', p: 10 }] },
|
||||
]
|
||||
this.my_result = ['hello world', '', '']
|
||||
done()
|
||||
})
|
||||
|
||||
it('should be able to continue applying updates when the project has been deleted', function (done) {
|
||||
let update
|
||||
const actions = []
|
||||
for (update of this.updates.slice(0, 6)) {
|
||||
;(update => {
|
||||
actions.push(callback =>
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
)
|
||||
})(update)
|
||||
}
|
||||
actions.push(callback =>
|
||||
DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback)
|
||||
)
|
||||
for (update of this.updates.slice(6)) {
|
||||
;(update => {
|
||||
actions.push(callback =>
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
)
|
||||
})(update)
|
||||
}
|
||||
|
||||
async.series(actions, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
doc.lines.should.deep.equal(this.my_result)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should store the doc ops in the correct order', function (done) {
|
||||
rclientDU.lrange(
|
||||
Keys.docOps({ doc_id: this.doc_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) return done(error)
|
||||
updates = updates.map(u => JSON.parse(u))
|
||||
for (let i = 0; i < this.updates.length; i++) {
|
||||
const appliedUpdate = this.updates[i]
|
||||
appliedUpdate.op.should.deep.equal(updates[i].op)
|
||||
}
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when older ops come in after the delete', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
const lines = ['', '', '']
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines,
|
||||
version: 0,
|
||||
})
|
||||
this.updates = [
|
||||
{ doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
|
||||
{ doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
|
||||
{ doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
|
||||
{ doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
|
||||
{ doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
|
||||
{ doc_id: this.doc_id, v: 0, op: [{ i: 'world', p: 1 }] },
|
||||
]
|
||||
this.my_result = ['hello', 'world', '']
|
||||
done()
|
||||
})
|
||||
|
||||
it('should be able to continue applying updates when the project has been deleted', function (done) {
|
||||
let update
|
||||
const actions = []
|
||||
for (update of this.updates.slice(0, 5)) {
|
||||
;(update => {
|
||||
actions.push(callback =>
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
)
|
||||
})(update)
|
||||
}
|
||||
actions.push(callback =>
|
||||
DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback)
|
||||
)
|
||||
for (update of this.updates.slice(5)) {
|
||||
;(update => {
|
||||
actions.push(callback =>
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
)
|
||||
})(update)
|
||||
}
|
||||
|
||||
async.series(actions, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
doc.lines.should.deep.equal(this.my_result)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a broken update', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
this.broken_update = {
|
||||
doc_id: this.doc_id,
|
||||
v: this.version,
|
||||
op: [{ d: 'not the correct content', p: 0 }],
|
||||
}
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
|
||||
DocUpdaterClient.subscribeToAppliedOps(
|
||||
(this.messageCallback = sinon.stub())
|
||||
)
|
||||
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.broken_update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not update the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
doc.lines.should.deep.equal(this.lines)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should send a message with an error', function () {
|
||||
this.messageCallback.called.should.equal(true)
|
||||
const [channel, message] = this.messageCallback.args[0]
|
||||
channel.should.equal('applied-ops')
|
||||
JSON.parse(message).should.deep.include({
|
||||
project_id: this.project_id,
|
||||
doc_id: this.doc_id,
|
||||
error: 'Delete component does not match',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is no version in Mongo', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
})
|
||||
|
||||
const update = {
|
||||
doc: this.doc_id,
|
||||
op: this.update.op,
|
||||
v: 0,
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should update the doc (using version = 0)', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
doc.lines.should.deep.equal(this.result)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the sending duplicate ops', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
|
||||
DocUpdaterClient.subscribeToAppliedOps(
|
||||
(this.messageCallback = sinon.stub())
|
||||
)
|
||||
|
||||
// One user delete 'one', the next turns it into 'once'. The second becomes a NOP.
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
{
|
||||
doc: this.doc_id,
|
||||
op: [
|
||||
{
|
||||
i: 'one and a half\n',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
v: this.version,
|
||||
meta: {
|
||||
source: 'ikHceq3yfAdQYzBo4-xZ',
|
||||
},
|
||||
},
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
{
|
||||
doc: this.doc_id,
|
||||
op: [
|
||||
{
|
||||
i: 'one and a half\n',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
v: this.version,
|
||||
dupIfSource: ['ikHceq3yfAdQYzBo4-xZ'],
|
||||
meta: {
|
||||
source: 'ikHceq3yfAdQYzBo4-xZ',
|
||||
},
|
||||
},
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should update the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
doc.lines.should.deep.equal(this.result)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should return a message about duplicate ops', function () {
|
||||
this.messageCallback.calledTwice.should.equal(true)
|
||||
this.messageCallback.args[0][0].should.equal('applied-ops')
|
||||
expect(JSON.parse(this.messageCallback.args[0][1]).op.dup).to.be.undefined
|
||||
this.messageCallback.args[1][0].should.equal('applied-ops')
|
||||
expect(JSON.parse(this.messageCallback.args[1][1]).op.dup).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when sending updates for a non-existing doc id', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
this.non_existing = {
|
||||
doc_id: this.doc_id,
|
||||
v: this.version,
|
||||
op: [{ d: 'content', p: 0 }],
|
||||
}
|
||||
|
||||
DocUpdaterClient.subscribeToAppliedOps(
|
||||
(this.messageCallback = sinon.stub())
|
||||
)
|
||||
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.non_existing,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not update or create a doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
res.statusCode.should.equal(404)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should send a message with an error', function () {
|
||||
this.messageCallback.called.should.equal(true)
|
||||
const [channel, message] = this.messageCallback.args[0]
|
||||
channel.should.equal('applied-ops')
|
||||
JSON.parse(message).should.deep.include({
|
||||
project_id: this.project_id,
|
||||
doc_id: this.doc_id,
|
||||
error: `doc not not found: /project/${this.project_id}/doc/${this.doc_id}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,671 @@
|
||||
const sinon = require('sinon')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const rclientProjectHistory = require('@overleaf/redis-wrapper').createClient(
|
||||
Settings.redis.project_history
|
||||
)
|
||||
const ProjectHistoryKeys = Settings.redis.project_history.key_schema
|
||||
|
||||
const MockProjectHistoryApi = require('./helpers/MockProjectHistoryApi')
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe("Applying updates to a project's structure", function () {
|
||||
before(function () {
|
||||
this.user_id = 'user-id-123'
|
||||
this.version = 1234
|
||||
})
|
||||
|
||||
describe('renaming a file', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.fileUpdate = {
|
||||
type: 'rename-file',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/file-path',
|
||||
newPathname: '/new-file-path',
|
||||
}
|
||||
this.updates = [this.fileUpdate]
|
||||
DocUpdaterApp.ensureRunning(error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.updates,
|
||||
this.version,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should push the applied file renames to the project history api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
const update = JSON.parse(updates[0])
|
||||
update.file.should.equal(this.fileUpdate.id)
|
||||
update.pathname.should.equal('/file-path')
|
||||
update.new_pathname.should.equal('/new-file-path')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.0`)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a file', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.fileUpdate = {
|
||||
type: 'rename-file',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/file-path',
|
||||
newPathname: '',
|
||||
}
|
||||
this.updates = [this.fileUpdate]
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.updates,
|
||||
this.version,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the applied file renames to the project history api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
const update = JSON.parse(updates[0])
|
||||
update.file.should.equal(this.fileUpdate.id)
|
||||
update.pathname.should.equal('/file-path')
|
||||
update.new_pathname.should.equal('')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.0`)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renaming a document', function () {
|
||||
before(function () {
|
||||
this.update = {
|
||||
type: 'rename-doc',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/doc-path',
|
||||
newPathname: '/new-doc-path',
|
||||
}
|
||||
this.updates = [this.update]
|
||||
})
|
||||
|
||||
describe('when the document is not loaded', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.updates,
|
||||
this.version,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the applied doc renames to the project history api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
const update = JSON.parse(updates[0])
|
||||
update.doc.should.equal(this.update.id)
|
||||
update.pathname.should.equal('/doc-path')
|
||||
update.new_pathname.should.equal('/new-doc-path')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.0`)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document is loaded', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.update.id, {})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.update.id, error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.updates,
|
||||
this.version,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should update the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.update.id,
|
||||
(error, res, doc) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
doc.pathname.should.equal(this.update.newPathname)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the applied doc renames to the project history api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
const update = JSON.parse(updates[0])
|
||||
update.doc.should.equal(this.update.id)
|
||||
update.pathname.should.equal('/doc-path')
|
||||
update.new_pathname.should.equal('/new-doc-path')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.0`)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('renaming multiple documents and files', function () {
|
||||
before(function () {
|
||||
this.docUpdate0 = {
|
||||
type: 'rename-doc',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/doc-path0',
|
||||
newPathname: '/new-doc-path0',
|
||||
}
|
||||
this.docUpdate1 = {
|
||||
type: 'rename-doc',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/doc-path1',
|
||||
newPathname: '/new-doc-path1',
|
||||
}
|
||||
this.fileUpdate0 = {
|
||||
type: 'rename-file',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/file-path0',
|
||||
newPathname: '/new-file-path0',
|
||||
}
|
||||
this.fileUpdate1 = {
|
||||
type: 'rename-file',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/file-path1',
|
||||
newPathname: '/new-file-path1',
|
||||
}
|
||||
this.updates = [
|
||||
this.docUpdate0,
|
||||
this.docUpdate1,
|
||||
this.fileUpdate0,
|
||||
this.fileUpdate1,
|
||||
]
|
||||
})
|
||||
|
||||
describe('when the documents are not loaded', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.updates,
|
||||
this.version,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the applied doc renames to the project history api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
let update = JSON.parse(updates[0])
|
||||
update.doc.should.equal(this.docUpdate0.id)
|
||||
update.pathname.should.equal('/doc-path0')
|
||||
update.new_pathname.should.equal('/new-doc-path0')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.0`)
|
||||
|
||||
update = JSON.parse(updates[1])
|
||||
update.doc.should.equal(this.docUpdate1.id)
|
||||
update.pathname.should.equal('/doc-path1')
|
||||
update.new_pathname.should.equal('/new-doc-path1')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.1`)
|
||||
|
||||
update = JSON.parse(updates[2])
|
||||
update.file.should.equal(this.fileUpdate0.id)
|
||||
update.pathname.should.equal('/file-path0')
|
||||
update.new_pathname.should.equal('/new-file-path0')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.2`)
|
||||
|
||||
update = JSON.parse(updates[3])
|
||||
update.file.should.equal(this.fileUpdate1.id)
|
||||
update.pathname.should.equal('/file-path1')
|
||||
update.new_pathname.should.equal('/new-file-path1')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.3`)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a document', function () {
|
||||
before(function () {
|
||||
this.update = {
|
||||
type: 'rename-doc',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/doc-path',
|
||||
newPathname: '',
|
||||
}
|
||||
this.updates = [this.update]
|
||||
})
|
||||
|
||||
describe('when the document is not loaded', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.updates,
|
||||
this.version,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the applied doc update to the project history api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
const update = JSON.parse(updates[0])
|
||||
update.doc.should.equal(this.update.id)
|
||||
update.pathname.should.equal('/doc-path')
|
||||
update.new_pathname.should.equal('')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.0`)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document is loaded', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.update.id, {})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.update.id, error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.updates,
|
||||
this.version,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should not modify the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.update.id,
|
||||
(error, res, doc) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
doc.pathname.should.equal('/a/b/c.tex') // default pathname from MockWebApi
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the applied doc update to the project history api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
const update = JSON.parse(updates[0])
|
||||
update.doc.should.equal(this.update.id)
|
||||
update.pathname.should.equal('/doc-path')
|
||||
update.new_pathname.should.equal('')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.0`)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('adding a file', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.fileUpdate = {
|
||||
type: 'add-file',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/file-path',
|
||||
url: 'filestore.example.com',
|
||||
}
|
||||
this.updates = [this.fileUpdate]
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.updates,
|
||||
this.version,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the file addition to the project history api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
const update = JSON.parse(updates[0])
|
||||
update.file.should.equal(this.fileUpdate.id)
|
||||
update.pathname.should.equal('/file-path')
|
||||
update.url.should.equal('filestore.example.com')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.0`)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('adding a doc', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.docUpdate = {
|
||||
type: 'add-doc',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/file-path',
|
||||
docLines: 'a\nb',
|
||||
}
|
||||
this.updates = [this.docUpdate]
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.updates,
|
||||
this.version,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the doc addition to the project history api', function (done) {
|
||||
rclientProjectHistory.lrange(
|
||||
ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
|
||||
0,
|
||||
-1,
|
||||
(error, updates) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
|
||||
const update = JSON.parse(updates[0])
|
||||
update.doc.should.equal(this.docUpdate.id)
|
||||
update.pathname.should.equal('/file-path')
|
||||
update.docLines.should.equal('a\nb')
|
||||
update.meta.user_id.should.equal(this.user_id)
|
||||
update.meta.ts.should.be.a('string')
|
||||
update.version.should.equal(`${this.version}.0`)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with enough updates to flush to the history service', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.version0 = 12345
|
||||
this.version1 = this.version0 + 1
|
||||
const updates = []
|
||||
for (let v = 0; v <= 599; v++) {
|
||||
// Should flush after 500 ops
|
||||
updates.push({
|
||||
type: 'add-doc',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/file-' + v,
|
||||
docLines: 'a\nb',
|
||||
})
|
||||
}
|
||||
|
||||
sinon.spy(MockProjectHistoryApi, 'flushProject')
|
||||
|
||||
// Send updates in chunks to causes multiple flushes
|
||||
const projectId = this.project_id
|
||||
const userId = this.project_id
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
projectId,
|
||||
userId,
|
||||
updates.slice(0, 250),
|
||||
this.version0,
|
||||
function (error) {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
projectId,
|
||||
userId,
|
||||
updates.slice(250),
|
||||
this.version1,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 2000)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.restore()
|
||||
})
|
||||
|
||||
it('should flush project history', function () {
|
||||
MockProjectHistoryApi.flushProject
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with too few updates to flush to the history service', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.version0 = 12345
|
||||
this.version1 = this.version0 + 1
|
||||
|
||||
const updates = []
|
||||
for (let v = 0; v <= 42; v++) {
|
||||
// Should flush after 500 ops
|
||||
updates.push({
|
||||
type: 'add-doc',
|
||||
id: DocUpdaterClient.randomId(),
|
||||
pathname: '/file-' + v,
|
||||
docLines: 'a\nb',
|
||||
})
|
||||
}
|
||||
|
||||
sinon.spy(MockProjectHistoryApi, 'flushProject')
|
||||
|
||||
// Send updates in chunks
|
||||
const projectId = this.project_id
|
||||
const userId = this.project_id
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
projectId,
|
||||
userId,
|
||||
updates.slice(0, 10),
|
||||
this.version0,
|
||||
function (error) {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
DocUpdaterClient.sendProjectUpdate(
|
||||
projectId,
|
||||
userId,
|
||||
updates.slice(10),
|
||||
this.version1,
|
||||
error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
setTimeout(done, 2000)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.restore()
|
||||
})
|
||||
|
||||
it('should not flush project history', function () {
|
||||
MockProjectHistoryApi.flushProject
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,371 @@
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
const { promisify } = require('node:util')
|
||||
const { exec } = require('node:child_process')
|
||||
const { expect } = require('chai')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const fs = require('node:fs')
|
||||
const Path = require('node:path')
|
||||
const MockDocstoreApi = require('./helpers/MockDocstoreApi')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const rclient = require('@overleaf/redis-wrapper').createClient(
|
||||
Settings.redis.documentupdater
|
||||
)
|
||||
|
||||
describe('CheckRedisMongoSyncState', function () {
|
||||
beforeEach(function (done) {
|
||||
DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
beforeEach(async function () {
|
||||
await rclient.flushall()
|
||||
})
|
||||
|
||||
let peekDocumentInDocstore
|
||||
beforeEach(function () {
|
||||
peekDocumentInDocstore = sinon.spy(MockDocstoreApi, 'peekDocument')
|
||||
})
|
||||
afterEach(function () {
|
||||
peekDocumentInDocstore.restore()
|
||||
})
|
||||
|
||||
async function runScript(options) {
|
||||
let result
|
||||
try {
|
||||
result = await promisify(exec)(
|
||||
Object.entries(options)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.concat(['node', 'scripts/check_redis_mongo_sync_state.js'])
|
||||
.join(' ')
|
||||
)
|
||||
} catch (error) {
|
||||
// includes details like exit code, stdErr and stdOut
|
||||
return error
|
||||
}
|
||||
result.code = 0
|
||||
return result
|
||||
}
|
||||
|
||||
describe('without projects', function () {
|
||||
it('should work when in sync', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(0)
|
||||
expect(result.stdout).to.include('Processed 0 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 0 projects with 0 out of sync docs'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a project', function () {
|
||||
let projectId, docId
|
||||
beforeEach(function (done) {
|
||||
projectId = DocUpdaterClient.randomId()
|
||||
docId = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(projectId, docId, {
|
||||
lines: ['mongo', 'lines'],
|
||||
version: 1,
|
||||
})
|
||||
DocUpdaterClient.getDoc(projectId, docId, done)
|
||||
})
|
||||
|
||||
it('should work when in sync', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(0)
|
||||
expect(result.stdout).to.include('Processed 1 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 0 projects with 0 out of sync docs'
|
||||
)
|
||||
|
||||
expect(peekDocumentInDocstore).to.not.have.been.called
|
||||
})
|
||||
|
||||
describe('with out of sync lines', function () {
|
||||
beforeEach(function () {
|
||||
MockWebApi.insertDoc(projectId, docId, {
|
||||
lines: ['updated', 'mongo', 'lines'],
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should detect the out of sync state', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(1)
|
||||
expect(result.stdout).to.include('Processed 1 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 1 projects with 1 out of sync docs'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with out of sync ranges', function () {
|
||||
beforeEach(function () {
|
||||
MockWebApi.insertDoc(projectId, docId, {
|
||||
lines: ['mongo', 'lines'],
|
||||
version: 1,
|
||||
ranges: { changes: ['FAKE CHANGE'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('should detect the out of sync state', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(1)
|
||||
expect(result.stdout).to.include('Processed 1 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 1 projects with 1 out of sync docs'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with out of sync version', function () {
|
||||
beforeEach(function () {
|
||||
MockWebApi.insertDoc(projectId, docId, {
|
||||
lines: ['mongo', 'lines'],
|
||||
version: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('should detect the out of sync state', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(1)
|
||||
expect(result.stdout).to.include('Processed 1 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 1 projects with 1 out of sync docs'
|
||||
)
|
||||
})
|
||||
|
||||
it('should auto-fix the out of sync state', async function () {
|
||||
const result = await runScript({
|
||||
AUTO_FIX_VERSION_MISMATCH: 'true',
|
||||
})
|
||||
expect(result.code).to.equal(0)
|
||||
expect(result.stdout).to.include('Processed 1 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 0 projects with 0 out of sync docs'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a project', function () {
|
||||
let projectId2, docId2
|
||||
beforeEach(function (done) {
|
||||
projectId2 = DocUpdaterClient.randomId()
|
||||
docId2 = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(projectId2, docId2, {
|
||||
lines: ['mongo', 'lines'],
|
||||
version: 1,
|
||||
})
|
||||
DocUpdaterClient.getDoc(projectId2, docId2, done)
|
||||
})
|
||||
|
||||
it('should work when in sync', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(0)
|
||||
expect(result.stdout).to.include('Processed 2 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 0 projects with 0 out of sync docs'
|
||||
)
|
||||
})
|
||||
|
||||
describe('with one out of sync', function () {
|
||||
beforeEach(function () {
|
||||
MockWebApi.insertDoc(projectId, docId, {
|
||||
lines: ['updated', 'mongo', 'lines'],
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should detect one project out of sync', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(1)
|
||||
expect(result.stdout).to.include('Processed 2 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 1 projects with 1 out of sync docs'
|
||||
)
|
||||
})
|
||||
|
||||
it('should write differences to disk', async function () {
|
||||
const FOLDER = '/tmp/folder'
|
||||
await fs.promises.rm(FOLDER, { recursive: true, force: true })
|
||||
const result = await runScript({
|
||||
WRITE_CONTENT: 'true',
|
||||
FOLDER,
|
||||
})
|
||||
expect(result.code).to.equal(1)
|
||||
expect(result.stdout).to.include('Processed 2 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 1 projects with 1 out of sync docs'
|
||||
)
|
||||
|
||||
const dir = Path.join(FOLDER, projectId, docId)
|
||||
expect(await fs.promises.readdir(FOLDER)).to.deep.equal([projectId])
|
||||
expect(await fs.promises.readdir(dir)).to.deep.equal([
|
||||
'mongo-snapshot.txt',
|
||||
'redis-snapshot.txt',
|
||||
])
|
||||
expect(
|
||||
await fs.promises.readFile(
|
||||
Path.join(dir, 'mongo-snapshot.txt'),
|
||||
'utf-8'
|
||||
)
|
||||
).to.equal('updated\nmongo\nlines')
|
||||
expect(
|
||||
await fs.promises.readFile(
|
||||
Path.join(dir, 'redis-snapshot.txt'),
|
||||
'utf-8'
|
||||
)
|
||||
).to.equal('mongo\nlines')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with both out of sync', function () {
|
||||
beforeEach(function () {
|
||||
MockWebApi.insertDoc(projectId, docId, {
|
||||
lines: ['updated', 'mongo', 'lines'],
|
||||
version: 1,
|
||||
})
|
||||
MockWebApi.insertDoc(projectId2, docId2, {
|
||||
lines: ['updated2', 'mongo', 'lines'],
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should detect both projects out of sync', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(1)
|
||||
expect(result.stdout).to.include('Processed 2 projects')
|
||||
expect(result.stdout).to.include(
|
||||
'Found 2 projects with 2 out of sync docs'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with more projects than the LIMIT', function () {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
beforeEach(function (done) {
|
||||
const projectId = DocUpdaterClient.randomId()
|
||||
const docId = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(projectId, docId, {
|
||||
lines: ['mongo', 'lines'],
|
||||
version: 1,
|
||||
})
|
||||
DocUpdaterClient.getDoc(projectId, docId, done)
|
||||
})
|
||||
}
|
||||
|
||||
it('should flag limit', async function () {
|
||||
const result = await runScript({ LIMIT: '4' })
|
||||
expect(result.code).to.equal(2)
|
||||
// A redis SCAN may return more than COUNT (aka LIMIT) entries. Match loosely.
|
||||
expect(result.stdout).to.match(/Processed \d+ projects/)
|
||||
expect(result.stderr).to.include(
|
||||
'Found too many un-flushed projects (LIMIT=4). Please fix the reported projects first, then try again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should continue with auto-flush', async function () {
|
||||
const result = await runScript({
|
||||
LIMIT: '4',
|
||||
FLUSH_IN_SYNC_PROJECTS: 'true',
|
||||
})
|
||||
expect(result.code).to.equal(0)
|
||||
expect(result.stdout).to.include('Processed 20 projects')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with partially deleted doc', function () {
|
||||
let projectId, docId
|
||||
beforeEach(function (done) {
|
||||
projectId = DocUpdaterClient.randomId()
|
||||
docId = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(projectId, docId, {
|
||||
lines: ['mongo', 'lines'],
|
||||
version: 1,
|
||||
})
|
||||
MockDocstoreApi.insertDoc(projectId, docId, {
|
||||
lines: ['mongo', 'lines'],
|
||||
version: 1,
|
||||
})
|
||||
DocUpdaterClient.getDoc(projectId, docId, err => {
|
||||
MockWebApi.clearDocs()
|
||||
done(err)
|
||||
})
|
||||
})
|
||||
describe('with only the file-tree entry deleted', function () {
|
||||
it('should flag the partial deletion', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(0)
|
||||
expect(result.stdout).to.include('Processed 1 projects')
|
||||
expect(result.stdout).to.include(
|
||||
`Found partially deleted doc ${docId} in project ${projectId}: use AUTO_FIX_PARTIALLY_DELETED_DOC_METADATA=true to fix metadata`
|
||||
)
|
||||
expect(result.stdout).to.include(
|
||||
'Found 0 projects with 0 out of sync docs'
|
||||
)
|
||||
expect(MockDocstoreApi.getDoc(projectId, docId)).to.not.include({
|
||||
deleted: true,
|
||||
name: 'c.tex',
|
||||
})
|
||||
expect(peekDocumentInDocstore).to.have.been.called
|
||||
})
|
||||
it('should autofix the partial deletion', async function () {
|
||||
const result = await runScript({
|
||||
AUTO_FIX_PARTIALLY_DELETED_DOC_METADATA: 'true',
|
||||
})
|
||||
expect(result.code).to.equal(0)
|
||||
expect(result.stdout).to.include('Processed 1 projects')
|
||||
expect(result.stdout).to.include(
|
||||
`Found partially deleted doc ${docId} in project ${projectId}: fixing metadata`
|
||||
)
|
||||
expect(result.stdout).to.include(
|
||||
'Found 0 projects with 0 out of sync docs'
|
||||
)
|
||||
|
||||
expect(MockDocstoreApi.getDoc(projectId, docId)).to.include({
|
||||
deleted: true,
|
||||
name: 'c.tex',
|
||||
})
|
||||
|
||||
const result2 = await runScript({})
|
||||
expect(result2.code).to.equal(0)
|
||||
expect(result2.stdout).to.include('Processed 1 projects')
|
||||
expect(result2.stdout).to.not.include(
|
||||
`Found partially deleted doc ${docId} in project ${projectId}`
|
||||
)
|
||||
expect(result2.stdout).to.include(
|
||||
'Found 0 projects with 0 out of sync docs'
|
||||
)
|
||||
})
|
||||
})
|
||||
describe('with docstore metadata updated', function () {
|
||||
beforeEach(function (done) {
|
||||
MockDocstoreApi.patchDocument(
|
||||
projectId,
|
||||
docId,
|
||||
{
|
||||
deleted: true,
|
||||
deletedAt: new Date(),
|
||||
name: 'c.tex',
|
||||
},
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should work when in sync', async function () {
|
||||
const result = await runScript({})
|
||||
expect(result.code).to.equal(0)
|
||||
expect(result.stdout).to.include('Processed 1 projects')
|
||||
expect(result.stdout).to.not.include(
|
||||
`Found partially deleted doc ${docId} in project ${projectId}`
|
||||
)
|
||||
expect(result.stdout).to.include(
|
||||
'Found 0 projects with 0 out of sync docs'
|
||||
)
|
||||
expect(peekDocumentInDocstore).to.have.been.called
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,174 @@
|
||||
// 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const sinon = require('sinon')
|
||||
const MockProjectHistoryApi = require('./helpers/MockProjectHistoryApi')
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('Deleting a document', function () {
|
||||
before(function (done) {
|
||||
this.lines = ['one', 'two', 'three']
|
||||
this.version = 42
|
||||
this.update = {
|
||||
doc: this.doc_id,
|
||||
op: [
|
||||
{
|
||||
i: 'one and a half\n',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
v: this.version,
|
||||
}
|
||||
this.result = ['one', 'one and a half', 'two', 'three']
|
||||
|
||||
sinon.spy(MockProjectHistoryApi, 'flushProject')
|
||||
DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.restore()
|
||||
})
|
||||
|
||||
describe('when the updated doc exists in the doc updater', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.deleteDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, body) => {
|
||||
if (error) return done(error)
|
||||
this.statusCode = res.statusCode
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockWebApi.setDocument.restore()
|
||||
MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should return a 204 status code', function () {
|
||||
this.statusCode.should.equal(204)
|
||||
})
|
||||
|
||||
it('should send the updated document and version to the web api', function () {
|
||||
MockWebApi.setDocument
|
||||
.calledWith(this.project_id, this.doc_id, this.result, this.version + 1)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should need to reload the doc if read again', function (done) {
|
||||
MockWebApi.getDocument.resetHistory()
|
||||
MockWebApi.getDocument.called.should.equals(false)
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
MockWebApi.getDocument
|
||||
.calledWith(this.project_id, this.doc_id)
|
||||
.should.equal(true)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should flush project history', function () {
|
||||
MockProjectHistoryApi.flushProject
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the doc is not in the doc updater', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
})
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
DocUpdaterClient.deleteDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, body) => {
|
||||
if (error) return done(error)
|
||||
this.statusCode = res.statusCode
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockWebApi.setDocument.restore()
|
||||
MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should return a 204 status code', function () {
|
||||
this.statusCode.should.equal(204)
|
||||
})
|
||||
|
||||
it('should not need to send the updated document to the web api', function () {
|
||||
MockWebApi.setDocument.called.should.equal(false)
|
||||
})
|
||||
|
||||
it('should need to reload the doc if read again', function (done) {
|
||||
MockWebApi.getDocument.called.should.equals(false)
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
MockWebApi.getDocument
|
||||
.calledWith(this.project_id, this.doc_id)
|
||||
.should.equal(true)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should flush project history', function () {
|
||||
MockProjectHistoryApi.flushProject
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,357 @@
|
||||
// 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const sinon = require('sinon')
|
||||
const async = require('async')
|
||||
|
||||
const MockProjectHistoryApi = require('./helpers/MockProjectHistoryApi')
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('Deleting a project', function () {
|
||||
beforeEach(function (done) {
|
||||
let docId0, docId1
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.docs = [
|
||||
{
|
||||
id: (docId0 = DocUpdaterClient.randomId()),
|
||||
lines: ['one', 'two', 'three'],
|
||||
update: {
|
||||
doc: docId0,
|
||||
op: [
|
||||
{
|
||||
i: 'one and a half\n',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
v: 0,
|
||||
},
|
||||
updatedLines: ['one', 'one and a half', 'two', 'three'],
|
||||
},
|
||||
{
|
||||
id: (docId1 = DocUpdaterClient.randomId()),
|
||||
lines: ['four', 'five', 'six'],
|
||||
update: {
|
||||
doc: docId1,
|
||||
op: [
|
||||
{
|
||||
i: 'four and a half\n',
|
||||
p: 5,
|
||||
},
|
||||
],
|
||||
v: 0,
|
||||
},
|
||||
updatedLines: ['four', 'four and a half', 'five', 'six'],
|
||||
},
|
||||
]
|
||||
for (const doc of Array.from(this.docs)) {
|
||||
MockWebApi.insertDoc(this.project_id, doc.id, {
|
||||
lines: doc.lines,
|
||||
version: doc.update.v,
|
||||
})
|
||||
}
|
||||
|
||||
DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
describe('without updates', function () {
|
||||
beforeEach(function (done) {
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
sinon.spy(MockProjectHistoryApi, 'flushProject')
|
||||
|
||||
async.series(
|
||||
this.docs.map(doc => {
|
||||
return callback => {
|
||||
DocUpdaterClient.preloadDoc(this.project_id, doc.id, error => {
|
||||
callback(error)
|
||||
})
|
||||
}
|
||||
}),
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.deleteProject(
|
||||
this.project_id,
|
||||
(error, res, body) => {
|
||||
if (error) return done(error)
|
||||
this.statusCode = res.statusCode
|
||||
done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
MockWebApi.setDocument.restore()
|
||||
MockProjectHistoryApi.flushProject.restore()
|
||||
})
|
||||
|
||||
it('should return a 204 status code', function () {
|
||||
this.statusCode.should.equal(204)
|
||||
})
|
||||
|
||||
it('should not send any document to the web api', function () {
|
||||
MockWebApi.setDocument.should.not.have.been.called
|
||||
})
|
||||
|
||||
it('should need to reload the docs if read again', function (done) {
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
async.series(
|
||||
this.docs.map(doc => {
|
||||
return callback => {
|
||||
MockWebApi.getDocument
|
||||
.calledWith(this.project_id, doc.id)
|
||||
.should.equal(false)
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
doc.id,
|
||||
(error, res, returnedDoc) => {
|
||||
if (error) return done(error)
|
||||
MockWebApi.getDocument
|
||||
.calledWith(this.project_id, doc.id)
|
||||
.should.equal(true)
|
||||
callback()
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
() => {
|
||||
MockWebApi.getDocument.restore()
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should flush each doc in project history', function () {
|
||||
MockProjectHistoryApi.flushProject
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with documents which have been updated', function () {
|
||||
beforeEach(function (done) {
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
sinon.spy(MockProjectHistoryApi, 'flushProject')
|
||||
|
||||
async.series(
|
||||
this.docs.map(doc => {
|
||||
return callback => {
|
||||
DocUpdaterClient.preloadDoc(this.project_id, doc.id, error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
doc.id,
|
||||
doc.update,
|
||||
error => {
|
||||
callback(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}),
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.deleteProject(
|
||||
this.project_id,
|
||||
(error, res, body) => {
|
||||
if (error) return done(error)
|
||||
this.statusCode = res.statusCode
|
||||
done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
MockWebApi.setDocument.restore()
|
||||
MockProjectHistoryApi.flushProject.restore()
|
||||
})
|
||||
|
||||
it('should return a 204 status code', function () {
|
||||
this.statusCode.should.equal(204)
|
||||
})
|
||||
|
||||
it('should send each document to the web api', function () {
|
||||
Array.from(this.docs).map(doc =>
|
||||
MockWebApi.setDocument
|
||||
.calledWith(this.project_id, doc.id, doc.updatedLines)
|
||||
.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('should need to reload the docs if read again', function (done) {
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
async.series(
|
||||
this.docs.map(doc => {
|
||||
return callback => {
|
||||
MockWebApi.getDocument
|
||||
.calledWith(this.project_id, doc.id)
|
||||
.should.equal(false)
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
doc.id,
|
||||
(error, res, returnedDoc) => {
|
||||
if (error) return done(error)
|
||||
MockWebApi.getDocument
|
||||
.calledWith(this.project_id, doc.id)
|
||||
.should.equal(true)
|
||||
callback()
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
() => {
|
||||
MockWebApi.getDocument.restore()
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should flush each doc in project history', function () {
|
||||
MockProjectHistoryApi.flushProject
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the background=true parameter from realtime and no request to flush the queue', function () {
|
||||
beforeEach(function (done) {
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
sinon.spy(MockProjectHistoryApi, 'flushProject')
|
||||
|
||||
async.series(
|
||||
this.docs.map(doc => {
|
||||
return callback => {
|
||||
DocUpdaterClient.preloadDoc(this.project_id, doc.id, error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
doc.id,
|
||||
doc.update,
|
||||
error => {
|
||||
callback(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}),
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.deleteProjectOnShutdown(
|
||||
this.project_id,
|
||||
(error, res, body) => {
|
||||
if (error) return done(error)
|
||||
this.statusCode = res.statusCode
|
||||
done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
MockWebApi.setDocument.restore()
|
||||
MockProjectHistoryApi.flushProject.restore()
|
||||
})
|
||||
|
||||
it('should return a 204 status code', function () {
|
||||
this.statusCode.should.equal(204)
|
||||
})
|
||||
|
||||
it('should not send any documents to the web api', function () {
|
||||
MockWebApi.setDocument.called.should.equal(false)
|
||||
})
|
||||
|
||||
it('should not flush to project history', function () {
|
||||
MockProjectHistoryApi.flushProject.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the background=true parameter from realtime and a request to flush the queue', function () {
|
||||
beforeEach(function (done) {
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
sinon.spy(MockProjectHistoryApi, 'flushProject')
|
||||
|
||||
async.series(
|
||||
this.docs.map(doc => {
|
||||
return callback => {
|
||||
DocUpdaterClient.preloadDoc(this.project_id, doc.id, error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
doc.id,
|
||||
doc.update,
|
||||
error => {
|
||||
callback(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}),
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.deleteProjectOnShutdown(
|
||||
this.project_id,
|
||||
(error, res, body) => {
|
||||
if (error) return done(error)
|
||||
this.statusCode = res.statusCode
|
||||
// after deleting the project and putting it in the queue, flush the queue
|
||||
setTimeout(() => DocUpdaterClient.flushOldProjects(done), 2000)
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
MockWebApi.setDocument.restore()
|
||||
MockProjectHistoryApi.flushProject.restore()
|
||||
})
|
||||
|
||||
it('should return a 204 status code', function () {
|
||||
this.statusCode.should.equal(204)
|
||||
})
|
||||
|
||||
it('should send each document to the web api', function () {
|
||||
Array.from(this.docs).map(doc =>
|
||||
MockWebApi.setDocument
|
||||
.calledWith(this.project_id, doc.id, doc.updatedLines)
|
||||
.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('should flush to project history', function () {
|
||||
MockProjectHistoryApi.flushProject.called.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,141 @@
|
||||
// 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const sinon = require('sinon')
|
||||
const async = require('async')
|
||||
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('Flushing a project', function () {
|
||||
before(function (done) {
|
||||
let docId0, docId1
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.docs = [
|
||||
{
|
||||
id: (docId0 = DocUpdaterClient.randomId()),
|
||||
lines: ['one', 'two', 'three'],
|
||||
update: {
|
||||
doc: docId0,
|
||||
op: [
|
||||
{
|
||||
i: 'one and a half\n',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
v: 0,
|
||||
},
|
||||
updatedLines: ['one', 'one and a half', 'two', 'three'],
|
||||
},
|
||||
{
|
||||
id: (docId1 = DocUpdaterClient.randomId()),
|
||||
lines: ['four', 'five', 'six'],
|
||||
update: {
|
||||
doc: docId1,
|
||||
op: [
|
||||
{
|
||||
i: 'four and a half\n',
|
||||
p: 5,
|
||||
},
|
||||
],
|
||||
v: 0,
|
||||
},
|
||||
updatedLines: ['four', 'four and a half', 'five', 'six'],
|
||||
},
|
||||
]
|
||||
for (const doc of Array.from(this.docs)) {
|
||||
MockWebApi.insertDoc(this.project_id, doc.id, {
|
||||
lines: doc.lines,
|
||||
version: doc.update.v,
|
||||
})
|
||||
}
|
||||
return DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
return describe('with documents which have been updated', function () {
|
||||
before(function (done) {
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
|
||||
return async.series(
|
||||
this.docs.map(doc => {
|
||||
return callback => {
|
||||
return DocUpdaterClient.preloadDoc(
|
||||
this.project_id,
|
||||
doc.id,
|
||||
error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
return DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
doc.id,
|
||||
doc.update,
|
||||
error => {
|
||||
return callback(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return setTimeout(() => {
|
||||
return DocUpdaterClient.flushProject(
|
||||
this.project_id,
|
||||
(error, res, body) => {
|
||||
if (error) return done(error)
|
||||
this.statusCode = res.statusCode
|
||||
return done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.setDocument.restore()
|
||||
})
|
||||
|
||||
it('should return a 204 status code', function () {
|
||||
return this.statusCode.should.equal(204)
|
||||
})
|
||||
|
||||
it('should send each document to the web api', function () {
|
||||
return Array.from(this.docs).map(doc =>
|
||||
MockWebApi.setDocument
|
||||
.calledWith(this.project_id, doc.id, doc.updatedLines)
|
||||
.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
return it('should update the lines in the doc updater', function (done) {
|
||||
return async.series(
|
||||
this.docs.map(doc => {
|
||||
return callback => {
|
||||
return DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
doc.id,
|
||||
(error, res, returnedDoc) => {
|
||||
if (error) return done(error)
|
||||
returnedDoc.lines.should.deep.equal(doc.updatedLines)
|
||||
return callback()
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
done
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,162 @@
|
||||
/* 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
|
||||
* 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 async = require('async')
|
||||
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('Flushing a doc to Mongo', function () {
|
||||
before(function (done) {
|
||||
this.lines = ['one', 'two', 'three']
|
||||
this.version = 42
|
||||
this.update = {
|
||||
doc: this.doc_id,
|
||||
meta: { user_id: 'last-author-fake-id' },
|
||||
op: [
|
||||
{
|
||||
i: 'one and a half\n',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
v: this.version,
|
||||
}
|
||||
this.result = ['one', 'one and a half', 'two', 'three']
|
||||
return DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
describe('when the updated doc exists in the doc updater', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
return DocUpdaterClient.sendUpdates(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
[this.update],
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return setTimeout(() => {
|
||||
return DocUpdaterClient.flushDoc(this.project_id, this.doc_id, done)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.setDocument.restore()
|
||||
})
|
||||
|
||||
it('should flush the updated doc lines and version to the web api', function () {
|
||||
return MockWebApi.setDocument
|
||||
.calledWith(this.project_id, this.doc_id, this.result, this.version + 1)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should flush the last update author and time to the web api', function () {
|
||||
const lastUpdatedAt = MockWebApi.setDocument.lastCall.args[5]
|
||||
parseInt(lastUpdatedAt).should.be.closeTo(new Date().getTime(), 30000)
|
||||
|
||||
const lastUpdatedBy = MockWebApi.setDocument.lastCall.args[6]
|
||||
return lastUpdatedBy.should.equal('last-author-fake-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the doc does not exist in the doc updater', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
})
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
return DocUpdaterClient.flushDoc(this.project_id, this.doc_id, done)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.setDocument.restore()
|
||||
})
|
||||
|
||||
return it('should not flush the doc to the web api', function () {
|
||||
return MockWebApi.setDocument.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the web api http request takes a long time on first request', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
let t = 30000
|
||||
sinon
|
||||
.stub(MockWebApi, 'setDocument')
|
||||
.callsFake(
|
||||
(
|
||||
projectId,
|
||||
docId,
|
||||
lines,
|
||||
version,
|
||||
ranges,
|
||||
lastUpdatedAt,
|
||||
lastUpdatedBy,
|
||||
callback
|
||||
) => {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
setTimeout(callback, t)
|
||||
return (t = 0)
|
||||
}
|
||||
)
|
||||
return DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, done)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.setDocument.restore()
|
||||
})
|
||||
|
||||
return it('should still work', function (done) {
|
||||
const start = Date.now()
|
||||
return DocUpdaterClient.flushDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
res.statusCode.should.equal(204)
|
||||
const delta = Date.now() - start
|
||||
expect(delta).to.be.below(20000)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,293 @@
|
||||
// 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
|
||||
* 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 MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('Getting a document', function () {
|
||||
before(function (done) {
|
||||
this.lines = ['one', 'two', 'three']
|
||||
this.version = 42
|
||||
return DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
describe('when the document is not loaded', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
|
||||
return DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, returnedDoc) => {
|
||||
if (error) return done(error)
|
||||
this.returnedDoc = returnedDoc
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should load the document from the web API', function () {
|
||||
return MockWebApi.getDocument
|
||||
.calledWith(this.project_id, this.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the document lines', function () {
|
||||
return this.returnedDoc.lines.should.deep.equal(this.lines)
|
||||
})
|
||||
|
||||
return it('should return the document at its current version', function () {
|
||||
return this.returnedDoc.version.should.equal(this.version)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document is already loaded', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
return DocUpdaterClient.preloadDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
return DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, returnedDoc) => {
|
||||
if (error) return done(error)
|
||||
this.returnedDoc = returnedDoc
|
||||
return done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should not load the document from the web API', function () {
|
||||
return MockWebApi.getDocument.called.should.equal(false)
|
||||
})
|
||||
|
||||
return it('should return the document lines', function () {
|
||||
return this.returnedDoc.lines.should.deep.equal(this.lines)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the request asks for some recent ops', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: (this.lines = ['one', 'two', 'three']),
|
||||
})
|
||||
|
||||
this.updates = __range__(0, 199, true).map(v => ({
|
||||
doc_id: this.doc_id,
|
||||
op: [{ i: v.toString(), p: 0 }],
|
||||
v,
|
||||
}))
|
||||
|
||||
return DocUpdaterClient.sendUpdates(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.updates,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
describe('when the ops are loaded', function () {
|
||||
before(function (done) {
|
||||
return DocUpdaterClient.getDocAndRecentOps(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
190,
|
||||
(error, res, returnedDoc) => {
|
||||
if (error) return done(error)
|
||||
this.returnedDoc = returnedDoc
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return the recent ops', function () {
|
||||
this.returnedDoc.ops.length.should.equal(10)
|
||||
return Array.from(this.updates.slice(190, -1)).map((update, i) =>
|
||||
this.returnedDoc.ops[i].op.should.deep.equal(update.op)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the ops are not all loaded', function () {
|
||||
before(function (done) {
|
||||
// We only track 100 ops
|
||||
return DocUpdaterClient.getDocAndRecentOps(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
10,
|
||||
(error, res, returnedDoc) => {
|
||||
if (error) return done(error)
|
||||
this.res = res
|
||||
this.returnedDoc = returnedDoc
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return UnprocessableEntity', function () {
|
||||
return this.res.statusCode.should.equal(422)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document does not exist', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
return DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
this.statusCode = res.statusCode
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return 404', function () {
|
||||
return this.statusCode.should.equal(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the web api returns an error', function () {
|
||||
before(function (done) {
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
sinon
|
||||
.stub(MockWebApi, 'getDocument')
|
||||
.callsFake((projectId, docId, callback) => {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
return callback(new Error('oops'))
|
||||
})
|
||||
return DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
this.statusCode = res.statusCode
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
return it('should return 500', function () {
|
||||
return this.statusCode.should.equal(500)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the web api http request takes a long time', function () {
|
||||
before(function (done) {
|
||||
this.timeout = 10000
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
sinon
|
||||
.stub(MockWebApi, 'getDocument')
|
||||
.callsFake((projectId, docId, callback) => {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
return setTimeout(callback, 30000)
|
||||
})
|
||||
return done()
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
return it('should return quickly(ish)', function (done) {
|
||||
const start = Date.now()
|
||||
return DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
res.statusCode.should.equal(500)
|
||||
const delta = Date.now() - start
|
||||
expect(delta).to.be.below(20000)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function __range__(left, right, inclusive) {
|
||||
const range = []
|
||||
const ascending = left < right
|
||||
const end = !inclusive ? right : ascending ? right + 1 : right - 1
|
||||
for (let i = left; ascending ? i < end : i > end; ascending ? i++ : i--) {
|
||||
range.push(i)
|
||||
}
|
||||
return range
|
||||
}
|
@@ -0,0 +1,176 @@
|
||||
/* eslint-disable
|
||||
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
|
||||
* 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 MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('Getting documents for project', function () {
|
||||
before(function (done) {
|
||||
this.lines = ['one', 'two', 'three']
|
||||
this.version = 42
|
||||
return DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
describe('when project state hash does not match', function () {
|
||||
before(function (done) {
|
||||
this.projectStateHash = DocUpdaterClient.randomId()
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
return DocUpdaterClient.preloadDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return DocUpdaterClient.getProjectDocs(
|
||||
this.project_id,
|
||||
this.projectStateHash,
|
||||
(error, res, returnedDocs) => {
|
||||
if (error) return done(error)
|
||||
this.res = res
|
||||
this.returnedDocs = returnedDocs
|
||||
return done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return a 409 Conflict response', function () {
|
||||
return this.res.statusCode.should.equal(409)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when project state hash matches', function () {
|
||||
before(function (done) {
|
||||
this.projectStateHash = DocUpdaterClient.randomId()
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
return DocUpdaterClient.preloadDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return DocUpdaterClient.getProjectDocs(
|
||||
this.project_id,
|
||||
this.projectStateHash,
|
||||
(error, res0, returnedDocs0) => {
|
||||
if (error) return done(error)
|
||||
// set the hash
|
||||
this.res0 = res0
|
||||
this.returnedDocs0 = returnedDocs0
|
||||
return DocUpdaterClient.getProjectDocs(
|
||||
this.project_id,
|
||||
this.projectStateHash,
|
||||
(error, res, returnedDocs) => {
|
||||
if (error) return done(error)
|
||||
// the hash should now match
|
||||
this.res = res
|
||||
this.returnedDocs = returnedDocs
|
||||
return done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should return a 200 response', function () {
|
||||
return this.res.statusCode.should.equal(200)
|
||||
})
|
||||
|
||||
return it('should return the documents', function () {
|
||||
return this.returnedDocs.should.deep.equal([
|
||||
{ _id: this.doc_id, lines: this.lines, v: this.version },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the doc has been removed', function () {
|
||||
before(function (done) {
|
||||
this.projectStateHash = DocUpdaterClient.randomId()
|
||||
;[this.project_id, this.doc_id] = Array.from([
|
||||
DocUpdaterClient.randomId(),
|
||||
DocUpdaterClient.randomId(),
|
||||
])
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
return DocUpdaterClient.preloadDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return DocUpdaterClient.getProjectDocs(
|
||||
this.project_id,
|
||||
this.projectStateHash,
|
||||
(error, res0, returnedDocs0) => {
|
||||
if (error) return done(error)
|
||||
// set the hash
|
||||
this.res0 = res0
|
||||
this.returnedDocs0 = returnedDocs0
|
||||
return DocUpdaterClient.deleteDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, body) => {
|
||||
if (error) return done(error)
|
||||
// delete the doc
|
||||
return DocUpdaterClient.getProjectDocs(
|
||||
this.project_id,
|
||||
this.projectStateHash,
|
||||
(error, res1, returnedDocs) => {
|
||||
if (error) return done(error)
|
||||
// the hash would match, but the doc has been deleted
|
||||
this.res = res1
|
||||
this.returnedDocs = returnedDocs
|
||||
return done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return a 409 Conflict response', function () {
|
||||
return this.res.statusCode.should.equal(409)
|
||||
})
|
||||
})
|
||||
})
|
100
services/document-updater/test/acceptance/js/PeekingADoc.js
Normal file
100
services/document-updater/test/acceptance/js/PeekingADoc.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const sinon = require('sinon')
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('Peeking a document', function () {
|
||||
before(function (done) {
|
||||
this.lines = ['one', 'two', 'three']
|
||||
this.version = 42
|
||||
return DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
describe('when the document is not loaded', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
|
||||
return DocUpdaterClient.peekDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, returnedDoc) => {
|
||||
this.error = error
|
||||
this.res = res
|
||||
this.returnedDoc = returnedDoc
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should return a 404 response', function () {
|
||||
this.res.statusCode.should.equal(404)
|
||||
})
|
||||
|
||||
it('should not load the document from the web API', function () {
|
||||
return MockWebApi.getDocument.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document is already loaded', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
return DocUpdaterClient.preloadDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
sinon.spy(MockWebApi, 'getDocument')
|
||||
return DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, returnedDoc) => {
|
||||
if (error) return done(error)
|
||||
this.res = res
|
||||
this.returnedDoc = returnedDoc
|
||||
return done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return MockWebApi.getDocument.restore()
|
||||
})
|
||||
|
||||
it('should return a 200 response', function () {
|
||||
this.res.statusCode.should.equal(200)
|
||||
})
|
||||
|
||||
it('should return the document lines', function () {
|
||||
return this.returnedDoc.lines.should.deep.equal(this.lines)
|
||||
})
|
||||
|
||||
it('should return the document version', function () {
|
||||
return this.returnedDoc.version.should.equal(this.version)
|
||||
})
|
||||
|
||||
it('should not load the document from the web API', function () {
|
||||
return MockWebApi.getDocument.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
882
services/document-updater/test/acceptance/js/RangesTests.js
Normal file
882
services/document-updater/test/acceptance/js/RangesTests.js
Normal file
@@ -0,0 +1,882 @@
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const async = require('async')
|
||||
|
||||
const { db, ObjectId } = require('../../../app/js/mongodb')
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
const RangesManager = require('../../../app/js/RangesManager')
|
||||
|
||||
const sandbox = sinon.createSandbox()
|
||||
|
||||
describe('Ranges', function () {
|
||||
before(function (done) {
|
||||
DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
describe('tracking changes from ops', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.id_seed = '587357bd35e64f6157'
|
||||
this.doc = {
|
||||
id: DocUpdaterClient.randomId(),
|
||||
lines: ['aaa'],
|
||||
}
|
||||
this.updates = [
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ i: '123', p: 1 }],
|
||||
v: 0,
|
||||
meta: { user_id: this.user_id },
|
||||
},
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ i: '456', p: 5 }],
|
||||
v: 1,
|
||||
meta: { user_id: this.user_id, tc: this.id_seed },
|
||||
},
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ d: '12', p: 1 }],
|
||||
v: 2,
|
||||
meta: { user_id: this.user_id },
|
||||
},
|
||||
]
|
||||
MockWebApi.insertDoc(this.project_id, this.doc.id, {
|
||||
lines: this.doc.lines,
|
||||
version: 0,
|
||||
})
|
||||
const jobs = []
|
||||
for (const update of this.updates) {
|
||||
jobs.push(callback =>
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
DocUpdaterApp.ensureRunning(error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
async.series(jobs, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the ranges', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
const change = ranges.changes[0]
|
||||
change.op.should.deep.equal({ i: '456', p: 3 })
|
||||
change.id.should.equal(this.id_seed + '000001')
|
||||
change.metadata.user_id.should.equal(this.user_id)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('Adding comments', function () {
|
||||
describe('standalone', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.doc = {
|
||||
id: DocUpdaterClient.randomId(),
|
||||
lines: ['foo bar baz'],
|
||||
}
|
||||
this.updates = [
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [
|
||||
{ c: 'bar', p: 4, t: (this.tid = DocUpdaterClient.randomId()) },
|
||||
],
|
||||
v: 0,
|
||||
},
|
||||
]
|
||||
MockWebApi.insertDoc(this.project_id, this.doc.id, {
|
||||
lines: this.doc.lines,
|
||||
version: 0,
|
||||
})
|
||||
const jobs = []
|
||||
for (const update of this.updates) {
|
||||
jobs.push(callback =>
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
)
|
||||
}
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
async.series(jobs, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the ranges', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
const comment = ranges.comments[0]
|
||||
comment.op.should.deep.equal({ c: 'bar', p: 4, t: this.tid })
|
||||
comment.id.should.equal(this.tid)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with conflicting ops needing OT', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.doc = {
|
||||
id: DocUpdaterClient.randomId(),
|
||||
lines: ['foo bar baz'],
|
||||
}
|
||||
this.updates = [
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ i: 'ABC', p: 3 }],
|
||||
v: 0,
|
||||
meta: { user_id: this.user_id },
|
||||
},
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [
|
||||
{ c: 'bar', p: 4, t: (this.tid = DocUpdaterClient.randomId()) },
|
||||
],
|
||||
v: 0,
|
||||
},
|
||||
]
|
||||
MockWebApi.insertDoc(this.project_id, this.doc.id, {
|
||||
lines: this.doc.lines,
|
||||
version: 0,
|
||||
})
|
||||
const jobs = []
|
||||
for (const update of this.updates) {
|
||||
jobs.push(callback =>
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
)
|
||||
}
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
async.series(jobs, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the comments with the OT shifted comment', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
const comment = ranges.comments[0]
|
||||
comment.op.should.deep.equal({ c: 'bar', p: 7, t: this.tid })
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading ranges from persistence layer', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.id_seed = '587357bd35e64f6157'
|
||||
this.doc = {
|
||||
id: DocUpdaterClient.randomId(),
|
||||
lines: ['a123aa'],
|
||||
}
|
||||
this.update = {
|
||||
doc: this.doc.id,
|
||||
op: [{ i: '456', p: 5 }],
|
||||
v: 0,
|
||||
meta: { user_id: this.user_id, tc: this.id_seed },
|
||||
}
|
||||
MockWebApi.insertDoc(this.project_id, this.doc.id, {
|
||||
lines: this.doc.lines,
|
||||
version: 0,
|
||||
ranges: {
|
||||
changes: [
|
||||
{
|
||||
op: { i: '123', p: 1 },
|
||||
metadata: {
|
||||
user_id: this.user_id,
|
||||
ts: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should have preloaded the existing ranges', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
const { changes } = data.ranges
|
||||
changes[0].op.should.deep.equal({ i: '123', p: 1 })
|
||||
changes[1].op.should.deep.equal({ i: '456', p: 5 })
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should flush the ranges to the persistence layer again', function (done) {
|
||||
DocUpdaterClient.flushDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
MockWebApi.getDocument(this.project_id, this.doc.id, (error, doc) => {
|
||||
if (error) return done(error)
|
||||
const { changes } = doc.ranges
|
||||
changes[0].op.should.deep.equal({ i: '123', p: 1 })
|
||||
changes[1].op.should.deep.equal({ i: '456', p: 5 })
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('accepting a change', function () {
|
||||
beforeEach(function (done) {
|
||||
sandbox.spy(MockWebApi, 'setDocument')
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.id_seed = '587357bd35e64f6157'
|
||||
this.doc = {
|
||||
id: DocUpdaterClient.randomId(),
|
||||
lines: ['aaa'],
|
||||
}
|
||||
this.update = {
|
||||
doc: this.doc.id,
|
||||
op: [{ i: '456', p: 1 }],
|
||||
v: 0,
|
||||
meta: { user_id: this.user_id, tc: this.id_seed },
|
||||
}
|
||||
MockWebApi.insertDoc(this.project_id, this.doc.id, {
|
||||
lines: this.doc.lines,
|
||||
version: 0,
|
||||
})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
const change = ranges.changes[0]
|
||||
change.op.should.deep.equal({ i: '456', p: 1 })
|
||||
change.id.should.equal(this.id_seed + '000001')
|
||||
change.metadata.user_id.should.equal(this.user_id)
|
||||
done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it('should remove the change after accepting', function (done) {
|
||||
DocUpdaterClient.acceptChange(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
this.id_seed + '000001',
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
expect(data.ranges.changes).to.be.undefined
|
||||
done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should persist the ranges after accepting', function (done) {
|
||||
DocUpdaterClient.flushDoc(this.project_id, this.doc.id, err => {
|
||||
if (err) return done(err)
|
||||
DocUpdaterClient.acceptChange(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
this.id_seed + '000001',
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
|
||||
DocUpdaterClient.flushDoc(this.project_id, this.doc.id, err => {
|
||||
if (err) return done(err)
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
expect(data.ranges.changes).to.be.undefined
|
||||
|
||||
MockWebApi.setDocument
|
||||
.calledWith(this.project_id, this.doc.id, ['a456aa'], 1, {})
|
||||
.should.equal(true)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('accepting multiple changes', function () {
|
||||
beforeEach(function (done) {
|
||||
this.getHistoryUpdatesSpy = sandbox.spy(
|
||||
RangesManager,
|
||||
'getHistoryUpdatesForAcceptedChanges'
|
||||
)
|
||||
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.doc = {
|
||||
id: DocUpdaterClient.randomId(),
|
||||
lines: ['aaa', 'bbb', 'ccc', 'ddd', 'eee'],
|
||||
}
|
||||
|
||||
MockWebApi.insertDoc(this.project_id, this.doc.id, {
|
||||
lines: this.doc.lines,
|
||||
version: 0,
|
||||
historyRangesSupport: true,
|
||||
})
|
||||
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
|
||||
this.id_seed_1 = 'tc_1'
|
||||
this.id_seed_2 = 'tc_2'
|
||||
this.id_seed_3 = 'tc_3'
|
||||
|
||||
this.updates = [
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ d: 'bbb', p: 4 }],
|
||||
v: 0,
|
||||
meta: {
|
||||
user_id: this.user_id,
|
||||
tc: this.id_seed_1,
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ d: 'ccc', p: 5 }],
|
||||
v: 1,
|
||||
meta: {
|
||||
user_id: this.user_id,
|
||||
tc: this.id_seed_2,
|
||||
},
|
||||
},
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ d: 'ddd', p: 6 }],
|
||||
v: 2,
|
||||
meta: {
|
||||
user_id: this.user_id,
|
||||
tc: this.id_seed_3,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
DocUpdaterClient.sendUpdates(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
this.updates,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
const changeOps = ranges.changes
|
||||
.map(change => change.op)
|
||||
.flat()
|
||||
changeOps.should.deep.equal([
|
||||
{ d: 'bbb', p: 4 },
|
||||
{ d: 'ccc', p: 5 },
|
||||
{ d: 'ddd', p: 6 },
|
||||
])
|
||||
done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it('accepting changes in order', function (done) {
|
||||
DocUpdaterClient.acceptChanges(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
[
|
||||
this.id_seed_1 + '000001',
|
||||
this.id_seed_2 + '000001',
|
||||
this.id_seed_3 + '000001',
|
||||
],
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const historyUpdates = this.getHistoryUpdatesSpy.returnValues[0]
|
||||
expect(historyUpdates[0]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 19,
|
||||
ts: historyUpdates[0].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 4, d: 'bbb' }],
|
||||
})
|
||||
|
||||
expect(historyUpdates[1]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 16,
|
||||
ts: historyUpdates[1].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 5, d: 'ccc' }],
|
||||
})
|
||||
|
||||
expect(historyUpdates[2]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 13,
|
||||
ts: historyUpdates[2].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 6, d: 'ddd' }],
|
||||
})
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('accepting changes in reverse order', function (done) {
|
||||
DocUpdaterClient.acceptChanges(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
[
|
||||
this.id_seed_3 + '000001',
|
||||
this.id_seed_2 + '000001',
|
||||
this.id_seed_1 + '000001',
|
||||
],
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const historyUpdates = this.getHistoryUpdatesSpy.returnValues[0]
|
||||
expect(historyUpdates[0]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 19,
|
||||
ts: historyUpdates[0].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 4, d: 'bbb' }],
|
||||
})
|
||||
|
||||
expect(historyUpdates[1]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 16,
|
||||
ts: historyUpdates[1].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 5, d: 'ccc' }],
|
||||
})
|
||||
|
||||
expect(historyUpdates[2]).to.deep.equal({
|
||||
doc: this.doc.id,
|
||||
meta: {
|
||||
pathname: '/a/b/c.tex',
|
||||
doc_length: 10,
|
||||
history_doc_length: 13,
|
||||
ts: historyUpdates[2].meta.ts,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
op: [{ p: 6, d: 'ddd' }],
|
||||
})
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a comment range', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.doc = {
|
||||
id: DocUpdaterClient.randomId(),
|
||||
lines: ['foo bar'],
|
||||
}
|
||||
this.update = {
|
||||
doc: this.doc.id,
|
||||
op: [{ c: 'bar', p: 4, t: (this.tid = DocUpdaterClient.randomId()) }],
|
||||
v: 0,
|
||||
}
|
||||
MockWebApi.insertDoc(this.project_id, this.doc.id, {
|
||||
lines: this.doc.lines,
|
||||
version: 0,
|
||||
})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
const change = ranges.comments[0]
|
||||
change.op.should.deep.equal({ c: 'bar', p: 4, t: this.tid })
|
||||
change.id.should.equal(this.tid)
|
||||
done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove the comment range', function (done) {
|
||||
DocUpdaterClient.removeComment(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
this.tid,
|
||||
(error, res) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
expect(res.statusCode).to.equal(204)
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
expect(data.ranges.comments).to.be.undefined
|
||||
done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tripping range size limit', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.id_seed = DocUpdaterClient.randomId()
|
||||
this.doc = {
|
||||
id: DocUpdaterClient.randomId(),
|
||||
lines: ['aaa'],
|
||||
}
|
||||
this.i = new Array(3 * 1024 * 1024).join('a')
|
||||
this.updates = [
|
||||
{
|
||||
doc: this.doc.id,
|
||||
op: [{ i: this.i, p: 1 }],
|
||||
v: 0,
|
||||
meta: { user_id: this.user_id, tc: this.id_seed },
|
||||
},
|
||||
]
|
||||
MockWebApi.insertDoc(this.project_id, this.doc.id, {
|
||||
lines: this.doc.lines,
|
||||
version: 0,
|
||||
})
|
||||
const jobs = []
|
||||
for (const update of this.updates) {
|
||||
jobs.push(callback =>
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
)
|
||||
}
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
async.series(jobs, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not update the ranges', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc.id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
expect(ranges.changes).to.be.undefined
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting text surrounding a comment', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.user_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: ['foo bar baz'],
|
||||
version: 0,
|
||||
ranges: {
|
||||
comments: [
|
||||
{
|
||||
op: {
|
||||
c: 'a',
|
||||
p: 5,
|
||||
tid: (this.tid = DocUpdaterClient.randomId()),
|
||||
},
|
||||
metadata: {
|
||||
user_id: this.user_id,
|
||||
ts: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
this.updates = [
|
||||
{
|
||||
doc: this.doc_id,
|
||||
op: [{ d: 'foo ', p: 0 }],
|
||||
v: 0,
|
||||
meta: { user_id: this.user_id },
|
||||
},
|
||||
{
|
||||
doc: this.doc_id,
|
||||
op: [{ d: 'bar ', p: 0 }],
|
||||
v: 1,
|
||||
meta: { user_id: this.user_id },
|
||||
},
|
||||
]
|
||||
const jobs = []
|
||||
for (const update of this.updates) {
|
||||
jobs.push(callback =>
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
)
|
||||
}
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
async.series(jobs, function (error) {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should write a snapshot from before the destructive change', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, data) => {
|
||||
if (error != null) {
|
||||
return done(error)
|
||||
}
|
||||
db.docSnapshots
|
||||
.find({
|
||||
project_id: new ObjectId(this.project_id),
|
||||
doc_id: new ObjectId(this.doc_id),
|
||||
})
|
||||
.toArray((error, docSnapshots) => {
|
||||
if (error != null) {
|
||||
return done(error)
|
||||
}
|
||||
expect(docSnapshots.length).to.equal(1)
|
||||
expect(docSnapshots[0].version).to.equal(1)
|
||||
expect(docSnapshots[0].lines).to.deep.equal(['bar baz'])
|
||||
expect(docSnapshots[0].ranges.comments[0].op).to.deep.equal({
|
||||
c: 'a',
|
||||
p: 1,
|
||||
tid: this.tid,
|
||||
})
|
||||
done()
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,528 @@
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const docUpdaterRedis = require('@overleaf/redis-wrapper').createClient(
|
||||
Settings.redis.documentupdater
|
||||
)
|
||||
const Keys = Settings.redis.documentupdater.key_schema
|
||||
|
||||
const MockProjectHistoryApi = require('./helpers/MockProjectHistoryApi')
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('Setting a document', function () {
|
||||
let numberOfReceivedUpdates = 0
|
||||
before(function (done) {
|
||||
DocUpdaterClient.subscribeToAppliedOps(() => {
|
||||
numberOfReceivedUpdates++
|
||||
})
|
||||
this.lines = ['one', 'two', 'three']
|
||||
this.version = 42
|
||||
this.update = {
|
||||
doc: this.doc_id,
|
||||
op: [
|
||||
{
|
||||
i: 'one and a half\n',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
v: this.version,
|
||||
}
|
||||
this.result = ['one', 'one and a half', 'two', 'three']
|
||||
this.newLines = ['these', 'are', 'the', 'new', 'lines']
|
||||
this.source = 'dropbox'
|
||||
this.user_id = 'user-id-123'
|
||||
|
||||
sinon.spy(MockProjectHistoryApi, 'flushProject')
|
||||
sinon.spy(MockWebApi, 'setDocument')
|
||||
DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.restore()
|
||||
MockWebApi.setDocument.restore()
|
||||
})
|
||||
|
||||
describe('when the updated doc exists in the doc updater', function () {
|
||||
before(function (done) {
|
||||
numberOfReceivedUpdates = 0
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(() => {
|
||||
DocUpdaterClient.setDocLines(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.newLines,
|
||||
this.source,
|
||||
this.user_id,
|
||||
false,
|
||||
(error, res, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.statusCode = res.statusCode
|
||||
this.body = body
|
||||
done()
|
||||
}
|
||||
)
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.resetHistory()
|
||||
MockWebApi.setDocument.resetHistory()
|
||||
})
|
||||
|
||||
it('should return a 200 status code', function () {
|
||||
this.statusCode.should.equal(200)
|
||||
})
|
||||
|
||||
it('should emit two updates (from sendUpdate and setDocLines)', function () {
|
||||
expect(numberOfReceivedUpdates).to.equal(2)
|
||||
})
|
||||
|
||||
it('should send the updated doc lines and version to the web api', function () {
|
||||
MockWebApi.setDocument
|
||||
.calledWith(this.project_id, this.doc_id, this.newLines)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should update the lines in the doc updater', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
doc.lines.should.deep.equal(this.newLines)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should bump the version in the doc updater', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
doc.version.should.equal(this.version + 2)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should leave the document in redis', function (done) {
|
||||
docUpdaterRedis.get(
|
||||
Keys.docLines({ doc_id: this.doc_id }),
|
||||
(error, lines) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(JSON.parse(lines)).to.deep.equal(this.newLines)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the mongo rev in the json response', function () {
|
||||
this.body.should.deep.equal({ rev: '123' })
|
||||
})
|
||||
|
||||
describe('when doc has the same contents', function () {
|
||||
beforeEach(function (done) {
|
||||
numberOfReceivedUpdates = 0
|
||||
DocUpdaterClient.setDocLines(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.newLines,
|
||||
this.source,
|
||||
this.user_id,
|
||||
false,
|
||||
(error, res, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.statusCode = res.statusCode
|
||||
this.body = body
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not bump the version in doc updater', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
doc.version.should.equal(this.version + 2)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not emit any updates', function (done) {
|
||||
setTimeout(() => {
|
||||
expect(numberOfReceivedUpdates).to.equal(0)
|
||||
done()
|
||||
}, 100) // delay by 100ms: make sure we do not check too early!
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the updated doc does not exist in the doc updater', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
numberOfReceivedUpdates = 0
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
DocUpdaterClient.setDocLines(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.newLines,
|
||||
this.source,
|
||||
this.user_id,
|
||||
false,
|
||||
(error, res, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.statusCode = res.statusCode
|
||||
this.body = body
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.resetHistory()
|
||||
MockWebApi.setDocument.resetHistory()
|
||||
})
|
||||
|
||||
it('should return a 200 status code', function () {
|
||||
this.statusCode.should.equal(200)
|
||||
})
|
||||
|
||||
it('should emit an update', function () {
|
||||
expect(numberOfReceivedUpdates).to.equal(1)
|
||||
})
|
||||
|
||||
it('should send the updated doc lines to the web api', function () {
|
||||
MockWebApi.setDocument
|
||||
.calledWith(this.project_id, this.doc_id, this.newLines)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should flush project history', function () {
|
||||
MockProjectHistoryApi.flushProject
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should remove the document from redis', function (done) {
|
||||
docUpdaterRedis.get(
|
||||
Keys.docLines({ doc_id: this.doc_id }),
|
||||
(error, lines) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
expect(lines).to.not.exist
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the mongo rev in the json response', function () {
|
||||
this.body.should.deep.equal({ rev: '123' })
|
||||
})
|
||||
})
|
||||
|
||||
const DOC_TOO_LARGE_TEST_CASES = [
|
||||
{
|
||||
desc: 'when the updated doc is too large for the body parser',
|
||||
size: Settings.maxJsonRequestSize,
|
||||
expectedStatusCode: 413,
|
||||
},
|
||||
{
|
||||
desc: 'when the updated doc is larger than the HTTP controller limit',
|
||||
size: Settings.max_doc_length,
|
||||
expectedStatusCode: 406,
|
||||
},
|
||||
]
|
||||
|
||||
DOC_TOO_LARGE_TEST_CASES.forEach(testCase => {
|
||||
describe(testCase.desc, function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
this.newLines = []
|
||||
while (JSON.stringify(this.newLines).length <= testCase.size) {
|
||||
this.newLines.push('(a long line of text)'.repeat(10000))
|
||||
}
|
||||
DocUpdaterClient.setDocLines(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.newLines,
|
||||
this.source,
|
||||
this.user_id,
|
||||
false,
|
||||
(error, res, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.statusCode = res.statusCode
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.resetHistory()
|
||||
MockWebApi.setDocument.resetHistory()
|
||||
})
|
||||
|
||||
it(`should return a ${testCase.expectedStatusCode} status code`, function () {
|
||||
this.statusCode.should.equal(testCase.expectedStatusCode)
|
||||
})
|
||||
|
||||
it('should not send the updated doc lines to the web api', function () {
|
||||
MockWebApi.setDocument.called.should.equal(false)
|
||||
})
|
||||
|
||||
it('should not flush project history', function () {
|
||||
MockProjectHistoryApi.flushProject.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the updated doc is large but under the bodyParser and HTTPController size limit', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
|
||||
this.newLines = []
|
||||
while (JSON.stringify(this.newLines).length < 2 * 1024 * 1024) {
|
||||
// limit in HTTPController
|
||||
this.newLines.push('(a long line of text)'.repeat(10000))
|
||||
}
|
||||
this.newLines.pop() // remove the line which took it over the limit
|
||||
DocUpdaterClient.setDocLines(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.newLines,
|
||||
this.source,
|
||||
this.user_id,
|
||||
false,
|
||||
(error, res, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.statusCode = res.statusCode
|
||||
this.body = body
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.resetHistory()
|
||||
MockWebApi.setDocument.resetHistory()
|
||||
})
|
||||
|
||||
it('should return a 200 status code', function () {
|
||||
this.statusCode.should.equal(200)
|
||||
})
|
||||
|
||||
it('should send the updated doc lines to the web api', function () {
|
||||
MockWebApi.setDocument
|
||||
.calledWith(this.project_id, this.doc_id, this.newLines)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the mongo rev in the json response', function () {
|
||||
this.body.should.deep.equal({ rev: '123' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('with track changes', function () {
|
||||
before(function () {
|
||||
this.lines = ['one', 'one and a half', 'two', 'three']
|
||||
this.id_seed = '587357bd35e64f6157'
|
||||
this.update = {
|
||||
doc: this.doc_id,
|
||||
op: [
|
||||
{
|
||||
d: 'one and a half\n',
|
||||
p: 4,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
tc: this.id_seed,
|
||||
user_id: this.user_id,
|
||||
},
|
||||
v: this.version,
|
||||
}
|
||||
})
|
||||
|
||||
describe('with the undo flag', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
// Go back to old lines, with undo flag
|
||||
DocUpdaterClient.setDocLines(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.lines,
|
||||
this.source,
|
||||
this.user_id,
|
||||
true,
|
||||
(error, res, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.statusCode = res.statusCode
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.resetHistory()
|
||||
MockWebApi.setDocument.resetHistory()
|
||||
})
|
||||
|
||||
it('should undo the tracked changes', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, data) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
expect(ranges.changes).to.be.undefined
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('without the undo flag', function () {
|
||||
before(function (done) {
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
})
|
||||
DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.update,
|
||||
error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
// Go back to old lines, without undo flag
|
||||
DocUpdaterClient.setDocLines(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.lines,
|
||||
this.source,
|
||||
this.user_id,
|
||||
false,
|
||||
(error, res, body) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
this.statusCode = res.statusCode
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
after(function () {
|
||||
MockProjectHistoryApi.flushProject.resetHistory()
|
||||
MockWebApi.setDocument.resetHistory()
|
||||
})
|
||||
|
||||
it('should not undo the tracked changes', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, data) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
const { ranges } = data
|
||||
expect(ranges.changes.length).to.equal(1)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
194
services/document-updater/test/acceptance/js/SizeCheckTests.js
Normal file
194
services/document-updater/test/acceptance/js/SizeCheckTests.js
Normal file
@@ -0,0 +1,194 @@
|
||||
const { expect } = require('chai')
|
||||
const Settings = require('@overleaf/settings')
|
||||
|
||||
const MockWebApi = require('./helpers/MockWebApi')
|
||||
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
|
||||
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
|
||||
|
||||
describe('SizeChecks', function () {
|
||||
before(function (done) {
|
||||
DocUpdaterApp.ensureRunning(done)
|
||||
})
|
||||
beforeEach(function () {
|
||||
this.version = 0
|
||||
this.update = {
|
||||
doc: this.doc_id,
|
||||
op: [
|
||||
{
|
||||
i: 'insert some more lines that will bring it above the limit\n',
|
||||
p: 42,
|
||||
},
|
||||
],
|
||||
v: this.version,
|
||||
}
|
||||
this.project_id = DocUpdaterClient.randomId()
|
||||
this.doc_id = DocUpdaterClient.randomId()
|
||||
})
|
||||
|
||||
describe('when a doc is above the doc size limit already', function () {
|
||||
beforeEach(function () {
|
||||
this.lines = ['x'.repeat(Settings.max_doc_length)] // including the extra newline, this will be over the limit
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
v: this.version,
|
||||
})
|
||||
})
|
||||
|
||||
it('should error when fetching the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res) => {
|
||||
if (error) return done(error)
|
||||
expect(res.statusCode).to.equal(500)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when trying to update', function () {
|
||||
beforeEach(function (done) {
|
||||
const update = {
|
||||
doc: this.doc_id,
|
||||
op: this.update.op,
|
||||
v: this.version,
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should still error when fetching the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res) => {
|
||||
if (error) return done(error)
|
||||
expect(res.statusCode).to.equal(500)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the stringified JSON is above the doc size limit but the doc character count is not', function () {
|
||||
beforeEach(function () {
|
||||
let charsRemaining = Settings.max_doc_length
|
||||
this.lines = []
|
||||
// Take the maximum allowed doc length and split it into N lines of 63 characters + a newline.
|
||||
// The character count will be exactly max_doc_length
|
||||
// The JSON stringified size will exceed max_doc_length, due to the JSON formatting of the array.
|
||||
// This document should be allowed, because we use the character count as the limit, not the JSON size.
|
||||
while (charsRemaining > 0) {
|
||||
const charstoAdd = Math.min(charsRemaining - 1, 63) // allow for additional newline
|
||||
this.lines.push('x'.repeat(charstoAdd))
|
||||
charsRemaining -= charstoAdd + 1
|
||||
}
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
v: this.version,
|
||||
})
|
||||
})
|
||||
|
||||
it('should be able to fetch the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
expect(doc.lines).to.deep.equal(this.lines)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('when trying to update', function () {
|
||||
beforeEach(function (done) {
|
||||
const update = {
|
||||
doc: this.doc_id,
|
||||
op: this.update.op,
|
||||
v: this.version,
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not update the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
expect(doc.lines).to.deep.equal(this.lines)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a doc is just below the doc size limit', function () {
|
||||
beforeEach(function () {
|
||||
this.lines = ['x'.repeat(Settings.max_doc_length - 1)] // character count is exactly max_doc_length after including the newline
|
||||
MockWebApi.insertDoc(this.project_id, this.doc_id, {
|
||||
lines: this.lines,
|
||||
v: this.version,
|
||||
})
|
||||
})
|
||||
|
||||
it('should be able to fetch the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
expect(doc.lines).to.deep.equal(this.lines)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('when trying to update', function () {
|
||||
beforeEach(function (done) {
|
||||
const update = {
|
||||
doc: this.doc_id,
|
||||
op: this.update.op,
|
||||
v: this.version,
|
||||
}
|
||||
DocUpdaterClient.sendUpdate(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
update,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
setTimeout(done, 200)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not update the doc', function (done) {
|
||||
DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, doc) => {
|
||||
if (error) return done(error)
|
||||
expect(doc.lines).to.deep.equal(this.lines)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,42 @@
|
||||
// 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
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const app = require('../../../../app')
|
||||
|
||||
module.exports = {
|
||||
running: false,
|
||||
initing: false,
|
||||
callbacks: [],
|
||||
ensureRunning(callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
if (this.running) {
|
||||
return callback()
|
||||
} else if (this.initing) {
|
||||
return this.callbacks.push(callback)
|
||||
}
|
||||
this.initing = true
|
||||
this.callbacks.push(callback)
|
||||
app.listen(3003, '127.0.0.1', error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
this.running = true
|
||||
return (() => {
|
||||
const result = []
|
||||
for (callback of Array.from(this.callbacks)) {
|
||||
result.push(callback())
|
||||
}
|
||||
return result
|
||||
})()
|
||||
})
|
||||
},
|
||||
}
|
@@ -0,0 +1,246 @@
|
||||
let DocUpdaterClient
|
||||
const Settings = require('@overleaf/settings')
|
||||
const _ = require('lodash')
|
||||
const rclient = require('@overleaf/redis-wrapper').createClient(
|
||||
Settings.redis.documentupdater
|
||||
)
|
||||
const keys = Settings.redis.documentupdater.key_schema
|
||||
const request = require('request').defaults({ jar: false })
|
||||
const async = require('async')
|
||||
|
||||
const rclientSub = require('@overleaf/redis-wrapper').createClient(
|
||||
Settings.redis.pubsub
|
||||
)
|
||||
rclientSub.subscribe('applied-ops')
|
||||
rclientSub.setMaxListeners(0)
|
||||
|
||||
module.exports = DocUpdaterClient = {
|
||||
randomId() {
|
||||
let str = ''
|
||||
for (let i = 0; i < 24; i++) {
|
||||
str += Math.floor(Math.random() * 16).toString(16)
|
||||
}
|
||||
return str
|
||||
},
|
||||
|
||||
subscribeToAppliedOps(callback) {
|
||||
rclientSub.on('message', callback)
|
||||
},
|
||||
|
||||
_getPendingUpdateListKey() {
|
||||
const shard = _.random(0, Settings.dispatcherCount - 1)
|
||||
if (shard === 0) {
|
||||
return 'pending-updates-list'
|
||||
} else {
|
||||
return `pending-updates-list-${shard}`
|
||||
}
|
||||
},
|
||||
|
||||
sendUpdate(projectId, docId, update, callback) {
|
||||
rclient.rpush(
|
||||
keys.pendingUpdates({ doc_id: docId }),
|
||||
JSON.stringify(update),
|
||||
error => {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
const docKey = `${projectId}:${docId}`
|
||||
rclient.sadd('DocsWithPendingUpdates', docKey, error => {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
|
||||
rclient.rpush(
|
||||
DocUpdaterClient._getPendingUpdateListKey(),
|
||||
docKey,
|
||||
callback
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
sendUpdates(projectId, docId, updates, callback) {
|
||||
DocUpdaterClient.preloadDoc(projectId, docId, error => {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
const jobs = updates.map(update => callback => {
|
||||
DocUpdaterClient.sendUpdate(projectId, docId, update, callback)
|
||||
})
|
||||
async.series(jobs, err => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
DocUpdaterClient.waitForPendingUpdates(projectId, docId, callback)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
waitForPendingUpdates(projectId, docId, callback) {
|
||||
async.retry(
|
||||
{ times: 30, interval: 100 },
|
||||
cb =>
|
||||
rclient.llen(keys.pendingUpdates({ doc_id: docId }), (err, length) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
if (length > 0) {
|
||||
cb(new Error('updates still pending'))
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
}),
|
||||
callback
|
||||
)
|
||||
},
|
||||
|
||||
getDoc(projectId, docId, callback) {
|
||||
request.get(
|
||||
`http://127.0.0.1:3003/project/${projectId}/doc/${docId}`,
|
||||
(error, res, body) => {
|
||||
if (body != null && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
body = JSON.parse(body)
|
||||
}
|
||||
callback(error, res, body)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
getDocAndRecentOps(projectId, docId, fromVersion, callback) {
|
||||
request.get(
|
||||
`http://127.0.0.1:3003/project/${projectId}/doc/${docId}?fromVersion=${fromVersion}`,
|
||||
(error, res, body) => {
|
||||
if (body != null && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
body = JSON.parse(body)
|
||||
}
|
||||
callback(error, res, body)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
getProjectLastUpdatedAt(projectId, callback) {
|
||||
request.get(
|
||||
`http://127.0.0.1:3003/project/${projectId}/last_updated_at`,
|
||||
(error, res, body) => {
|
||||
if (body != null && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
body = JSON.parse(body)
|
||||
}
|
||||
callback(error, res, body)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
preloadDoc(projectId, docId, callback) {
|
||||
DocUpdaterClient.getDoc(projectId, docId, callback)
|
||||
},
|
||||
|
||||
peekDoc(projectId, docId, callback) {
|
||||
request.get(
|
||||
`http://127.0.0.1:3003/project/${projectId}/doc/${docId}/peek`,
|
||||
(error, res, body) => {
|
||||
if (body != null && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
body = JSON.parse(body)
|
||||
}
|
||||
callback(error, res, body)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
flushDoc(projectId, docId, callback) {
|
||||
request.post(
|
||||
`http://127.0.0.1:3003/project/${projectId}/doc/${docId}/flush`,
|
||||
(error, res, body) => callback(error, res, body)
|
||||
)
|
||||
},
|
||||
|
||||
setDocLines(projectId, docId, lines, source, userId, undoing, callback) {
|
||||
request.post(
|
||||
{
|
||||
url: `http://127.0.0.1:3003/project/${projectId}/doc/${docId}`,
|
||||
json: {
|
||||
lines,
|
||||
source,
|
||||
user_id: userId,
|
||||
undoing,
|
||||
},
|
||||
},
|
||||
(error, res, body) => callback(error, res, body)
|
||||
)
|
||||
},
|
||||
|
||||
deleteDoc(projectId, docId, callback) {
|
||||
request.del(
|
||||
`http://127.0.0.1:3003/project/${projectId}/doc/${docId}`,
|
||||
(error, res, body) => callback(error, res, body)
|
||||
)
|
||||
},
|
||||
|
||||
flushProject(projectId, callback) {
|
||||
request.post(`http://127.0.0.1:3003/project/${projectId}/flush`, callback)
|
||||
},
|
||||
|
||||
deleteProject(projectId, callback) {
|
||||
request.del(`http://127.0.0.1:3003/project/${projectId}`, callback)
|
||||
},
|
||||
|
||||
deleteProjectOnShutdown(projectId, callback) {
|
||||
request.del(
|
||||
`http://127.0.0.1:3003/project/${projectId}?background=true&shutdown=true`,
|
||||
callback
|
||||
)
|
||||
},
|
||||
|
||||
flushOldProjects(callback) {
|
||||
request.get(
|
||||
'http://127.0.0.1:3003/flush_queued_projects?min_delete_age=1',
|
||||
callback
|
||||
)
|
||||
},
|
||||
|
||||
acceptChange(projectId, docId, changeId, callback) {
|
||||
request.post(
|
||||
`http://127.0.0.1:3003/project/${projectId}/doc/${docId}/change/${changeId}/accept`,
|
||||
callback
|
||||
)
|
||||
},
|
||||
|
||||
acceptChanges(projectId, docId, changeIds, callback) {
|
||||
request.post(
|
||||
{
|
||||
url: `http://127.0.0.1:3003/project/${projectId}/doc/${docId}/change/accept`,
|
||||
json: { change_ids: changeIds },
|
||||
},
|
||||
callback
|
||||
)
|
||||
},
|
||||
|
||||
removeComment(projectId, docId, comment, callback) {
|
||||
request.del(
|
||||
`http://127.0.0.1:3003/project/${projectId}/doc/${docId}/comment/${comment}`,
|
||||
callback
|
||||
)
|
||||
},
|
||||
|
||||
getProjectDocs(projectId, projectStateHash, callback) {
|
||||
request.get(
|
||||
`http://127.0.0.1:3003/project/${projectId}/doc?state=${projectStateHash}`,
|
||||
(error, res, body) => {
|
||||
if (body != null && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
body = JSON.parse(body)
|
||||
}
|
||||
callback(error, res, body)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
sendProjectUpdate(projectId, userId, updates, version, callback) {
|
||||
request.post(
|
||||
{
|
||||
url: `http://127.0.0.1:3003/project/${projectId}`,
|
||||
json: { userId, updates, version },
|
||||
},
|
||||
(error, res, body) => callback(error, res, body)
|
||||
)
|
||||
},
|
||||
}
|
@@ -0,0 +1,111 @@
|
||||
const express = require('express')
|
||||
const bodyParser = require('body-parser')
|
||||
const app = express()
|
||||
const MAX_REQUEST_SIZE = 2 * (2 * 1024 * 1024 + 64 * 1024)
|
||||
|
||||
const MockDocstoreApi = {
|
||||
docs: {},
|
||||
|
||||
clearDocs() {
|
||||
this.docs = {}
|
||||
},
|
||||
|
||||
getDoc(projectId, docId) {
|
||||
return this.docs[`${projectId}:${docId}`]
|
||||
},
|
||||
|
||||
insertDoc(projectId, docId, doc) {
|
||||
if (doc.version == null) {
|
||||
doc.version = 0
|
||||
}
|
||||
if (doc.lines == null) {
|
||||
doc.lines = []
|
||||
}
|
||||
this.docs[`${projectId}:${docId}`] = doc
|
||||
},
|
||||
|
||||
patchDocument(projectId, docId, meta, callback) {
|
||||
Object.assign(this.docs[`${projectId}:${docId}`], meta)
|
||||
callback(null)
|
||||
},
|
||||
|
||||
peekDocument(projectId, docId, callback) {
|
||||
callback(null, this.docs[`${projectId}:${docId}`])
|
||||
},
|
||||
|
||||
getAllDeletedDocs(projectId, callback) {
|
||||
callback(
|
||||
null,
|
||||
Object.entries(this.docs)
|
||||
.filter(([key, doc]) => key.startsWith(projectId) && doc.deleted)
|
||||
.map(([key, doc]) => {
|
||||
return {
|
||||
_id: key.split(':')[1],
|
||||
name: doc.name,
|
||||
deletedAt: doc.deletedAt,
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
run() {
|
||||
app.get('/project/:project_id/doc-deleted', (req, res, next) => {
|
||||
this.getAllDeletedDocs(req.params.project_id, (error, docs) => {
|
||||
if (error) {
|
||||
res.sendStatus(500)
|
||||
} else {
|
||||
res.json(docs)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/project/:project_id/doc/:doc_id/peek', (req, res, next) => {
|
||||
this.peekDocument(
|
||||
req.params.project_id,
|
||||
req.params.doc_id,
|
||||
(error, doc) => {
|
||||
if (error) {
|
||||
res.sendStatus(500)
|
||||
} else if (doc) {
|
||||
res.json(doc)
|
||||
} else {
|
||||
res.sendStatus(404)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
app.patch(
|
||||
'/project/:project_id/doc/:doc_id',
|
||||
bodyParser.json({ limit: MAX_REQUEST_SIZE }),
|
||||
(req, res, next) => {
|
||||
MockDocstoreApi.patchDocument(
|
||||
req.params.project_id,
|
||||
req.params.doc_id,
|
||||
req.body,
|
||||
error => {
|
||||
if (error) {
|
||||
res.sendStatus(500)
|
||||
} else {
|
||||
res.sendStatus(204)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
app
|
||||
.listen(3016, error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
.on('error', error => {
|
||||
console.error('error starting MockDocstoreApi:', error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
MockDocstoreApi.run()
|
||||
module.exports = MockDocstoreApi
|
@@ -0,0 +1,40 @@
|
||||
// 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
|
||||
*/
|
||||
let MockProjectHistoryApi
|
||||
const express = require('express')
|
||||
const app = express()
|
||||
|
||||
module.exports = MockProjectHistoryApi = {
|
||||
flushProject(docId, callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
|
||||
run() {
|
||||
app.post('/project/:project_id/flush', (req, res, next) => {
|
||||
return this.flushProject(req.params.project_id, error => {
|
||||
if (error != null) {
|
||||
return res.sendStatus(500)
|
||||
} else {
|
||||
return res.sendStatus(204)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return app.listen(3054, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
MockProjectHistoryApi.run()
|
@@ -0,0 +1,121 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
*/
|
||||
// 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
|
||||
*/
|
||||
let MockWebApi
|
||||
const express = require('express')
|
||||
const bodyParser = require('body-parser')
|
||||
const app = express()
|
||||
const MAX_REQUEST_SIZE = 2 * (2 * 1024 * 1024 + 64 * 1024)
|
||||
|
||||
module.exports = MockWebApi = {
|
||||
docs: {},
|
||||
|
||||
clearDocs() {
|
||||
return (this.docs = {})
|
||||
},
|
||||
|
||||
insertDoc(projectId, docId, doc) {
|
||||
if (doc.version == null) {
|
||||
doc.version = 0
|
||||
}
|
||||
if (doc.lines == null) {
|
||||
doc.lines = []
|
||||
}
|
||||
doc.pathname = '/a/b/c.tex'
|
||||
return (this.docs[`${projectId}:${docId}`] = doc)
|
||||
},
|
||||
|
||||
setDocument(
|
||||
projectId,
|
||||
docId,
|
||||
lines,
|
||||
version,
|
||||
ranges,
|
||||
lastUpdatedAt,
|
||||
lastUpdatedBy,
|
||||
callback
|
||||
) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
const doc =
|
||||
this.docs[`${projectId}:${docId}`] ||
|
||||
(this.docs[`${projectId}:${docId}`] = {})
|
||||
doc.lines = lines
|
||||
doc.version = version
|
||||
doc.ranges = ranges
|
||||
doc.pathname = '/a/b/c.tex'
|
||||
doc.lastUpdatedAt = lastUpdatedAt
|
||||
doc.lastUpdatedBy = lastUpdatedBy
|
||||
return callback(null)
|
||||
},
|
||||
|
||||
getDocument(projectId, docId, callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
return callback(null, this.docs[`${projectId}:${docId}`])
|
||||
},
|
||||
|
||||
run() {
|
||||
app.get('/project/:project_id/doc/:doc_id', (req, res, next) => {
|
||||
return this.getDocument(
|
||||
req.params.project_id,
|
||||
req.params.doc_id,
|
||||
(error, doc) => {
|
||||
if (error != null) {
|
||||
return res.sendStatus(500)
|
||||
} else if (doc != null) {
|
||||
return res.send(JSON.stringify(doc))
|
||||
} else {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
app.post(
|
||||
'/project/:project_id/doc/:doc_id',
|
||||
bodyParser.json({ limit: MAX_REQUEST_SIZE }),
|
||||
(req, res, next) => {
|
||||
return MockWebApi.setDocument(
|
||||
req.params.project_id,
|
||||
req.params.doc_id,
|
||||
req.body.lines,
|
||||
req.body.version,
|
||||
req.body.ranges,
|
||||
req.body.lastUpdatedAt,
|
||||
req.body.lastUpdatedBy,
|
||||
error => {
|
||||
if (error != null) {
|
||||
return res.sendStatus(500)
|
||||
} else {
|
||||
return res.json({ rev: '123' })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return app
|
||||
.listen(3000, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
.on('error', error => {
|
||||
console.error('error starting MockWebApi:', error.message)
|
||||
return process.exit(1)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
MockWebApi.run()
|
@@ -0,0 +1,65 @@
|
||||
let listenInBackground, sendPings
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient1 = redis.createClient({
|
||||
cluster: [
|
||||
{
|
||||
port: '7000',
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const rclient2 = redis.createClient({
|
||||
cluster: [
|
||||
{
|
||||
port: '7000',
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
let counter = 0
|
||||
const sendPing = function (cb) {
|
||||
if (cb == null) {
|
||||
cb = function () {}
|
||||
}
|
||||
return rclient1.rpush('test-blpop', counter, error => {
|
||||
if (error != null) {
|
||||
console.error('[SENDING ERROR]', error.message)
|
||||
}
|
||||
if (error == null) {
|
||||
counter += 1
|
||||
}
|
||||
return cb()
|
||||
})
|
||||
}
|
||||
|
||||
let previous = null
|
||||
const listenForPing = cb =>
|
||||
rclient2.blpop('test-blpop', 200, (error, result) => {
|
||||
if (error != null) {
|
||||
return cb(error)
|
||||
}
|
||||
let [, value] = Array.from(result)
|
||||
value = parseInt(value, 10)
|
||||
if (value % 10 === 0) {
|
||||
console.log('.')
|
||||
}
|
||||
if (previous != null && value !== previous + 1) {
|
||||
error = new Error(
|
||||
`Counter not in order. Got ${value}, expected ${previous + 1}`
|
||||
)
|
||||
}
|
||||
previous = value
|
||||
return cb(error, value)
|
||||
})
|
||||
|
||||
const PING_DELAY = 100
|
||||
;(sendPings = () => sendPing(() => setTimeout(sendPings, PING_DELAY)))()
|
||||
;(listenInBackground = () =>
|
||||
listenForPing(error => {
|
||||
if (error) {
|
||||
console.error('[RECEIVING ERROR]', error.message)
|
||||
}
|
||||
return setTimeout(listenInBackground)
|
||||
}))()
|
@@ -0,0 +1,54 @@
|
||||
let sendPings
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient1 = redis.createClient({
|
||||
cluster: [
|
||||
{
|
||||
port: '7000',
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const rclient2 = redis.createClient({
|
||||
cluster: [
|
||||
{
|
||||
port: '7000',
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
let counter = 0
|
||||
const sendPing = function (cb) {
|
||||
if (cb == null) {
|
||||
cb = function () {}
|
||||
}
|
||||
return rclient1.publish('test-pubsub', counter, error => {
|
||||
if (error) {
|
||||
console.error('[SENDING ERROR]', error.message)
|
||||
}
|
||||
if (error == null) {
|
||||
counter += 1
|
||||
}
|
||||
return cb()
|
||||
})
|
||||
}
|
||||
|
||||
let previous = null
|
||||
rclient2.subscribe('test-pubsub')
|
||||
rclient2.on('message', (channel, value) => {
|
||||
value = parseInt(value, 10)
|
||||
if (value % 10 === 0) {
|
||||
console.log('.')
|
||||
}
|
||||
if (previous != null && value !== previous + 1) {
|
||||
console.error(
|
||||
'[RECEIVING ERROR]',
|
||||
`Counter not in order. Got ${value}, expected ${previous + 1}`
|
||||
)
|
||||
}
|
||||
return (previous = value)
|
||||
})
|
||||
|
||||
const PING_DELAY = 100
|
||||
;(sendPings = () => sendPing(() => setTimeout(sendPings, PING_DELAY)))()
|
52
services/document-updater/test/setup.js
Normal file
52
services/document-updater/test/setup.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const chai = require('chai')
|
||||
const chaiAsPromised = require('chai-as-promised')
|
||||
const sinonChai = require('sinon-chai')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
|
||||
// ensure every ObjectId has the id string as a property for correct comparisons
|
||||
require('mongodb-legacy').ObjectId.cacheHexString = true
|
||||
|
||||
// Chai configuration
|
||||
chai.should()
|
||||
chai.use(chaiAsPromised)
|
||||
// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc')
|
||||
// has a nicer failure messages
|
||||
chai.use(sinonChai)
|
||||
|
||||
// Global stubs
|
||||
const sandbox = sinon.createSandbox()
|
||||
const stubs = {
|
||||
logger: {
|
||||
debug: sandbox.stub(),
|
||||
log: sandbox.stub(),
|
||||
warn: sandbox.stub(),
|
||||
err: sandbox.stub(),
|
||||
error: sandbox.stub(),
|
||||
},
|
||||
}
|
||||
|
||||
// SandboxedModule configuration
|
||||
SandboxedModule.configure({
|
||||
requires: {
|
||||
'@overleaf/logger': stubs.logger,
|
||||
'mongodb-legacy': require('mongodb-legacy'), // for ObjectId comparisons
|
||||
},
|
||||
globals: { Buffer, JSON, Math, console, process },
|
||||
sourceTransformers: {
|
||||
removeNodePrefix: function (source) {
|
||||
return source.replace(/require\(['"]node:/g, "require('")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mocha hooks
|
||||
exports.mochaHooks = {
|
||||
beforeEach() {
|
||||
this.logger = stubs.logger
|
||||
},
|
||||
|
||||
afterEach() {
|
||||
sandbox.reset()
|
||||
},
|
||||
}
|
387
services/document-updater/test/stress/js/run.js
Normal file
387
services/document-updater/test/stress/js/run.js
Normal file
@@ -0,0 +1,387 @@
|
||||
/* 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
|
||||
* DS202: Simplify dynamic range loops
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const DocUpdaterClient = require('../../acceptance/js/helpers/DocUpdaterClient')
|
||||
// MockWebApi = require "../../acceptance/js/helpers/MockWebApi"
|
||||
const assert = require('node:assert')
|
||||
const async = require('async')
|
||||
|
||||
const insert = function (string, pos, content) {
|
||||
const result = string.slice(0, pos) + content + string.slice(pos)
|
||||
return result
|
||||
}
|
||||
|
||||
const transform = function (op1, op2) {
|
||||
if (op2.p < op1.p) {
|
||||
return {
|
||||
p: op1.p + op2.i.length,
|
||||
i: op1.i,
|
||||
}
|
||||
} else {
|
||||
return op1
|
||||
}
|
||||
}
|
||||
|
||||
class StressTestClient {
|
||||
constructor(options) {
|
||||
if (options == null) {
|
||||
options = {}
|
||||
}
|
||||
this.options = options
|
||||
if (this.options.updateDelay == null) {
|
||||
this.options.updateDelay = 200
|
||||
}
|
||||
this.project_id = this.options.project_id || DocUpdaterClient.randomId()
|
||||
this.doc_id = this.options.doc_id || DocUpdaterClient.randomId()
|
||||
this.pos = this.options.pos || 0
|
||||
this.content = this.options.content || ''
|
||||
|
||||
this.client_id = DocUpdaterClient.randomId()
|
||||
this.version = this.options.version || 0
|
||||
this.inflight_op = null
|
||||
this.charCode = 0
|
||||
|
||||
this.counts = {
|
||||
conflicts: 0,
|
||||
local_updates: 0,
|
||||
remote_updates: 0,
|
||||
max_delay: 0,
|
||||
}
|
||||
|
||||
DocUpdaterClient.subscribeToAppliedOps((channel, update) => {
|
||||
update = JSON.parse(update)
|
||||
if (update.error != null) {
|
||||
console.error(new Error(`Error from server: '${update.error}'`))
|
||||
return
|
||||
}
|
||||
if (update.doc_id === this.doc_id) {
|
||||
return this.processReply(update)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sendUpdate() {
|
||||
const data = String.fromCharCode(65 + (this.charCode++ % 26))
|
||||
this.content = insert(this.content, this.pos, data)
|
||||
this.inflight_op = {
|
||||
i: data,
|
||||
p: this.pos++,
|
||||
}
|
||||
this.resendUpdate()
|
||||
return (this.inflight_op_sent = Date.now())
|
||||
}
|
||||
|
||||
resendUpdate() {
|
||||
assert(this.inflight_op != null)
|
||||
DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, {
|
||||
doc: this.doc_id,
|
||||
op: [this.inflight_op],
|
||||
v: this.version,
|
||||
meta: {
|
||||
source: this.client_id,
|
||||
},
|
||||
dupIfSource: [this.client_id],
|
||||
})
|
||||
return (this.update_timer = setTimeout(() => {
|
||||
console.log(
|
||||
`[${new Date()}] \t[${this.client_id.slice(
|
||||
0,
|
||||
4
|
||||
)}] WARN: Resending update after 5 seconds`
|
||||
)
|
||||
return this.resendUpdate()
|
||||
}, 5000))
|
||||
}
|
||||
|
||||
processReply(update) {
|
||||
if (update.op.v !== this.version) {
|
||||
if (update.op.v < this.version) {
|
||||
console.log(
|
||||
`[${new Date()}] \t[${this.client_id.slice(
|
||||
0,
|
||||
4
|
||||
)}] WARN: Duplicate ack (already seen version)`
|
||||
)
|
||||
return
|
||||
} else {
|
||||
console.error(
|
||||
`[${new Date()}] \t[${this.client_id.slice(
|
||||
0,
|
||||
4
|
||||
)}] ERROR: Version jumped ahead (client: ${this.version}, op: ${
|
||||
update.op.v
|
||||
})`
|
||||
)
|
||||
}
|
||||
}
|
||||
this.version++
|
||||
if (update.op.meta.source === this.client_id) {
|
||||
if (this.inflight_op != null) {
|
||||
this.counts.local_updates++
|
||||
this.inflight_op = null
|
||||
clearTimeout(this.update_timer)
|
||||
const delay = Date.now() - this.inflight_op_sent
|
||||
this.counts.max_delay = Math.max(this.counts.max_delay, delay)
|
||||
return this.continue()
|
||||
} else {
|
||||
return console.log(
|
||||
`[${new Date()}] \t[${this.client_id.slice(
|
||||
0,
|
||||
4
|
||||
)}] WARN: Duplicate ack`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
assert(update.op.op.length === 1)
|
||||
this.counts.remote_updates++
|
||||
let externalOp = update.op.op[0]
|
||||
if (this.inflight_op != null) {
|
||||
this.counts.conflicts++
|
||||
this.inflight_op = transform(this.inflight_op, externalOp)
|
||||
externalOp = transform(externalOp, this.inflight_op)
|
||||
}
|
||||
if (externalOp.p < this.pos) {
|
||||
this.pos += externalOp.i.length
|
||||
}
|
||||
return (this.content = insert(this.content, externalOp.p, externalOp.i))
|
||||
}
|
||||
}
|
||||
|
||||
continue() {
|
||||
if (this.updateCount > 0) {
|
||||
this.updateCount--
|
||||
return setTimeout(
|
||||
() => {
|
||||
return this.sendUpdate()
|
||||
},
|
||||
this.options.updateDelay * (0.5 + Math.random())
|
||||
)
|
||||
} else {
|
||||
return this.updateCallback()
|
||||
}
|
||||
}
|
||||
|
||||
runForNUpdates(n, callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
this.updateCallback = callback
|
||||
this.updateCount = n
|
||||
return this.continue()
|
||||
}
|
||||
|
||||
check(callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
return DocUpdaterClient.getDoc(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
(error, res, body) => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
if (body.lines == null) {
|
||||
return console.error(
|
||||
`[${new Date()}] \t[${this.client_id.slice(
|
||||
0,
|
||||
4
|
||||
)}] ERROR: Invalid response from get doc (${this.doc_id})`,
|
||||
body
|
||||
)
|
||||
}
|
||||
const content = body.lines.join('\n')
|
||||
const { version } = body
|
||||
if (content !== this.content) {
|
||||
if (version === this.version) {
|
||||
console.error(
|
||||
`[${new Date()}] \t[${this.client_id.slice(
|
||||
0,
|
||||
4
|
||||
)}] Error: Client content does not match server.`
|
||||
)
|
||||
console.error(`Server: ${content.split('a')}`)
|
||||
console.error(`Client: ${this.content.split('a')}`)
|
||||
} else {
|
||||
console.error(
|
||||
`[${new Date()}] \t[${this.client_id.slice(
|
||||
0,
|
||||
4
|
||||
)}] Error: Version mismatch (Server: '${version}', Client: '${
|
||||
this.version
|
||||
}')`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isContentValid(this.content)) {
|
||||
const iterable = this.content.split('')
|
||||
for (let i = 0; i < iterable.length; i++) {
|
||||
const chunk = iterable[i]
|
||||
if (chunk != null && chunk !== 'a') {
|
||||
console.log(chunk, i)
|
||||
}
|
||||
}
|
||||
throw new Error('bad content')
|
||||
}
|
||||
return callback()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
isChunkValid(chunk) {
|
||||
const char = 0
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const letter = chunk[i]
|
||||
if (letter.charCodeAt(0) !== 65 + (i % 26)) {
|
||||
console.error(
|
||||
`[${new Date()}] \t[${this.client_id.slice(0, 4)}] Invalid Chunk:`,
|
||||
chunk
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
isContentValid(content) {
|
||||
for (const chunk of Array.from(content.split('a'))) {
|
||||
if (chunk != null && chunk !== '') {
|
||||
if (!this.isChunkValid(chunk)) {
|
||||
console.error(
|
||||
`[${new Date()}] \t[${this.client_id.slice(0, 4)}] Invalid content`,
|
||||
content
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const checkDocument = function (projectId, docId, clients, callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
const jobs = clients.map(client => cb => client.check(cb))
|
||||
return async.parallel(jobs, callback)
|
||||
}
|
||||
|
||||
const printSummary = function (docId, clients) {
|
||||
const slot = require('cluster-key-slot')
|
||||
const now = new Date()
|
||||
console.log(
|
||||
`[${now}] [${docId.slice(0, 4)} (slot: ${slot(docId)})] ${
|
||||
clients.length
|
||||
} clients...`
|
||||
)
|
||||
return (() => {
|
||||
const result = []
|
||||
for (const client of Array.from(clients)) {
|
||||
console.log(
|
||||
`[${now}] \t[${client.client_id.slice(0, 4)}] { local: ${
|
||||
client.counts.local_updates
|
||||
}, remote: ${client.counts.remote_updates}, conflicts: ${
|
||||
client.counts.conflicts
|
||||
}, max_delay: ${client.counts.max_delay} }`
|
||||
)
|
||||
result.push(
|
||||
(client.counts = {
|
||||
local_updates: 0,
|
||||
remote_updates: 0,
|
||||
conflicts: 0,
|
||||
max_delay: 0,
|
||||
})
|
||||
)
|
||||
}
|
||||
return result
|
||||
})()
|
||||
}
|
||||
|
||||
const CLIENT_COUNT = parseInt(process.argv[2], 10)
|
||||
const UPDATE_DELAY = parseInt(process.argv[3], 10)
|
||||
const SAMPLE_INTERVAL = parseInt(process.argv[4], 10)
|
||||
|
||||
for (const docAndProjectId of Array.from(process.argv.slice(5))) {
|
||||
;(function (docAndProjectId) {
|
||||
const [projectId, docId] = Array.from(docAndProjectId.split(':'))
|
||||
console.log({ projectId, docId })
|
||||
return DocUpdaterClient.setDocLines(
|
||||
projectId,
|
||||
docId,
|
||||
[new Array(CLIENT_COUNT + 2).join('a')],
|
||||
null,
|
||||
null,
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return DocUpdaterClient.getDoc(projectId, docId, (error, res, body) => {
|
||||
let runBatch
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
if (body.lines == null) {
|
||||
return console.error(
|
||||
`[${new Date()}] ERROR: Invalid response from get doc (${docId})`,
|
||||
body
|
||||
)
|
||||
}
|
||||
const content = body.lines.join('\n')
|
||||
const { version } = body
|
||||
|
||||
const clients = []
|
||||
for (
|
||||
let pos = 1, end = CLIENT_COUNT, asc = end >= 1;
|
||||
asc ? pos <= end : pos >= end;
|
||||
asc ? pos++ : pos--
|
||||
) {
|
||||
;(function (pos) {
|
||||
const client = new StressTestClient({
|
||||
doc_id: docId,
|
||||
project_id: projectId,
|
||||
content,
|
||||
pos,
|
||||
version,
|
||||
updateDelay: UPDATE_DELAY,
|
||||
})
|
||||
return clients.push(client)
|
||||
})(pos)
|
||||
}
|
||||
|
||||
return (runBatch = function () {
|
||||
const jobs = clients.map(
|
||||
client => cb =>
|
||||
client.runForNUpdates(SAMPLE_INTERVAL / UPDATE_DELAY, cb)
|
||||
)
|
||||
return async.parallel(jobs, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
printSummary(docId, clients)
|
||||
return checkDocument(projectId, docId, clients, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return runBatch()
|
||||
})
|
||||
})
|
||||
})()
|
||||
})
|
||||
}
|
||||
)
|
||||
})(docAndProjectId)
|
||||
}
|
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
@@ -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
@@ -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
|
||||
}
|
@@ -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
84
services/document-updater/test/unit/js/Limits/LimitsTests.js
Normal file
84
services/document-updater/test/unit/js/Limits/LimitsTests.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
@@ -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
@@ -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
|
||||
})()
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@@ -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')
|
||||
}
|
54
services/document-updater/test/unit/js/UtilsTests.js
Normal file
54
services/document-updater/test/unit/js/UtilsTests.js
Normal 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')
|
||||
}
|
Reference in New Issue
Block a user