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.project_id = 'project-id-123' this.projectHistoryId = '123' this.doc_id = 'doc-id-123' this.source = 'editor' this.next = sinon.stub() this.res = { send: sinon.stub(), sendStatus: sinon.stub(), json: sinon.stub(), status: sinon.stub().returnsThis(), } this.DocumentManager = { promises: { getDocAndRecentOpsWithLock: sinon.stub(), getCommentWithLock: sinon.stub(), setDocWithLock: sinon.stub(), flushDocIfLoadedWithLock: sinon.stub().resolves(), flushAndDeleteDocWithLock: sinon.stub().resolves(), acceptChangesWithLock: sinon.stub().resolves(), updateCommentStateWithLock: sinon.stub().resolves(), deleteCommentWithLock: sinon.stub().resolves(), appendToDocWithLock: sinon.stub(), }, } this.HistoryManager = { flushProjectChangesAsync: sinon.stub(), promises: { resyncProjectHistory: sinon.stub().resolves(), }, } this.ProjectHistoryRedisManager = { promises: { queueOps: sinon.stub().resolves(), }, } this.ProjectManager = { promises: { flushProjectWithLocks: sinon.stub().resolves(), flushAndDeleteProjectWithLocks: sinon.stub().resolves(), queueFlushAndDeleteProject: sinon.stub().resolves(), getProjectDocsAndFlushIfOld: sinon.stub(), updateProjectWithLocks: sinon.stub().resolves(), }, } this.DeleteQueueManager = {} this.RedisManager = { DOC_OPS_TTL: 42, } this.Metrics = { Timer: class Timer {}, } this.Metrics.Timer.prototype.done = sinon.stub() this.Utils = { addTrackedDeletesToContent: sinon.stub().returnsArg(0), } this.HistoryConversions = { toHistoryRanges: sinon.stub().returnsArg(0), } this.HttpController = SandboxedModule.require(modulePath, { requires: { './DocumentManager': this.DocumentManager, './HistoryManager': this.HistoryManager, './ProjectHistoryRedisManager': this.ProjectHistoryRedisManager, './ProjectManager': this.ProjectManager, './DeleteQueueManager': this.DeleteQueueManager, './RedisManager': this.RedisManager, './Metrics': this.Metrics, './Errors': Errors, './Utils': this.Utils, './HistoryConversions': this.HistoryConversions, '@overleaf/settings': { max_doc_length: 2 * 1024 * 1024 }, }, }) }) 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(async function () { this.DocumentManager.promises.getDocAndRecentOpsWithLock.resolves({ lines: this.lines, version: this.version, ops: [], ranges: this.ranges, pathname: this.pathname, projectHistoryId: this.projectHistoryId, type: 'sharejs-text-ot', }) await this.HttpController.getDoc(this.req, this.res, this.next) }) it('should get the doc', function () { this.DocumentManager.promises.getDocAndRecentOpsWithLock.should.have.been.calledWith( this.project_id, this.doc_id, -1 ) }) it('should return the doc as JSON', function () { this.res.json.should.have.been.calledWith({ id: this.doc_id, lines: this.lines, version: this.version, ops: [], ranges: this.ranges, pathname: this.pathname, ttlInS: 42, type: 'sharejs-text-ot', }) }) it('should log the request', function () { this.logger.debug .calledWith( { docId: this.doc_id, projectId: this.project_id, historyRanges: false, }, '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(async function () { this.DocumentManager.promises.getDocAndRecentOpsWithLock.resolves({ lines: this.lines, version: this.version, ops: this.ops, ranges: this.ranges, pathname: this.pathname, projectHistoryId: this.projectHistoryId, type: 'sharejs-text-ot', }) this.req.query = { fromVersion: `${this.fromVersion}` } await this.HttpController.getDoc(this.req, this.res, this.next) }) it('should get the doc', function () { this.DocumentManager.promises.getDocAndRecentOpsWithLock.should.have.been.calledWith( this.project_id, this.doc_id, this.fromVersion ) }) it('should return the doc as JSON', function () { this.res.json.should.have.been.calledWith({ id: this.doc_id, lines: this.lines, version: this.version, ops: this.ops, ranges: this.ranges, pathname: this.pathname, ttlInS: 42, type: 'sharejs-text-ot', }) }) it('should log the request', function () { this.logger.debug .calledWith( { docId: this.doc_id, projectId: this.project_id, historyRanges: false, }, 'getting doc via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('when historyRanges query param is true', function () { beforeEach(async function () { this.DocumentManager.promises.getDocAndRecentOpsWithLock.resolves({ lines: this.lines, version: this.version, ops: [], ranges: this.ranges, pathname: this.pathname, projectHistoryId: this.projectHistoryId, type: 'sharejs-text-ot', }) this.req.query = { historyRanges: 'true' } await this.HttpController.getDoc(this.req, this.res, this.next) }) it('should get the doc', function () { this.DocumentManager.promises.getDocAndRecentOpsWithLock.should.have.been.calledWith( this.project_id, this.doc_id, -1 ) }) it('should return the doc as JSON with history ranges processing', function () { this.res.json.should.have.been.calledWith({ id: this.doc_id, lines: this.lines, version: this.version, ops: [], ranges: this.ranges, pathname: this.pathname, ttlInS: 42, type: 'sharejs-text-ot', }) }) it('should call addTrackedDeletesToContent for history ranges processing', function () { this.Utils.addTrackedDeletesToContent.called.should.equal(true) }) it('should call toHistoryRanges for range conversion', function () { this.HistoryConversions.toHistoryRanges.called.should.equal(true) }) it('should log the request with historyRanges: true', function () { this.logger.debug .calledWith( { docId: this.doc_id, projectId: this.project_id, historyRanges: true, }, '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(async function () { this.DocumentManager.promises.getDocAndRecentOpsWithLock.resolves({ lines: null, version: null, }) await 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(async function () { this.DocumentManager.promises.getDocAndRecentOpsWithLock.rejects( new Error('oops') ) await 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('getComment', function () { beforeEach(function () { this.ranges = { changes: 'mock', comments: [ { id: 'comment-id-1', }, { id: 'comment-id-2', }, ], } this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, comment_id: this.comment_id, }, query: {}, body: {}, } }) beforeEach(async function () { this.DocumentManager.promises.getCommentWithLock.resolves( this.ranges.comments[0] ) await this.HttpController.getComment(this.req, this.res, this.next) }) it('should get the comment', function () { this.DocumentManager.promises.getCommentWithLock.should.have.been.calledWith( this.project_id, this.doc_id, this.comment_id ) }) it('should return the comment as JSON', function () { this.res.json .calledWith({ id: 'comment-id-1', }) .should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { projectId: this.project_id, docId: this.doc_id, commentId: this.comment_id, }, 'getting comment via http' ) .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(async function () { this.DocumentManager.promises.setDocWithLock.resolves({ rev: '123' }) await this.HttpController.setDoc(this.req, this.res, this.next) }) it('should set the doc', function () { this.DocumentManager.promises.setDocWithLock.should.have.been.calledWith( this.project_id, this.doc_id, this.lines, this.source, this.user_id, this.undoing, true ) }) it('should return a json response with the document rev from web', function () { this.res.json.calledWithMatch({ rev: '123' }).should.equal(true) }) it('should log the request', function () { this.logger.debug .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(async function () { this.DocumentManager.promises.setDocWithLock.rejects(new Error('oops')) await 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(async function () { const lines = [] for (let _ = 0; _ <= 200000; _++) { lines.push('test test test') } this.req.body.lines = lines this.DocumentManager.promises.setDocWithLock.resolves() await 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.promises.setDocWithLock.should.not.have.been.called }) }) }) describe('flushProject', function () { beforeEach(function () { this.req = { params: { project_id: this.project_id, }, query: {}, body: {}, } }) describe('successfully', function () { beforeEach(async function () { await this.HttpController.flushProject(this.req, this.res, this.next) }) it('should flush the project', function () { this.ProjectManager.promises.flushProjectWithLocks.should.have.been.calledWith( this.project_id ) }) 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.debug .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(async function () { this.ProjectManager.promises.flushProjectWithLocks.rejects( new Error('oops') ) await 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(async function () { await this.HttpController.flushDocIfLoaded( this.req, this.res, this.next ) }) it('should flush the doc', function () { this.DocumentManager.promises.flushDocIfLoadedWithLock.should.have.been.calledWith( this.project_id, this.doc_id ) }) 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.debug .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(async function () { this.DocumentManager.promises.flushDocIfLoadedWithLock.rejects( new Error('oops') ) await 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(async function () { await this.HttpController.deleteDoc(this.req, this.res, this.next) }) it('should flush and delete the doc', function () { this.DocumentManager.promises.flushAndDeleteDocWithLock.should.have.been.calledWith( this.project_id, this.doc_id, { ignoreFlushErrors: false } ) }) 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.debug .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(async function () { this.req.query.ignore_flush_errors = 'true' await this.HttpController.deleteDoc(this.req, this.res, this.next) }) it('should delete the doc', function () { this.DocumentManager.promises.flushAndDeleteDocWithLock.should.have.been.calledWith( this.project_id, this.doc_id, { ignoreFlushErrors: 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(async function () { this.DocumentManager.promises.flushAndDeleteDocWithLock.rejects( new Error('oops') ) await 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(async function () { await this.HttpController.deleteProject(this.req, this.res, this.next) }) it('should delete the project', function () { this.ProjectManager.promises.flushAndDeleteProjectWithLocks.should.have.been.calledWith( this.project_id ) }) 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.debug .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(async function () { this.req.query = { background: true, shutdown: true } await this.HttpController.deleteProject(this.req, this.res, this.next) }) it('should queue the flush and delete', function () { this.ProjectManager.promises.queueFlushAndDeleteProject.should.have.been.calledWith( this.project_id ) }) }) describe('when an errors occurs', function () { beforeEach(async function () { this.ProjectManager.promises.flushAndDeleteProjectWithLocks.rejects( new Error('oops') ) await 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(async function () { this.changeContributors = ['user-id-1', 'user-id-2'] this.DocumentManager.promises.acceptChangesWithLock.resolves( this.changeContributors ) await this.HttpController.acceptChanges(this.req, this.res, this.next) }) it('should accept the change', function () { this.DocumentManager.promises.acceptChangesWithLock.should.have.been.calledWith( this.project_id, this.doc_id, [this.change_id] ) }) it('should return a successful 200 with a list of the change contributors', function () { this.res.status.should.have.been.calledWith(200) this.res.json.should.have.been.calledWith({ changeContributors: this.changeContributors, }) }) it('should log the request', function () { this.logger.debug .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(async 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 } await this.HttpController.acceptChanges(this.req, this.res, this.next) }) it('should accept the changes in the body payload', function () { this.DocumentManager.promises.acceptChangesWithLock.should.have.been.calledWith( this.project_id, this.doc_id, this.change_ids ) }) it('should log the request with the correct number of changes', function () { this.logger.debug .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(async function () { this.DocumentManager.promises.acceptChangesWithLock.rejects( new Error('oops') ) await 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('resolveComment', function () { beforeEach(function () { this.user_id = 'user-id-123' this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, comment_id: (this.comment_id = 'mock-comment-id'), }, query: {}, body: { user_id: this.user_id, }, } this.resolved = true }) describe('successfully', function () { beforeEach(async function () { await this.HttpController.resolveComment(this.req, this.res, this.next) }) it('should accept the change', function () { this.DocumentManager.promises.updateCommentStateWithLock.should.have.been.calledWith( this.project_id, this.doc_id, this.comment_id, this.user_id, this.resolved ) }) 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.debug .calledWith( { projectId: this.project_id, docId: this.doc_id, commentId: this.comment_id, }, 'resolving comment via http' ) .should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(async function () { this.DocumentManager.promises.updateCommentStateWithLock.rejects( new Error('oops') ) await this.HttpController.resolveComment(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('reopenComment', function () { beforeEach(function () { this.user_id = 'user-id-123' this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, comment_id: (this.comment_id = 'mock-comment-id'), }, query: {}, body: { user_id: this.user_id, }, } this.resolved = false }) describe('successfully', function () { beforeEach(async function () { await this.HttpController.reopenComment(this.req, this.res, this.next) }) it('should accept the change', function () { this.DocumentManager.promises.updateCommentStateWithLock.should.have.been.calledWith( this.project_id, this.doc_id, this.comment_id, this.user_id, this.resolved ) }) 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.debug .calledWith( { projectId: this.project_id, docId: this.doc_id, commentId: this.comment_id, }, 'reopening comment via http' ) .should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(async function () { this.DocumentManager.promises.updateCommentStateWithLock.rejects( new Error('oops') ) await this.HttpController.reopenComment(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.user_id = 'user-id-123' this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, comment_id: (this.comment_id = 'mock-comment-id'), }, query: {}, body: { user_id: this.user_id, }, } }) describe('successfully', function () { beforeEach(async function () { await this.HttpController.deleteComment(this.req, this.res, this.next) }) it('should accept the change', function () { this.DocumentManager.promises.deleteCommentWithLock.should.have.been.calledWith( this.project_id, this.doc_id, this.comment_id, this.user_id ) }) 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.debug .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(async function () { this.DocumentManager.promises.deleteCommentWithLock.rejects( new Error('oops') ) await 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(async function () { this.ProjectManager.promises.getProjectDocsAndFlushIfOld.resolves( this.docs ) await this.HttpController.getProjectDocsAndFlushIfOld( this.req, this.res, this.next ) }) it('should get docs from the project manager', function () { this.ProjectManager.promises.getProjectDocsAndFlushIfOld.should.have.been.calledWith( this.project_id, this.state, {} ) }) it('should return a successful response', function () { this.res.send.calledWith(this.docs).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { projectId: this.project_id, exclude: [] }, 'getting docs via http' ) .should.equal(true) }) it('should log the response', function () { this.logger.debug .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(async function () { this.ProjectManager.promises.getProjectDocsAndFlushIfOld.rejects( new Errors.ProjectStateChangedError('project state changed') ) await 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(async function () { this.ProjectManager.promises.getProjectDocsAndFlushIfOld.rejects( new Error('oops') ) await 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', 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, source: this.source, }, params: { project_id: this.project_id, }, } }) describe('successfully', function () { beforeEach(async function () { await this.HttpController.updateProject(this.req, this.res, this.next) }) it('should accept the change', function () { this.ProjectManager.promises.updateProjectWithLocks.should.have.been.calledWith( this.project_id, this.projectHistoryId, this.userId, this.updates, this.version, this.source ) }) 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(async function () { this.ProjectManager.promises.updateProjectWithLocks.rejects( new Error('oops') ) await 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(async function () { await this.HttpController.resyncProjectHistory( this.req, this.res, this.next ) }) it('should accept the change', function () { this.HistoryManager.promises.resyncProjectHistory.should.have.been.calledWith( this.project_id, this.projectHistoryId, this.docs, this.files, {} ) }) it('should return a successful No Content response', function () { this.res.sendStatus.should.have.been.calledWith(204) }) }) describe('when an errors occurs', function () { beforeEach(async function () { this.HistoryManager.promises.resyncProjectHistory.rejects( new Error('oops') ) await 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) }) }) }) describe('appendToDoc', 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(async function () { this.DocumentManager.promises.appendToDocWithLock.resolves({ rev: '123', }) await this.HttpController.appendToDoc(this.req, this.res, this.next) }) it('should append to the doc', function () { this.DocumentManager.promises.appendToDocWithLock.should.have.been.calledWith( this.project_id, this.doc_id, this.lines, this.source, this.user_id ) }) it('should return a json response with the document rev from web', function () { this.res.json.calledWithMatch({ rev: '123' }).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { docId: this.doc_id, projectId: this.project_id, lines: this.lines, source: this.source, userId: this.user_id, }, 'appending to 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(async function () { this.DocumentManager.promises.appendToDocWithLock.rejects( new Error('oops') ) await this.HttpController.appendToDoc(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(async function () { this.DocumentManager.promises.appendToDocWithLock.rejects( new Errors.FileTooLargeError() ) await this.HttpController.appendToDoc(this.req, this.res, this.next) }) it('should send back a 422 response', function () { this.res.sendStatus.calledWith(422).should.equal(true) }) }) }) })