overleaf/services/document-updater/test/unit/js/HttpController/HttpControllerTests.js
Eric Mc Sween 9799b94752 Accept ordered doc and file updates
Add an `updates` parameter to the project update endpoint. It can be
used instead of `docUpdates` and `fileUpdates` to provide a single list
of updates in the order they should be processed.
2020-05-20 07:57:32 -04:00

1027 lines
30 KiB
JavaScript

const sinon = require('sinon')
const modulePath = '../../../../app/js/HttpController.js'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/js/Errors.js')
describe('HttpController', function () {
beforeEach(function () {
this.HttpController = SandboxedModule.require(modulePath, {
requires: {
'./DocumentManager': (this.DocumentManager = {}),
'./HistoryManager': (this.HistoryManager = {
flushProjectChangesAsync: sinon.stub()
}),
'./ProjectManager': (this.ProjectManager = {}),
'logger-sharelatex': (this.logger = { log: sinon.stub() }),
'./ProjectFlusher': { flushAllProjects() {} },
'./DeleteQueueManager': (this.DeleteQueueManager = {}),
'./Metrics': (this.Metrics = {}),
'./Errors': Errors
}
})
this.Metrics.Timer = class Timer {}
this.Metrics.Timer.prototype.done = sinon.stub()
this.project_id = 'project-id-123'
this.doc_id = 'doc-id-123'
this.next = sinon.stub()
this.res = {
send: sinon.stub(),
sendStatus: sinon.stub(),
json: sinon.stub()
}
})
describe('getDoc', function () {
beforeEach(function () {
this.lines = ['one', 'two', 'three']
this.ops = ['mock-op-1', 'mock-op-2']
this.version = 42
this.fromVersion = 42
this.ranges = { changes: 'mock', comments: 'mock' }
this.pathname = '/a/b/c'
this.req = {
params: {
project_id: this.project_id,
doc_id: this.doc_id
},
query: {},
body: {}
}
})
describe('when the document exists and no recent ops are requested', function () {
beforeEach(function () {
this.DocumentManager.getDocAndRecentOpsWithLock = sinon
.stub()
.callsArgWith(
3,
null,
this.lines,
this.version,
[],
this.ranges,
this.pathname
)
this.HttpController.getDoc(this.req, this.res, this.next)
})
it('should get the doc', function () {
this.DocumentManager.getDocAndRecentOpsWithLock
.calledWith(this.project_id, this.doc_id, -1)
.should.equal(true)
})
it('should return the doc as JSON', function () {
this.res.json
.calledWith({
id: this.doc_id,
lines: this.lines,
version: this.version,
ops: [],
ranges: this.ranges,
pathname: this.pathname
})
.should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{ docId: this.doc_id, projectId: this.project_id },
'getting doc via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when recent ops are requested', function () {
beforeEach(function () {
this.DocumentManager.getDocAndRecentOpsWithLock = sinon
.stub()
.callsArgWith(
3,
null,
this.lines,
this.version,
this.ops,
this.ranges,
this.pathname
)
this.req.query = { fromVersion: `${this.fromVersion}` }
this.HttpController.getDoc(this.req, this.res, this.next)
})
it('should get the doc', function () {
this.DocumentManager.getDocAndRecentOpsWithLock
.calledWith(this.project_id, this.doc_id, this.fromVersion)
.should.equal(true)
})
it('should return the doc as JSON', function () {
this.res.json
.calledWith({
id: this.doc_id,
lines: this.lines,
version: this.version,
ops: this.ops,
ranges: this.ranges,
pathname: this.pathname
})
.should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{ docId: this.doc_id, projectId: this.project_id },
'getting doc via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when the document does not exist', function () {
beforeEach(function () {
this.DocumentManager.getDocAndRecentOpsWithLock = sinon
.stub()
.callsArgWith(3, null, null, null)
this.HttpController.getDoc(this.req, this.res, this.next)
})
it('should call next with NotFoundError', function () {
this.next
.calledWith(sinon.match.instanceOf(Errors.NotFoundError))
.should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.DocumentManager.getDocAndRecentOpsWithLock = sinon
.stub()
.callsArgWith(3, new Error('oops'), null, null)
this.HttpController.getDoc(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('setDoc', function () {
beforeEach(function () {
this.lines = ['one', 'two', 'three']
this.source = 'dropbox'
this.user_id = 'user-id-123'
this.req = {
headers: {},
params: {
project_id: this.project_id,
doc_id: this.doc_id
},
query: {},
body: {
lines: this.lines,
source: this.source,
user_id: this.user_id,
undoing: (this.undoing = true)
}
}
})
describe('successfully', function () {
beforeEach(function () {
this.DocumentManager.setDocWithLock = sinon.stub().callsArgWith(6)
this.HttpController.setDoc(this.req, this.res, this.next)
})
it('should set the doc', function () {
this.DocumentManager.setDocWithLock
.calledWith(
this.project_id,
this.doc_id,
this.lines,
this.source,
this.user_id,
this.undoing
)
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{
docId: this.doc_id,
projectId: this.project_id,
lines: this.lines,
source: this.source,
userId: this.user_id,
undoing: this.undoing
},
'setting doc via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.DocumentManager.setDocWithLock = sinon
.stub()
.callsArgWith(6, new Error('oops'))
this.HttpController.setDoc(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
describe('when the payload is too large', function () {
beforeEach(function () {
const lines = []
for (let _ = 0; _ <= 200000; _++) {
lines.push('test test test')
}
this.req.body.lines = lines
this.DocumentManager.setDocWithLock = sinon.stub().callsArgWith(6)
this.HttpController.setDoc(this.req, this.res, this.next)
})
it('should send back a 406 response', function () {
this.res.sendStatus.calledWith(406).should.equal(true)
})
it('should not call setDocWithLock', function () {
this.DocumentManager.setDocWithLock.callCount.should.equal(0)
})
})
})
describe('flushProject', function () {
beforeEach(function () {
this.req = {
params: {
project_id: this.project_id
},
query: {},
body: {}
}
})
describe('successfully', function () {
beforeEach(function () {
this.ProjectManager.flushProjectWithLocks = sinon.stub().callsArgWith(1)
this.HttpController.flushProject(this.req, this.res, this.next)
})
it('should flush the project', function () {
this.ProjectManager.flushProjectWithLocks
.calledWith(this.project_id)
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{ projectId: this.project_id },
'flushing project via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.ProjectManager.flushProjectWithLocks = sinon
.stub()
.callsArgWith(1, new Error('oops'))
this.HttpController.flushProject(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('flushDocIfLoaded', function () {
beforeEach(function () {
this.lines = ['one', 'two', 'three']
this.version = 42
this.req = {
params: {
project_id: this.project_id,
doc_id: this.doc_id
},
query: {},
body: {}
}
})
describe('successfully', function () {
beforeEach(function () {
this.DocumentManager.flushDocIfLoadedWithLock = sinon
.stub()
.callsArgWith(2)
this.HttpController.flushDocIfLoaded(this.req, this.res, this.next)
})
it('should flush the doc', function () {
this.DocumentManager.flushDocIfLoadedWithLock
.calledWith(this.project_id, this.doc_id)
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{ docId: this.doc_id, projectId: this.project_id },
'flushing doc via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.DocumentManager.flushDocIfLoadedWithLock = sinon
.stub()
.callsArgWith(2, new Error('oops'))
this.HttpController.flushDocIfLoaded(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('deleteDoc', function () {
beforeEach(function () {
this.req = {
params: {
project_id: this.project_id,
doc_id: this.doc_id
},
query: {},
body: {}
}
})
describe('successfully', function () {
beforeEach(function () {
this.DocumentManager.flushAndDeleteDocWithLock = sinon
.stub()
.callsArgWith(3)
this.HttpController.deleteDoc(this.req, this.res, this.next)
})
it('should flush and delete the doc', function () {
this.DocumentManager.flushAndDeleteDocWithLock
.calledWith(this.project_id, this.doc_id, {
ignoreFlushErrors: false
})
.should.equal(true)
})
it('should flush project history', function () {
this.HistoryManager.flushProjectChangesAsync
.calledWithExactly(this.project_id)
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{ docId: this.doc_id, projectId: this.project_id },
'deleting doc via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('ignoring errors', function () {
beforeEach(function () {
this.req.query.ignore_flush_errors = 'true'
this.DocumentManager.flushAndDeleteDocWithLock = sinon.stub().yields()
this.HttpController.deleteDoc(this.req, this.res, this.next)
})
it('should delete the doc', function () {
this.DocumentManager.flushAndDeleteDocWithLock
.calledWith(this.project_id, this.doc_id, { ignoreFlushErrors: true })
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.DocumentManager.flushAndDeleteDocWithLock = sinon
.stub()
.callsArgWith(3, new Error('oops'))
this.HttpController.deleteDoc(this.req, this.res, this.next)
})
it('should flush project history', function () {
this.HistoryManager.flushProjectChangesAsync
.calledWithExactly(this.project_id)
.should.equal(true)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('deleteProject', function () {
beforeEach(function () {
this.req = {
params: {
project_id: this.project_id
},
query: {},
body: {}
}
})
describe('successfully', function () {
beforeEach(function () {
this.ProjectManager.flushAndDeleteProjectWithLocks = sinon
.stub()
.callsArgWith(2)
this.HttpController.deleteProject(this.req, this.res, this.next)
})
it('should delete the project', function () {
this.ProjectManager.flushAndDeleteProjectWithLocks
.calledWith(this.project_id)
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{ projectId: this.project_id },
'deleting project via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('with the background=true option from realtime', function () {
beforeEach(function () {
this.ProjectManager.queueFlushAndDeleteProject = sinon
.stub()
.callsArgWith(1)
this.req.query = { background: true, shutdown: true }
this.HttpController.deleteProject(this.req, this.res, this.next)
})
it('should queue the flush and delete', function () {
this.ProjectManager.queueFlushAndDeleteProject
.calledWith(this.project_id)
.should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.ProjectManager.flushAndDeleteProjectWithLocks = sinon
.stub()
.callsArgWith(2, new Error('oops'))
this.HttpController.deleteProject(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('acceptChanges', function () {
beforeEach(function () {
this.req = {
params: {
project_id: this.project_id,
doc_id: this.doc_id,
change_id: (this.change_id = 'mock-change-od-1')
},
query: {},
body: {}
}
})
describe('successfully with a single change', function () {
beforeEach(function () {
this.DocumentManager.acceptChangesWithLock = sinon
.stub()
.callsArgWith(3)
this.HttpController.acceptChanges(this.req, this.res, this.next)
})
it('should accept the change', function () {
this.DocumentManager.acceptChangesWithLock
.calledWith(this.project_id, this.doc_id, [this.change_id])
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{ projectId: this.project_id, docId: this.doc_id },
'accepting 1 changes via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('succesfully with with multiple changes', function () {
beforeEach(function () {
this.change_ids = [
'mock-change-od-1',
'mock-change-od-2',
'mock-change-od-3',
'mock-change-od-4'
]
this.req.body = { change_ids: this.change_ids }
this.DocumentManager.acceptChangesWithLock = sinon
.stub()
.callsArgWith(3)
this.HttpController.acceptChanges(this.req, this.res, this.next)
})
it('should accept the changes in the body payload', function () {
this.DocumentManager.acceptChangesWithLock
.calledWith(this.project_id, this.doc_id, this.change_ids)
.should.equal(true)
})
it('should log the request with the correct number of changes', function () {
this.logger.log
.calledWith(
{ projectId: this.project_id, docId: this.doc_id },
`accepting ${this.change_ids.length} changes via http`
)
.should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.DocumentManager.acceptChangesWithLock = sinon
.stub()
.callsArgWith(3, new Error('oops'))
this.HttpController.acceptChanges(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('deleteComment', function () {
beforeEach(function () {
this.req = {
params: {
project_id: this.project_id,
doc_id: this.doc_id,
comment_id: (this.comment_id = 'mock-comment-id')
},
query: {},
body: {}
}
})
describe('successfully', function () {
beforeEach(function () {
this.DocumentManager.deleteCommentWithLock = sinon
.stub()
.callsArgWith(3)
this.HttpController.deleteComment(this.req, this.res, this.next)
})
it('should accept the change', function () {
this.DocumentManager.deleteCommentWithLock
.calledWith(this.project_id, this.doc_id, this.comment_id)
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{
projectId: this.project_id,
docId: this.doc_id,
commentId: this.comment_id
},
'deleting comment via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.DocumentManager.deleteCommentWithLock = sinon
.stub()
.callsArgWith(3, new Error('oops'))
this.HttpController.deleteComment(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('getProjectDocsAndFlushIfOld', function () {
beforeEach(function () {
this.state = '01234567890abcdef'
this.docs = [
{ _id: '1234', lines: 'hello', v: 23 },
{ _id: '4567', lines: 'world', v: 45 }
]
this.req = {
params: {
project_id: this.project_id
},
query: {
state: this.state
},
body: {}
}
})
describe('successfully', function () {
beforeEach(function () {
this.ProjectManager.getProjectDocsAndFlushIfOld = sinon
.stub()
.callsArgWith(3, null, this.docs)
this.HttpController.getProjectDocsAndFlushIfOld(
this.req,
this.res,
this.next
)
})
it('should get docs from the project manager', function () {
this.ProjectManager.getProjectDocsAndFlushIfOld
.calledWith(this.project_id, this.state, {})
.should.equal(true)
})
it('should return a successful response', function () {
this.res.send.calledWith(this.docs).should.equal(true)
})
it('should log the request', function () {
this.logger.log
.calledWith(
{ projectId: this.project_id, exclude: [] },
'getting docs via http'
)
.should.equal(true)
})
it('should log the response', function () {
this.logger.log
.calledWith(
{ projectId: this.project_id, result: ['1234:23', '4567:45'] },
'got docs via http'
)
.should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when there is a conflict', function () {
beforeEach(function () {
this.ProjectManager.getProjectDocsAndFlushIfOld = sinon
.stub()
.callsArgWith(
3,
new Errors.ProjectStateChangedError('project state changed')
)
this.HttpController.getProjectDocsAndFlushIfOld(
this.req,
this.res,
this.next
)
})
it('should return an HTTP 409 Conflict response', function () {
this.res.sendStatus.calledWith(409).should.equal(true)
})
})
describe('when an error occurs', function () {
beforeEach(function () {
this.ProjectManager.getProjectDocsAndFlushIfOld = sinon
.stub()
.callsArgWith(3, new Error('oops'))
this.HttpController.getProjectDocsAndFlushIfOld(
this.req,
this.res,
this.next
)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('updateProject (split doc and file updates)', function () {
beforeEach(function () {
this.projectHistoryId = 'history-id-123'
this.userId = 'user-id-123'
this.docUpdates = [
{ id: 1, pathname: 'thesis.tex', newPathname: 'book.tex' },
{ id: 2, pathname: 'article.tex', docLines: 'hello' }
]
this.fileUpdates = [
{ id: 3, pathname: 'apple.png', newPathname: 'banana.png' },
{ id: 4, url: 'filestore.example.com/4' }
]
this.expectedUpdates = [
{
type: 'rename-doc',
id: 1,
pathname: 'thesis.tex',
newPathname: 'book.tex'
},
{ type: 'add-doc', id: 2, pathname: 'article.tex', docLines: 'hello' },
{
type: 'rename-file',
id: 3,
pathname: 'apple.png',
newPathname: 'banana.png'
},
{ type: 'add-file', id: 4, url: 'filestore.example.com/4' }
]
this.version = 1234567
this.req = {
query: {},
body: {
projectHistoryId: this.projectHistoryId,
userId: this.userId,
docUpdates: this.docUpdates,
fileUpdates: this.fileUpdates,
version: this.version
},
params: {
project_id: this.project_id
}
}
})
describe('successfully', function () {
beforeEach(function () {
this.ProjectManager.updateProjectWithLocks = sinon.stub().yields()
this.HttpController.updateProject(this.req, this.res, this.next)
})
it('should accept the change', function () {
this.ProjectManager.updateProjectWithLocks
.calledWith(
this.project_id,
this.projectHistoryId,
this.userId,
this.expectedUpdates,
this.version
)
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.ProjectManager.updateProjectWithLocks = sinon
.stub()
.yields(new Error('oops'))
this.HttpController.updateProject(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('updateProject (single updates parameter)', function () {
beforeEach(function () {
this.projectHistoryId = 'history-id-123'
this.userId = 'user-id-123'
this.updates = [
{
type: 'rename-doc',
id: 1,
pathname: 'thesis.tex',
newPathname: 'book.tex'
},
{ type: 'add-doc', id: 2, pathname: 'article.tex', docLines: 'hello' },
{
type: 'rename-file',
id: 3,
pathname: 'apple.png',
newPathname: 'banana.png'
},
{ type: 'add-file', id: 4, url: 'filestore.example.com/4' }
]
this.version = 1234567
this.req = {
query: {},
body: {
projectHistoryId: this.projectHistoryId,
userId: this.userId,
updates: this.updates,
version: this.version
},
params: {
project_id: this.project_id
}
}
})
describe('successfully', function () {
beforeEach(function () {
this.ProjectManager.updateProjectWithLocks = sinon.stub().yields()
this.HttpController.updateProject(this.req, this.res, this.next)
})
it('should accept the change', function () {
this.ProjectManager.updateProjectWithLocks
.calledWith(
this.project_id,
this.projectHistoryId,
this.userId,
this.updates,
this.version
)
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
it('should time the request', function () {
this.Metrics.Timer.prototype.done.called.should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.ProjectManager.updateProjectWithLocks = sinon
.stub()
.yields(new Error('oops'))
this.HttpController.updateProject(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
describe('resyncProjectHistory', function () {
beforeEach(function () {
this.projectHistoryId = 'history-id-123'
this.docs = sinon.stub()
this.files = sinon.stub()
this.fileUpdates = sinon.stub()
this.req = {
query: {},
body: {
projectHistoryId: this.projectHistoryId,
docs: this.docs,
files: this.files
},
params: {
project_id: this.project_id
}
}
})
describe('successfully', function () {
beforeEach(function () {
this.HistoryManager.resyncProjectHistory = sinon.stub().callsArgWith(4)
this.HttpController.resyncProjectHistory(this.req, this.res, this.next)
})
it('should accept the change', function () {
this.HistoryManager.resyncProjectHistory
.calledWith(
this.project_id,
this.projectHistoryId,
this.docs,
this.files
)
.should.equal(true)
})
it('should return a successful No Content response', function () {
this.res.sendStatus.calledWith(204).should.equal(true)
})
})
describe('when an errors occurs', function () {
beforeEach(function () {
this.HistoryManager.resyncProjectHistory = sinon
.stub()
.callsArgWith(4, new Error('oops'))
this.HttpController.resyncProjectHistory(this.req, this.res, this.next)
})
it('should call next with the error', function () {
this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
})
})
})
})