Просмотр исходного кода

prettier: convert test/unit decaffeinated files to Prettier format

Tim Alby 6 лет назад
Родитель
Сommit
9a2f8ecbd4
24 измененных файлов с 8239 добавлено и 6162 удалено
  1. 78 66
      services/document-updater/test/unit/js/DiffCodec/DiffCodecTests.js
  2. 163 141
      services/document-updater/test/unit/js/DispatchManager/DispatchManagerTests.js
  3. 1099 687
      services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js
  4. 414 302
      services/document-updater/test/unit/js/HistoryManager/HistoryManagerTests.js
  5. 86 72
      services/document-updater/test/unit/js/HistoryRedisManager/HistoryRedisManagerTests.js
  6. 940 803
      services/document-updater/test/unit/js/HttpController/HttpControllerTests.js
  7. 50 52
      services/document-updater/test/unit/js/LockManager/CheckingTheLock.js
  8. 81 70
      services/document-updater/test/unit/js/LockManager/ReleasingTheLock.js
  9. 100 102
      services/document-updater/test/unit/js/LockManager/getLockTests.js
  10. 123 112
      services/document-updater/test/unit/js/LockManager/tryLockTests.js
  11. 424 298
      services/document-updater/test/unit/js/PersistenceManager/PersistenceManagerTests.js
  12. 188 146
      services/document-updater/test/unit/js/ProjectHistoryRedisManager/ProjectHistoryRedisManagerTests.js
  13. 130 105
      services/document-updater/test/unit/js/ProjectManager/flushAndDeleteProjectTests.js
  14. 121 97
      services/document-updater/test/unit/js/ProjectManager/flushProjectTests.js
  15. 212 155
      services/document-updater/test/unit/js/ProjectManager/getProjectDocsTests.js
  16. 390 236
      services/document-updater/test/unit/js/ProjectManager/updateProjectTests.js
  17. 503 371
      services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js
  18. 109 111
      services/document-updater/test/unit/js/RateLimitManager/RateLimitManager.js
  19. 159 124
      services/document-updater/test/unit/js/RealTimeRedisManager/RealTimeRedisManagerTests.js
  20. 1405 994
      services/document-updater/test/unit/js/RedisManager/RedisManagerTests.js
  21. 420 346
      services/document-updater/test/unit/js/ShareJS/TextTransformTests.js
  22. 139 123
      services/document-updater/test/unit/js/ShareJsDB/ShareJsDBTests.js
  23. 227 176
      services/document-updater/test/unit/js/ShareJsUpdateManager/ShareJsUpdateManagerTests.js
  24. 678 473
      services/document-updater/test/unit/js/UpdateManager/UpdateManagerTests.js

+ 78 - 66
services/document-updater/test/unit/js/DiffCodec/DiffCodecTests.js

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

+ 163 - 141
services/document-updater/test/unit/js/DispatchManager/DispatchManagerTests.js

@@ -11,144 +11,166 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/DispatchManager.js";
-const SandboxedModule = require('sandboxed-module');
-const Errors = require("../../../../app/js/Errors.js");
-
-describe("DispatchManager", function() {
-	beforeEach(function() {
-		this.timeout(3000);
-		this.DispatchManager = SandboxedModule.require(modulePath, { requires: {
-			"./UpdateManager" : (this.UpdateManager = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub(), warn: sinon.stub() }),
-			"settings-sharelatex": (this.settings = {
-				redis: {
-					documentupdater: {}
-				}
-			}),
-			"redis-sharelatex": (this.redis = {}),
-			"./RateLimitManager": {},
-			"./Errors": Errors,
-			"./Metrics": {
-				Timer() {
-					return {done() {}};
-				}
-			}
-		}
-	}
-		);
-		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);
-		});
-			
-		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);
-				});
-			});
-
-			return 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 warning", function() {
-					return this.logger.warn.called.should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.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(error) {}; }
-                callCount++;
-                if (callCount === 3) {
-                    this.settings.shuttingDown = true;
-                }
-                return setTimeout(() => callback()
-                , 10);
-            };
-            sinon.spy(this.worker, "_waitForUpdateThenDispatchWorker");
-            
-        
-            this.worker.run();
-
-            var checkStatus = () => {
-                if (!this.settings.shuttingDown) { // retry until shutdown
-                    setTimeout(checkStatus, 100);
-                    
-                } else {
-                    this.worker._waitForUpdateThenDispatchWorker.callCount.should.equal(3);
-                    return done();
-                }
-            };
-
-            return checkStatus();
-        }); });
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/DispatchManager.js'
+const SandboxedModule = require('sandboxed-module')
+const Errors = require('../../../../app/js/Errors.js')
+
+describe('DispatchManager', function () {
+  beforeEach(function () {
+    this.timeout(3000)
+    this.DispatchManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './UpdateManager': (this.UpdateManager = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub(),
+          warn: sinon.stub()
+        }),
+        'settings-sharelatex': (this.settings = {
+          redis: {
+            documentupdater: {}
+          }
+        }),
+        'redis-sharelatex': (this.redis = {}),
+        './RateLimitManager': {},
+        './Errors': Errors,
+        './Metrics': {
+          Timer() {
+            return { done() {} }
+          }
+        }
+      }
+    })
+    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
+      ))
+    })
+
+    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)
+        })
+      })
+
+      return 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 warning', function () {
+          return this.logger.warn.called.should.equal(true)
+        })
+
+        return it('should call the callback', function () {
+          return this.callback.called.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 (error) {}
+          }
+          callCount++
+          if (callCount === 3) {
+            this.settings.shuttingDown = true
+          }
+          return setTimeout(() => callback(), 10)
+        }
+        sinon.spy(this.worker, '_waitForUpdateThenDispatchWorker')
+
+        this.worker.run()
+
+        var checkStatus = () => {
+          if (!this.settings.shuttingDown) {
+            // retry until shutdown
+            setTimeout(checkStatus, 100)
+          } else {
+            this.worker._waitForUpdateThenDispatchWorker.callCount.should.equal(
+              3
+            )
+            return done()
+          }
+        }
+
+        return checkStatus()
+      })
+    })
+  })
+})

+ 1099 - 687
services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js

@@ -12,690 +12,1102 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/DocumentManager.js";
-const SandboxedModule = require('sandboxed-module');
-const Errors = require("../../../../app/js/Errors");
-const tk = require("timekeeper");
-
-describe("DocumentManager", function() {
-	beforeEach(function() {
-		let Timer;
-		tk.freeze(new Date());
-		this.DocumentManager = SandboxedModule.require(modulePath, { requires: {
-			"./RedisManager": (this.RedisManager = {}),
-			"./ProjectHistoryRedisManager": (this.ProjectHistoryRedisManager = {}),
-			"./PersistenceManager": (this.PersistenceManager = {}),
-			"./HistoryManager": (this.HistoryManager = {
-				flushDocChangesAsync: sinon.stub(),
-				flushProjectChangesAsync: sinon.stub()
-			}),
-			"logger-sharelatex": (this.logger = {log: sinon.stub(), warn: sinon.stub()}),
-			"./DocOpsManager": (this.DocOpsManager = {}),
-			"./Metrics": (this.Metrics = {
-				Timer: (Timer = (function() {
-					Timer = class Timer {
-						static initClass() {
-							this.prototype.done = sinon.stub();
-						}
-					};
-					Timer.initClass();
-					return Timer;
-				})())
-			}),
-			"./RealTimeRedisManager": (this.RealTimeRedisManager = {}),
-			"./DiffCodec": (this.DiffCodec = {}),
-			"./UpdateManager": (this.UpdateManager = {}),
-			"./RangesManager": (this.RangesManager = {})
-		}
-	});
-		this.project_id = "project-id-123";
-		this.projectHistoryId = "history-id-123";
-		this.projectHistoryType = "project-history";
-		this.doc_id = "doc-id-123";
-		this.user_id = 1234;
-		this.callback = sinon.stub();
-		this.lines = ["one", "two", "three"];
-		this.version = 42;
-		this.ranges = { comments: "mock", entries: "mock" };
-		this.pathname = '/a/b/c.tex';
-		this.unflushedTime = Date.now();
-		this.lastUpdatedAt = Date.now();
-		return this.lastUpdatedBy = 'last-author-id';
-	});
-
-	afterEach(function() { return tk.reset(); });
-
-	describe("flushAndDeleteDoc", function() {
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.RedisManager.removeDocFromMemory = sinon.stub().callsArg(2);
-				this.DocumentManager.flushDocIfLoaded = sinon.stub().callsArgWith(2);
-				return this.DocumentManager.flushAndDeleteDoc(this.project_id, this.doc_id, {}, this.callback);
-			});
-
-			it("should flush the doc", function() {
-				return this.DocumentManager.flushDocIfLoaded
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should remove the doc from redis", function() {
-				return this.RedisManager.removeDocFromMemory
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should call the callback without error", function() {
-				return this.callback.calledWith(null).should.equal(true);
-			});
-
-			it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-
-			return it("should flush to the history api", function() {
-				return this.HistoryManager.flushDocChangesAsync
-					.calledWithExactly(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-		});
-
-		return describe("when a flush error occurs", function() {
-			beforeEach(function() {
-				this.DocumentManager.flushDocIfLoaded = sinon.stub().callsArgWith(2, new Error("boom!"));
-				return this.RedisManager.removeDocFromMemory = sinon.stub().callsArg(2);
-			});
-
-			it("should not remove the doc from redis", function(done) {
-				return this.DocumentManager.flushAndDeleteDoc(this.project_id, this.doc_id, {}, error => {
-					error.should.exist;
-					this.RedisManager.removeDocFromMemory.called.should.equal(false);
-					return done();
-				});
-			});
-
-			return describe("when ignoring flush errors", function() { return it("should remove the doc from redis", function(done) {
-                return this.DocumentManager.flushAndDeleteDoc(this.project_id, this.doc_id, { ignoreFlushErrors: true }, error => {
-                    if (error != null) {
-                        return done(error);
-                    }
-                    this.RedisManager.removeDocFromMemory.called.should.equal(true);
-                    return done();
-                });
-            }); });
-		});
-	});
-
-	describe("flushDocIfLoaded", function() {
-		describe("when the doc is in Redis", function() {
-			beforeEach(function() {
-				this.RedisManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, this.unflushedTime, this.lastUpdatedAt, this.lastUpdatedBy);
-				this.RedisManager.clearUnflushedTime = sinon.stub().callsArgWith(1, null);
-				this.PersistenceManager.setDoc = sinon.stub().yields();
-				return this.DocumentManager.flushDocIfLoaded(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should get the doc from redis", function() {
-				return this.RedisManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should write the doc lines to the persistence layer", function() {
-				return this.PersistenceManager.setDoc
-					.calledWith(this.project_id, this.doc_id, this.lines, this.version, this.ranges, this.lastUpdatedAt, this.lastUpdatedBy)
-					.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 the document is not in Redis", function() {
-			beforeEach(function() {
-				this.RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null, null);
-				this.PersistenceManager.setDoc = sinon.stub().yields();
-				this.DocOpsManager.flushDocOpsToMongo = sinon.stub().callsArgWith(2);
-				return this.DocumentManager.flushDocIfLoaded(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should get the doc from redis", function() {
-				return this.RedisManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should not write anything to the persistence layer", function() {
-				this.PersistenceManager.setDoc.called.should.equal(false);
-				return this.DocOpsManager.flushDocOpsToMongo.called.should.equal(false);
-			});
-
-			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);
-			});
-		});
-	});
-
-	describe("getDocAndRecentOps", function() {
-		describe("with a previous version specified", function() {
-			beforeEach(function() {
-				this.DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId);
-				this.RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, this.ops);
-				return this.DocumentManager.getDocAndRecentOps(this.project_id, this.doc_id, this.fromVersion, this.callback);
-			});
-
-			it("should get the doc", function() {
-				return this.DocumentManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should get the doc ops", function() {
-				return this.RedisManager.getPreviousDocOps
-					.calledWith(this.doc_id, this.fromVersion, this.version)
-					.should.equal(true);
-			});
-
-			it("should call the callback with the doc info", function() {
-				return this.callback.calledWith(null, this.lines, this.version, this.ops, this.ranges, this.pathname, this.projectHistoryId).should.equal(true);
-			});
-
-			return it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-		});
-
-		return describe("with no previous version specified", function() {
-			beforeEach(function() {
-				this.DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId);
-				this.RedisManager.getPreviousDocOps = sinon.stub().callsArgWith(3, null, this.ops);
-				return this.DocumentManager.getDocAndRecentOps(this.project_id, this.doc_id, -1, this.callback);
-			});
-
-			it("should get the doc", function() {
-				return this.DocumentManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should not need to get the doc ops", function() {
-				return this.RedisManager.getPreviousDocOps.called.should.equal(false);
-			});
-
-			it("should call the callback with the doc info", function() {
-				return this.callback.calledWith(null, this.lines, this.version, [], this.ranges, this.pathname, this.projectHistoryId).should.equal(true);
-			});
-
-			return it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-		});
-	});
-
-	describe("getDoc", function() {
-		describe("when the doc exists in Redis", function() {
-			beforeEach(function() {
-				this.RedisManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, this.unflushedTime);
-				return this.DocumentManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should get the doc from Redis", function() {
-				return this.RedisManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should call the callback with the doc info", function() {
-				return this.callback.calledWith(null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, this.unflushedTime, true).should.equal(true);
-			});
-
-			return it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-		});
-
-		return describe("when the doc does not exist in Redis", function() {
-			beforeEach(function() {
-				this.RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null, null, null, null);
-				this.PersistenceManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, this.projectHistoryType);
-				this.RedisManager.putDocInMemory = sinon.stub().yields();
-				this.RedisManager.setHistoryType = sinon.stub().yields();
-				return this.DocumentManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should try to get the doc from Redis", function() {
-				return this.RedisManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should get the doc from the PersistenceManager", function() {
-				return this.PersistenceManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should set the doc in Redis", function() {
-				return this.RedisManager.putDocInMemory
-					.calledWith(this.project_id, this.doc_id, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId)
-					.should.equal(true);
-			});
-
-			it("should set the history type in Redis", function() {
-				return this.RedisManager.setHistoryType
-					.calledWith(this.doc_id, this.projectHistoryType)
-					.should.equal(true);
-			});
-
-			it("should call the callback with the doc info", function() {
-				return this.callback.calledWith(null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, null, false).should.equal(true);
-			});
-
-			return it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-		});
-	});
-
-	describe("setDoc", function() { return describe("with plain tex lines", function() {
-        beforeEach(function() {
-            this.beforeLines = ["before", "lines"];
-            this.afterLines = ["after", "lines"];
-            this.ops = [{ i: "foo", p: 4 }, { d: "bar", p: 42 }];
-            this.DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, this.beforeLines, this.version, this.ranges, this.pathname, this.projectHistoryId, this.unflushedTime, true);
-            this.DiffCodec.diffAsShareJsOp = sinon.stub().callsArgWith(2, null, this.ops);
-            this.UpdateManager.applyUpdate = sinon.stub().callsArgWith(3, null);
-            this.DocumentManager.flushDocIfLoaded = sinon.stub().callsArg(2);
-            return this.DocumentManager.flushAndDeleteDoc = sinon.stub().callsArg(3);
-        });
-
-        describe("when already loaded", function() {
-            beforeEach(function() {
-                return this.DocumentManager.setDoc(this.project_id, this.doc_id, this.afterLines, this.source, this.user_id, false, this.callback);
-            });
-
-            it("should get the current doc lines", function() {
-                return this.DocumentManager.getDoc
-                    .calledWith(this.project_id, this.doc_id)
-                    .should.equal(true);
-            });
-
-            it("should return a diff of the old and new lines", function() {
-                return this.DiffCodec.diffAsShareJsOp
-                    .calledWith(this.beforeLines, this.afterLines)
-                    .should.equal(true);
-            });
-
-            it("should apply the diff as a ShareJS op", function() {
-                return this.UpdateManager.applyUpdate
-                    .calledWith(
-                        this.project_id,
-                        this.doc_id,
-                        {
-                            doc: this.doc_id,
-                            v: this.version,
-                            op: this.ops,
-                            meta: {
-                                type: "external",
-                                source: this.source,
-                                user_id: this.user_id
-                            }
-                        }
-                    )
-                    .should.equal(true);
-            });
-
-            it("should flush the doc to Mongo", function() {
-                return this.DocumentManager.flushDocIfLoaded
-                    .calledWith(this.project_id, this.doc_id)
-                    .should.equal(true);
-            });
-
-            it("should not flush the project history", function() {
-                return this.HistoryManager.flushProjectChangesAsync
-                    .called.should.equal(false);
-            });
-
-            it("should call the callback", 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);
-            });
-        });
-
-        describe("when not already loaded", function() {
-            beforeEach(function() {
-                this.DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, this.beforeLines, this.version, this.pathname, null, false);
-                return this.DocumentManager.setDoc(this.project_id, this.doc_id, this.afterLines, this.source, this.user_id, false, this.callback);
-            });
-
-            it("should flush and delete the doc from the doc updater", function() {
-                return this.DocumentManager.flushAndDeleteDoc
-                    .calledWith(this.project_id, this.doc_id, {})
-                    .should.equal(true);
-            });
-
-            return it("should not flush the project history", function() {
-                return this.HistoryManager.flushProjectChangesAsync
-                    .calledWithExactly(this.project_id)
-                    .should.equal(true);
-            });
-        });
-
-        describe("without new lines", function() {
-            beforeEach(function() {
-                return this.DocumentManager.setDoc(this.project_id, this.doc_id, null, this.source, this.user_id, false, this.callback);
-            });
-
-            it("should return the callback with an error", function() {
-                return this.callback.calledWith(new Error("No lines were passed to setDoc"));
-            });
-
-            return it("should not try to get the doc lines", function() {
-                return this.DocumentManager.getDoc.called.should.equal(false);
-            });
-        });
-
-        return describe("with the undoing flag", function() {
-            beforeEach(function() {
-                // Copy ops so we don't interfere with other tests
-                this.ops = [{ i: "foo", p: 4 }, { d: "bar", p: 42 }];
-                this.DiffCodec.diffAsShareJsOp = sinon.stub().callsArgWith(2, null, this.ops);
-                return this.DocumentManager.setDoc(this.project_id, this.doc_id, this.afterLines, this.source, this.user_id, true, this.callback);
-            });
-
-            return it("should set the undo flag on each op", function() {
-                return Array.from(this.ops).map((op) =>
-                    op.u.should.equal(true));
-            });
-        });
-    }); });
-
-	describe("acceptChanges", function() {
-		beforeEach(function() {
-			this.change_id = "mock-change-id";
-			this.change_ids = [ "mock-change-id-1", "mock-change-id-2", "mock-change-id-3", "mock-change-id-4" ];
-			this.version = 34;
-			this.lines = ["original", "lines"];
-			this.ranges = { entries: "mock", comments: "mock" };
-			this.updated_ranges = { entries: "updated", comments: "updated" };
-			this.DocumentManager.getDoc = sinon.stub().yields(null, this.lines, this.version, this.ranges);
-			this.RangesManager.acceptChanges = sinon.stub().yields(null, this.updated_ranges);
-			return this.RedisManager.updateDocument = sinon.stub().yields();
-		});
-
-		describe("successfully with a single change", function() {
-			beforeEach(function() {
-				return this.DocumentManager.acceptChanges(this.project_id, this.doc_id, [ this.change_id ], this.callback);
-			});
-
-			it("should get the document's current ranges", function() {
-				return this.DocumentManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should apply the accept change to the ranges", function() {
-				return this.RangesManager.acceptChanges
-					.calledWith([ this.change_id ], this.ranges)
-					.should.equal(true);
-			});
-
-			it("should save the updated ranges", function() {
-				return this.RedisManager.updateDocument
-					.calledWith(this.project_id, this.doc_id, this.lines, this.version, [], this.updated_ranges, {})
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		describe("successfully with multiple changes", function() {
-			beforeEach(function() {
-				return this.DocumentManager.acceptChanges(this.project_id, this.doc_id, this.change_ids, this.callback);
-			});
-
-			return it("should apply the accept change to the ranges", function() {
-				return this.RangesManager.acceptChanges
-					.calledWith(this.change_ids, this.ranges)
-					.should.equal(true);
-			});
-		});
-
-		return describe("when the doc is not found", function() {
-			beforeEach(function() {
-				this.DocumentManager.getDoc = sinon.stub().yields(null, null, null, null);
-				return this.DocumentManager.acceptChanges(this.project_id, this.doc_id, [ this.change_id ], this.callback);
-			});
-
-			it("should not save anything", function() {
-				return this.RedisManager.updateDocument.called.should.equal(false);
-			});
-
-			return it("should call the callback with a not found error", function() {
-				const error = new Errors.NotFoundError(`document not found: ${this.doc_id}`);
-				return this.callback.calledWith(error).should.equal(true);
-			});
-		});
-	});
-
-	describe("deleteComment", function() {
-		beforeEach(function() {
-			this.comment_id = "mock-comment-id";
-			this.version = 34;
-			this.lines = ["original", "lines"];
-			this.ranges = { comments: ["one", "two", "three"] };
-			this.updated_ranges = { comments: ["one", "three"] };
-			this.DocumentManager.getDoc = sinon.stub().yields(null, this.lines, this.version, this.ranges);
-			this.RangesManager.deleteComment = sinon.stub().yields(null, this.updated_ranges);
-			return this.RedisManager.updateDocument = sinon.stub().yields();
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				return this.DocumentManager.deleteComment(this.project_id, this.doc_id, this.comment_id, this.callback);
-			});
-
-			it("should get the document's current ranges", function() {
-				return this.DocumentManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should delete the comment from the ranges", function() {
-				return this.RangesManager.deleteComment
-					.calledWith(this.comment_id, this.ranges)
-					.should.equal(true);
-			});
-
-			it("should save the updated ranges", function() {
-				return this.RedisManager.updateDocument
-					.calledWith(this.project_id, this.doc_id, this.lines, this.version, [], this.updated_ranges, {})
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		return describe("when the doc is not found", function() {
-			beforeEach(function() {
-				this.DocumentManager.getDoc = sinon.stub().yields(null, null, null, null);
-				return this.DocumentManager.acceptChanges(this.project_id, this.doc_id, [ this.comment_id ], this.callback);
-			});
-
-			it("should not save anything", function() {
-				return this.RedisManager.updateDocument.called.should.equal(false);
-			});
-
-			return it("should call the callback with a not found error", function() {
-				const error = new Errors.NotFoundError(`document not found: ${this.doc_id}`);
-				return this.callback.calledWith(error).should.equal(true);
-			});
-		});
-	});
-
-	describe("getDocAndFlushIfOld", function() {
-		beforeEach(function() {
-			return this.DocumentManager.flushDocIfLoaded = sinon.stub().callsArg(2);
-		});
-
-		describe("when the doc is in Redis", function() {
-			describe("and has changes to be flushed", function() {
-				beforeEach(function() {
-					this.DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, this.projectHistoryId, this.pathname, Date.now() - 1e9, true);
-					return this.DocumentManager.getDocAndFlushIfOld(this.project_id, this.doc_id, this.callback);
-				});
-
-				it("should get the doc", function() {
-					return this.DocumentManager.getDoc
-						.calledWith(this.project_id, this.doc_id)
-						.should.equal(true);
-				});
-
-				it("should flush the doc", function() {
-					return this.DocumentManager.flushDocIfLoaded
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-				});
-
-				return it("should call the callback with the lines and versions", function() {
-					return this.callback.calledWith(null, this.lines, this.version).should.equal(true);
-				});
-			});
-
-			return describe("and has only changes that don't need to be flushed", function() {
-				beforeEach(function() {
-					this.DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, this.pathname, Date.now() - 100, true);
-					return this.DocumentManager.getDocAndFlushIfOld(this.project_id, this.doc_id, this.callback);
-				});
-
-				it("should get the doc", function() {
-					return this.DocumentManager.getDoc
-						.calledWith(this.project_id, this.doc_id)
-						.should.equal(true);
-				});
-
-				it("should not flush the doc", function() {
-					return this.DocumentManager.flushDocIfLoaded
-					.called.should.equal(false);
-				});
-
-				return it("should call the callback with the lines and versions", function() {
-					return this.callback.calledWith(null, this.lines, this.version).should.equal(true);
-				});
-			});
-		});
-
-		return describe("when the doc is not in Redis", function() {
-			beforeEach(function() {
-				this.DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, null, false);
-				return this.DocumentManager.getDocAndFlushIfOld(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should get the doc", function() {
-				return this.DocumentManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should not flush the doc", function() {
-				return this.DocumentManager.flushDocIfLoaded
-				.called.should.equal(false);
-			});
-
-			return it("should call the callback with the lines and versions", function() {
-				return this.callback.calledWith(null, this.lines, this.version).should.equal(true);
-			});
-		});
-	});
-
-	describe("renameDoc", function() {
-		beforeEach(function() {
-			this.update = 'some-update';
-			return this.RedisManager.renameDoc = sinon.stub().yields();
-		});
-
-		return describe("successfully", function() {
-			beforeEach(function() {
-				return this.DocumentManager.renameDoc(this.project_id, this.doc_id, this.user_id, this.update, this.projectHistoryId, this.callback);
-			});
-
-			it("should rename the document", function() {
-				return this.RedisManager.renameDoc
-					.calledWith(this.project_id, this.doc_id, this.user_id, this.update, this.projectHistoryId)
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-	});
-
-	return describe("resyncDocContents", function() {
-		describe("when doc is loaded in redis", function() {
-			beforeEach(function() {
-				this.RedisManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId);
-				this.ProjectHistoryRedisManager.queueResyncDocContent = sinon.stub();
-				return this.DocumentManager.resyncDocContents(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("gets the doc contents from redis", function() {
-				return this.RedisManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			return it("queues a resync doc content update", function() {
-				return this.ProjectHistoryRedisManager.queueResyncDocContent
-					.calledWith(this.project_id, this.projectHistoryId, this.doc_id, this.lines, this.version, this.pathname, this.callback)
-					.should.equal(true);
-			});
-		});
-
-		return describe("when doc is not loaded in redis", function() {
-			beforeEach(function() {
-				this.RedisManager.getDoc = sinon.stub().callsArgWith(2, null);
-				this.PersistenceManager.getDoc = sinon.stub().callsArgWith(2, null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId);
-				this.ProjectHistoryRedisManager.queueResyncDocContent = sinon.stub();
-				return this.DocumentManager.resyncDocContents(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("tries to get the doc contents from redis", function() {
-				return this.RedisManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("gets the doc contents from web", function() {
-				return this.PersistenceManager.getDoc
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			return it("queues a resync doc content update", function() {
-				return this.ProjectHistoryRedisManager.queueResyncDocContent
-					.calledWith(this.project_id, this.projectHistoryId, this.doc_id, this.lines, this.version, this.pathname, this.callback)
-					.should.equal(true);
-			});
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/DocumentManager.js'
+const SandboxedModule = require('sandboxed-module')
+const Errors = require('../../../../app/js/Errors')
+const tk = require('timekeeper')
+
+describe('DocumentManager', function () {
+  beforeEach(function () {
+    let Timer
+    tk.freeze(new Date())
+    this.DocumentManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './RedisManager': (this.RedisManager = {}),
+        './ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
+        './PersistenceManager': (this.PersistenceManager = {}),
+        './HistoryManager': (this.HistoryManager = {
+          flushDocChangesAsync: sinon.stub(),
+          flushProjectChangesAsync: sinon.stub()
+        }),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          warn: sinon.stub()
+        }),
+        './DocOpsManager': (this.DocOpsManager = {}),
+        './Metrics': (this.Metrics = {
+          Timer: (Timer = (function () {
+            Timer = class Timer {
+              static initClass() {
+                this.prototype.done = sinon.stub()
+              }
+            }
+            Timer.initClass()
+            return Timer
+          })())
+        }),
+        './RealTimeRedisManager': (this.RealTimeRedisManager = {}),
+        './DiffCodec': (this.DiffCodec = {}),
+        './UpdateManager': (this.UpdateManager = {}),
+        './RangesManager': (this.RangesManager = {})
+      }
+    })
+    this.project_id = 'project-id-123'
+    this.projectHistoryId = 'history-id-123'
+    this.projectHistoryType = 'project-history'
+    this.doc_id = 'doc-id-123'
+    this.user_id = 1234
+    this.callback = sinon.stub()
+    this.lines = ['one', 'two', 'three']
+    this.version = 42
+    this.ranges = { comments: 'mock', entries: 'mock' }
+    this.pathname = '/a/b/c.tex'
+    this.unflushedTime = Date.now()
+    this.lastUpdatedAt = Date.now()
+    return (this.lastUpdatedBy = 'last-author-id')
+  })
+
+  afterEach(function () {
+    return tk.reset()
+  })
+
+  describe('flushAndDeleteDoc', function () {
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.RedisManager.removeDocFromMemory = sinon.stub().callsArg(2)
+        this.DocumentManager.flushDocIfLoaded = sinon.stub().callsArgWith(2)
+        return this.DocumentManager.flushAndDeleteDoc(
+          this.project_id,
+          this.doc_id,
+          {},
+          this.callback
+        )
+      })
+
+      it('should flush the doc', function () {
+        return this.DocumentManager.flushDocIfLoaded
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should remove the doc from redis', function () {
+        return this.RedisManager.removeDocFromMemory
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should call the callback without error', function () {
+        return this.callback.calledWith(null).should.equal(true)
+      })
+
+      it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+
+      return it('should flush to the history api', function () {
+        return this.HistoryManager.flushDocChangesAsync
+          .calledWithExactly(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+    })
+
+    return describe('when a flush error occurs', function () {
+      beforeEach(function () {
+        this.DocumentManager.flushDocIfLoaded = sinon
+          .stub()
+          .callsArgWith(2, new Error('boom!'))
+        return (this.RedisManager.removeDocFromMemory = sinon
+          .stub()
+          .callsArg(2))
+      })
+
+      it('should not remove the doc from redis', function (done) {
+        return this.DocumentManager.flushAndDeleteDoc(
+          this.project_id,
+          this.doc_id,
+          {},
+          (error) => {
+            error.should.exist
+            this.RedisManager.removeDocFromMemory.called.should.equal(false)
+            return done()
+          }
+        )
+      })
+
+      return describe('when ignoring flush errors', function () {
+        return it('should remove the doc from redis', function (done) {
+          return this.DocumentManager.flushAndDeleteDoc(
+            this.project_id,
+            this.doc_id,
+            { ignoreFlushErrors: true },
+            (error) => {
+              if (error != null) {
+                return done(error)
+              }
+              this.RedisManager.removeDocFromMemory.called.should.equal(true)
+              return done()
+            }
+          )
+        })
+      })
+    })
+  })
+
+  describe('flushDocIfLoaded', function () {
+    describe('when the doc is in Redis', function () {
+      beforeEach(function () {
+        this.RedisManager.getDoc = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId,
+            this.unflushedTime,
+            this.lastUpdatedAt,
+            this.lastUpdatedBy
+          )
+        this.RedisManager.clearUnflushedTime = sinon
+          .stub()
+          .callsArgWith(1, null)
+        this.PersistenceManager.setDoc = sinon.stub().yields()
+        return this.DocumentManager.flushDocIfLoaded(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the doc from redis', function () {
+        return this.RedisManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should write the doc lines to the persistence layer', function () {
+        return this.PersistenceManager.setDoc
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.lastUpdatedAt,
+            this.lastUpdatedBy
+          )
+          .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 the document is not in Redis', function () {
+      beforeEach(function () {
+        this.RedisManager.getDoc = sinon
+          .stub()
+          .callsArgWith(2, null, null, null, null)
+        this.PersistenceManager.setDoc = sinon.stub().yields()
+        this.DocOpsManager.flushDocOpsToMongo = sinon.stub().callsArgWith(2)
+        return this.DocumentManager.flushDocIfLoaded(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the doc from redis', function () {
+        return this.RedisManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should not write anything to the persistence layer', function () {
+        this.PersistenceManager.setDoc.called.should.equal(false)
+        return this.DocOpsManager.flushDocOpsToMongo.called.should.equal(false)
+      })
+
+      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)
+      })
+    })
+  })
+
+  describe('getDocAndRecentOps', function () {
+    describe('with a previous version specified', function () {
+      beforeEach(function () {
+        this.DocumentManager.getDoc = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId
+          )
+        this.RedisManager.getPreviousDocOps = sinon
+          .stub()
+          .callsArgWith(3, null, this.ops)
+        return this.DocumentManager.getDocAndRecentOps(
+          this.project_id,
+          this.doc_id,
+          this.fromVersion,
+          this.callback
+        )
+      })
+
+      it('should get the doc', function () {
+        return this.DocumentManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should get the doc ops', function () {
+        return this.RedisManager.getPreviousDocOps
+          .calledWith(this.doc_id, this.fromVersion, this.version)
+          .should.equal(true)
+      })
+
+      it('should call the callback with the doc info', function () {
+        return this.callback
+          .calledWith(
+            null,
+            this.lines,
+            this.version,
+            this.ops,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+    })
+
+    return describe('with no previous version specified', function () {
+      beforeEach(function () {
+        this.DocumentManager.getDoc = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId
+          )
+        this.RedisManager.getPreviousDocOps = sinon
+          .stub()
+          .callsArgWith(3, null, this.ops)
+        return this.DocumentManager.getDocAndRecentOps(
+          this.project_id,
+          this.doc_id,
+          -1,
+          this.callback
+        )
+      })
+
+      it('should get the doc', function () {
+        return this.DocumentManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should not need to get the doc ops', function () {
+        return this.RedisManager.getPreviousDocOps.called.should.equal(false)
+      })
+
+      it('should call the callback with the doc info', function () {
+        return this.callback
+          .calledWith(
+            null,
+            this.lines,
+            this.version,
+            [],
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+    })
+  })
+
+  describe('getDoc', function () {
+    describe('when the doc exists in Redis', function () {
+      beforeEach(function () {
+        this.RedisManager.getDoc = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId,
+            this.unflushedTime
+          )
+        return this.DocumentManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the doc from Redis', function () {
+        return this.RedisManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should call the callback with the doc info', function () {
+        return this.callback
+          .calledWith(
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId,
+            this.unflushedTime,
+            true
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+    })
+
+    return describe('when the doc does not exist in Redis', function () {
+      beforeEach(function () {
+        this.RedisManager.getDoc = sinon
+          .stub()
+          .callsArgWith(2, null, null, null, null, null, null)
+        this.PersistenceManager.getDoc = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId,
+            this.projectHistoryType
+          )
+        this.RedisManager.putDocInMemory = sinon.stub().yields()
+        this.RedisManager.setHistoryType = sinon.stub().yields()
+        return this.DocumentManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should try to get the doc from Redis', function () {
+        return this.RedisManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should get the doc from the PersistenceManager', function () {
+        return this.PersistenceManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should set the doc in Redis', function () {
+        return this.RedisManager.putDocInMemory
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId
+          )
+          .should.equal(true)
+      })
+
+      it('should set the history type in Redis', function () {
+        return this.RedisManager.setHistoryType
+          .calledWith(this.doc_id, this.projectHistoryType)
+          .should.equal(true)
+      })
+
+      it('should call the callback with the doc info', function () {
+        return this.callback
+          .calledWith(
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId,
+            null,
+            false
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+    })
+  })
+
+  describe('setDoc', function () {
+    return describe('with plain tex lines', function () {
+      beforeEach(function () {
+        this.beforeLines = ['before', 'lines']
+        this.afterLines = ['after', 'lines']
+        this.ops = [
+          { i: 'foo', p: 4 },
+          { d: 'bar', p: 42 }
+        ]
+        this.DocumentManager.getDoc = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.beforeLines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId,
+            this.unflushedTime,
+            true
+          )
+        this.DiffCodec.diffAsShareJsOp = sinon
+          .stub()
+          .callsArgWith(2, null, this.ops)
+        this.UpdateManager.applyUpdate = sinon.stub().callsArgWith(3, null)
+        this.DocumentManager.flushDocIfLoaded = sinon.stub().callsArg(2)
+        return (this.DocumentManager.flushAndDeleteDoc = sinon
+          .stub()
+          .callsArg(3))
+      })
+
+      describe('when already loaded', function () {
+        beforeEach(function () {
+          return this.DocumentManager.setDoc(
+            this.project_id,
+            this.doc_id,
+            this.afterLines,
+            this.source,
+            this.user_id,
+            false,
+            this.callback
+          )
+        })
+
+        it('should get the current doc lines', function () {
+          return this.DocumentManager.getDoc
+            .calledWith(this.project_id, this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should return a diff of the old and new lines', function () {
+          return this.DiffCodec.diffAsShareJsOp
+            .calledWith(this.beforeLines, this.afterLines)
+            .should.equal(true)
+        })
+
+        it('should apply the diff as a ShareJS op', function () {
+          return this.UpdateManager.applyUpdate
+            .calledWith(this.project_id, this.doc_id, {
+              doc: this.doc_id,
+              v: this.version,
+              op: this.ops,
+              meta: {
+                type: 'external',
+                source: this.source,
+                user_id: this.user_id
+              }
+            })
+            .should.equal(true)
+        })
+
+        it('should flush the doc to Mongo', function () {
+          return this.DocumentManager.flushDocIfLoaded
+            .calledWith(this.project_id, this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should not flush the project history', function () {
+          return this.HistoryManager.flushProjectChangesAsync.called.should.equal(
+            false
+          )
+        })
+
+        it('should call the callback', 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)
+        })
+      })
+
+      describe('when not already loaded', function () {
+        beforeEach(function () {
+          this.DocumentManager.getDoc = sinon
+            .stub()
+            .callsArgWith(
+              2,
+              null,
+              this.beforeLines,
+              this.version,
+              this.pathname,
+              null,
+              false
+            )
+          return this.DocumentManager.setDoc(
+            this.project_id,
+            this.doc_id,
+            this.afterLines,
+            this.source,
+            this.user_id,
+            false,
+            this.callback
+          )
+        })
+
+        it('should flush and delete the doc from the doc updater', function () {
+          return this.DocumentManager.flushAndDeleteDoc
+            .calledWith(this.project_id, this.doc_id, {})
+            .should.equal(true)
+        })
+
+        return it('should not flush the project history', function () {
+          return this.HistoryManager.flushProjectChangesAsync
+            .calledWithExactly(this.project_id)
+            .should.equal(true)
+        })
+      })
+
+      describe('without new lines', function () {
+        beforeEach(function () {
+          return this.DocumentManager.setDoc(
+            this.project_id,
+            this.doc_id,
+            null,
+            this.source,
+            this.user_id,
+            false,
+            this.callback
+          )
+        })
+
+        it('should return the callback with an error', function () {
+          return this.callback.calledWith(
+            new Error('No lines were passed to setDoc')
+          )
+        })
+
+        return it('should not try to get the doc lines', function () {
+          return this.DocumentManager.getDoc.called.should.equal(false)
+        })
+      })
+
+      return describe('with the undoing flag', function () {
+        beforeEach(function () {
+          // Copy ops so we don't interfere with other tests
+          this.ops = [
+            { i: 'foo', p: 4 },
+            { d: 'bar', p: 42 }
+          ]
+          this.DiffCodec.diffAsShareJsOp = sinon
+            .stub()
+            .callsArgWith(2, null, this.ops)
+          return this.DocumentManager.setDoc(
+            this.project_id,
+            this.doc_id,
+            this.afterLines,
+            this.source,
+            this.user_id,
+            true,
+            this.callback
+          )
+        })
+
+        return it('should set the undo flag on each op', function () {
+          return Array.from(this.ops).map((op) => op.u.should.equal(true))
+        })
+      })
+    })
+  })
+
+  describe('acceptChanges', function () {
+    beforeEach(function () {
+      this.change_id = 'mock-change-id'
+      this.change_ids = [
+        'mock-change-id-1',
+        'mock-change-id-2',
+        'mock-change-id-3',
+        'mock-change-id-4'
+      ]
+      this.version = 34
+      this.lines = ['original', 'lines']
+      this.ranges = { entries: 'mock', comments: 'mock' }
+      this.updated_ranges = { entries: 'updated', comments: 'updated' }
+      this.DocumentManager.getDoc = sinon
+        .stub()
+        .yields(null, this.lines, this.version, this.ranges)
+      this.RangesManager.acceptChanges = sinon
+        .stub()
+        .yields(null, this.updated_ranges)
+      return (this.RedisManager.updateDocument = sinon.stub().yields())
+    })
+
+    describe('successfully with a single change', function () {
+      beforeEach(function () {
+        return this.DocumentManager.acceptChanges(
+          this.project_id,
+          this.doc_id,
+          [this.change_id],
+          this.callback
+        )
+      })
+
+      it("should get the document's current ranges", function () {
+        return this.DocumentManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should apply the accept change to the ranges', function () {
+        return this.RangesManager.acceptChanges
+          .calledWith([this.change_id], this.ranges)
+          .should.equal(true)
+      })
+
+      it('should save the updated ranges', function () {
+        return this.RedisManager.updateDocument
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.lines,
+            this.version,
+            [],
+            this.updated_ranges,
+            {}
+          )
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    describe('successfully with multiple changes', function () {
+      beforeEach(function () {
+        return this.DocumentManager.acceptChanges(
+          this.project_id,
+          this.doc_id,
+          this.change_ids,
+          this.callback
+        )
+      })
+
+      return it('should apply the accept change to the ranges', function () {
+        return this.RangesManager.acceptChanges
+          .calledWith(this.change_ids, this.ranges)
+          .should.equal(true)
+      })
+    })
+
+    return describe('when the doc is not found', function () {
+      beforeEach(function () {
+        this.DocumentManager.getDoc = sinon
+          .stub()
+          .yields(null, null, null, null)
+        return this.DocumentManager.acceptChanges(
+          this.project_id,
+          this.doc_id,
+          [this.change_id],
+          this.callback
+        )
+      })
+
+      it('should not save anything', function () {
+        return this.RedisManager.updateDocument.called.should.equal(false)
+      })
+
+      return it('should call the callback with a not found error', function () {
+        const error = new Errors.NotFoundError(
+          `document not found: ${this.doc_id}`
+        )
+        return this.callback.calledWith(error).should.equal(true)
+      })
+    })
+  })
+
+  describe('deleteComment', function () {
+    beforeEach(function () {
+      this.comment_id = 'mock-comment-id'
+      this.version = 34
+      this.lines = ['original', 'lines']
+      this.ranges = { comments: ['one', 'two', 'three'] }
+      this.updated_ranges = { comments: ['one', 'three'] }
+      this.DocumentManager.getDoc = sinon
+        .stub()
+        .yields(null, this.lines, this.version, this.ranges)
+      this.RangesManager.deleteComment = sinon
+        .stub()
+        .yields(null, this.updated_ranges)
+      return (this.RedisManager.updateDocument = sinon.stub().yields())
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        return this.DocumentManager.deleteComment(
+          this.project_id,
+          this.doc_id,
+          this.comment_id,
+          this.callback
+        )
+      })
+
+      it("should get the document's current ranges", function () {
+        return this.DocumentManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should delete the comment from the ranges', function () {
+        return this.RangesManager.deleteComment
+          .calledWith(this.comment_id, this.ranges)
+          .should.equal(true)
+      })
+
+      it('should save the updated ranges', function () {
+        return this.RedisManager.updateDocument
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.lines,
+            this.version,
+            [],
+            this.updated_ranges,
+            {}
+          )
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    return describe('when the doc is not found', function () {
+      beforeEach(function () {
+        this.DocumentManager.getDoc = sinon
+          .stub()
+          .yields(null, null, null, null)
+        return this.DocumentManager.acceptChanges(
+          this.project_id,
+          this.doc_id,
+          [this.comment_id],
+          this.callback
+        )
+      })
+
+      it('should not save anything', function () {
+        return this.RedisManager.updateDocument.called.should.equal(false)
+      })
+
+      return it('should call the callback with a not found error', function () {
+        const error = new Errors.NotFoundError(
+          `document not found: ${this.doc_id}`
+        )
+        return this.callback.calledWith(error).should.equal(true)
+      })
+    })
+  })
+
+  describe('getDocAndFlushIfOld', function () {
+    beforeEach(function () {
+      return (this.DocumentManager.flushDocIfLoaded = sinon.stub().callsArg(2))
+    })
+
+    describe('when the doc is in Redis', function () {
+      describe('and has changes to be flushed', function () {
+        beforeEach(function () {
+          this.DocumentManager.getDoc = sinon
+            .stub()
+            .callsArgWith(
+              2,
+              null,
+              this.lines,
+              this.version,
+              this.ranges,
+              this.projectHistoryId,
+              this.pathname,
+              Date.now() - 1e9,
+              true
+            )
+          return this.DocumentManager.getDocAndFlushIfOld(
+            this.project_id,
+            this.doc_id,
+            this.callback
+          )
+        })
+
+        it('should get the doc', function () {
+          return this.DocumentManager.getDoc
+            .calledWith(this.project_id, this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should flush the doc', function () {
+          return this.DocumentManager.flushDocIfLoaded
+            .calledWith(this.project_id, this.doc_id)
+            .should.equal(true)
+        })
+
+        return it('should call the callback with the lines and versions', function () {
+          return this.callback
+            .calledWith(null, this.lines, this.version)
+            .should.equal(true)
+        })
+      })
+
+      return describe("and has only changes that don't need to be flushed", function () {
+        beforeEach(function () {
+          this.DocumentManager.getDoc = sinon
+            .stub()
+            .callsArgWith(
+              2,
+              null,
+              this.lines,
+              this.version,
+              this.ranges,
+              this.pathname,
+              Date.now() - 100,
+              true
+            )
+          return this.DocumentManager.getDocAndFlushIfOld(
+            this.project_id,
+            this.doc_id,
+            this.callback
+          )
+        })
+
+        it('should get the doc', function () {
+          return this.DocumentManager.getDoc
+            .calledWith(this.project_id, this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should not flush the doc', function () {
+          return this.DocumentManager.flushDocIfLoaded.called.should.equal(
+            false
+          )
+        })
+
+        return it('should call the callback with the lines and versions', function () {
+          return this.callback
+            .calledWith(null, this.lines, this.version)
+            .should.equal(true)
+        })
+      })
+    })
+
+    return describe('when the doc is not in Redis', function () {
+      beforeEach(function () {
+        this.DocumentManager.getDoc = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            null,
+            false
+          )
+        return this.DocumentManager.getDocAndFlushIfOld(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the doc', function () {
+        return this.DocumentManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should not flush the doc', function () {
+        return this.DocumentManager.flushDocIfLoaded.called.should.equal(false)
+      })
+
+      return it('should call the callback with the lines and versions', function () {
+        return this.callback
+          .calledWith(null, this.lines, this.version)
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('renameDoc', function () {
+    beforeEach(function () {
+      this.update = 'some-update'
+      return (this.RedisManager.renameDoc = sinon.stub().yields())
+    })
+
+    return describe('successfully', function () {
+      beforeEach(function () {
+        return this.DocumentManager.renameDoc(
+          this.project_id,
+          this.doc_id,
+          this.user_id,
+          this.update,
+          this.projectHistoryId,
+          this.callback
+        )
+      })
+
+      it('should rename the document', function () {
+        return this.RedisManager.renameDoc
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.user_id,
+            this.update,
+            this.projectHistoryId
+          )
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+  })
+
+  return describe('resyncDocContents', function () {
+    describe('when doc is loaded in redis', function () {
+      beforeEach(function () {
+        this.RedisManager.getDoc = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId
+          )
+        this.ProjectHistoryRedisManager.queueResyncDocContent = sinon.stub()
+        return this.DocumentManager.resyncDocContents(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('gets the doc contents from redis', function () {
+        return this.RedisManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('queues a resync doc content update', function () {
+        return this.ProjectHistoryRedisManager.queueResyncDocContent
+          .calledWith(
+            this.project_id,
+            this.projectHistoryId,
+            this.doc_id,
+            this.lines,
+            this.version,
+            this.pathname,
+            this.callback
+          )
+          .should.equal(true)
+      })
+    })
+
+    return describe('when doc is not loaded in redis', function () {
+      beforeEach(function () {
+        this.RedisManager.getDoc = sinon.stub().callsArgWith(2, null)
+        this.PersistenceManager.getDoc = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId
+          )
+        this.ProjectHistoryRedisManager.queueResyncDocContent = sinon.stub()
+        return this.DocumentManager.resyncDocContents(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('tries to get the doc contents from redis', function () {
+        return this.RedisManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('gets the doc contents from web', function () {
+        return this.PersistenceManager.getDoc
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('queues a resync doc content update', function () {
+        return this.ProjectHistoryRedisManager.queueResyncDocContent
+          .calledWith(
+            this.project_id,
+            this.projectHistoryId,
+            this.doc_id,
+            this.lines,
+            this.version,
+            this.pathname,
+            this.callback
+          )
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 414 - 302
services/document-updater/test/unit/js/HistoryManager/HistoryManagerTests.js

@@ -9,305 +9,417 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../../app/js/HistoryManager');
-
-describe("HistoryManager", function() {
-	beforeEach(function() {
-		this.HistoryManager = SandboxedModule.require(modulePath, { requires: {
-			"request": (this.request = {}),
-			"settings-sharelatex": (this.Settings = {
-				apis: {
-					project_history: {
-						enabled: true,
-						url: "http://project_history.example.com"
-					},
-					trackchanges: {
-						url: "http://trackchanges.example.com"
-					}
-				}
-			}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub(), debug: sinon.stub() }),
-			"./DocumentManager": (this.DocumentManager = {}),
-			"./HistoryRedisManager": (this.HistoryRedisManager = {}),
-			"./RedisManager": (this.RedisManager = {}),
-			"./ProjectHistoryRedisManager": (this.ProjectHistoryRedisManager = {}),
-			"./Metrics": (this.metrics = {inc: sinon.stub()})
-		}
-	});
-		this.project_id = "mock-project-id";
-		this.doc_id = "mock-doc-id";
-		return this.callback = sinon.stub();
-	});
-
-	describe("flushDocChangesAsync", function() {
-		beforeEach(function() {
-			return this.request.post = sinon.stub().callsArgWith(1, null, {statusCode: 204});
-		});
-
-		describe("when the project uses track changes", function() {
-			beforeEach(function() {
-				this.RedisManager.getHistoryType = sinon.stub().yields(null, 'track-changes');
-				return this.HistoryManager.flushDocChangesAsync(this.project_id, this.doc_id);
-			});
-
-			return it("should send a request to the track changes api", function() {
-				return this.request.post
-					.calledWith(`${this.Settings.apis.trackchanges.url}/project/${this.project_id}/doc/${this.doc_id}/flush`)
-					.should.equal(true);
-			});
-		});
-
-		describe("when the project uses project history and double flush is not disabled", function() {
-			beforeEach(function() {
-				this.RedisManager.getHistoryType = sinon.stub().yields(null, 'project-history');
-				return this.HistoryManager.flushDocChangesAsync(this.project_id, this.doc_id);
-			});
-
-			return it("should send a request to the track changes api", function() {
-				return this.request.post
-					.called
-					.should.equal(true);
-			});
-		});
-
-		return describe("when the project uses project history and double flush is disabled", function() {
-			beforeEach(function() {
-				this.Settings.disableDoubleFlush = true;
-				this.RedisManager.getHistoryType = sinon.stub().yields(null, 'project-history');
-				return this.HistoryManager.flushDocChangesAsync(this.project_id, this.doc_id);
-			});
-
-			return it("should not send a request to the track changes api", function() {
-				return this.request.post
-					.called
-					.should.equal(false);
-			});
-		});
-	});
-
-
-	describe("flushProjectChangesAsync", function() {
-		beforeEach(function() {
-			this.request.post = sinon.stub().callsArgWith(1, null, {statusCode: 204});
-
-			return this.HistoryManager.flushProjectChangesAsync(this.project_id);
-		});
-
-		return it("should send a request to the project history api", function() {
-			return 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() {
-				this.request.post = sinon.stub().callsArgWith(1, null, {statusCode: 204});
-				return this.HistoryManager.flushProjectChanges(this.project_id, {background:true});});
-
-			return it("should send a request to the project history api", function() {
-				return this.request.post
-					.calledWith({url: `${this.Settings.apis.project_history.url}/project/${this.project_id}/flush`, qs:{background:true}})
-					.should.equal(true);
-			});
-		});
-
-		return describe("with the skip_history_flush option", function() {
-			beforeEach(function() {
-				this.request.post = sinon.stub();
-				return this.HistoryManager.flushProjectChanges(this.project_id, {skip_history_flush:true});});
-
-			return it("should not send a request to the project history api", function() {
-				return this.request.post
-					.called
-					.should.equal(false);
-			});
-		});
-	});
-
-	describe("recordAndFlushHistoryOps", function() {
-		beforeEach(function() {
-			this.ops = [ 'mock-ops' ];
-			this.project_ops_length = 10;
-			this.doc_ops_length = 5;
-
-			this.HistoryManager.flushProjectChangesAsync = sinon.stub();
-			this.HistoryRedisManager.recordDocHasHistoryOps = sinon.stub().callsArg(3);
-			return this.HistoryManager.flushDocChangesAsync = sinon.stub();
-		});
-
-		describe("with no ops", function() {
-			beforeEach(function() {
-				return this.HistoryManager.recordAndFlushHistoryOps(
-					this.project_id, this.doc_id, [], this.doc_ops_length, this.project_ops_length, this.callback
-				);
-			});
-
-			it("should not flush project changes", function() {
-				return this.HistoryManager.flushProjectChangesAsync.called.should.equal(false);
-			});
-
-			it("should not record doc has history ops", function() {
-				return this.HistoryRedisManager.recordDocHasHistoryOps.called.should.equal(false);
-			});
-
-			it("should not flush doc changes", function() {
-				return this.HistoryManager.flushDocChangesAsync.called.should.equal(false);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		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.shouldFlushHistoryOps.withArgs(this.doc_ops_length).returns(false);
-
-				return this.HistoryManager.recordAndFlushHistoryOps(
-					this.project_id, this.doc_id, this.ops, this.doc_ops_length, this.project_ops_length, this.callback
-				);
-			});
-
-			it("should flush project changes", function() {
-				return this.HistoryManager.flushProjectChangesAsync
-					.calledWith(this.project_id)
-					.should.equal(true);
-			});
-
-			it("should record doc has history ops", function() {
-				return this.HistoryRedisManager.recordDocHasHistoryOps
-					.calledWith(this.project_id, this.doc_id, this.ops);
-			});
-
-			it("should not flush doc changes", function() {
-				return this.HistoryManager.flushDocChangesAsync.called.should.equal(false);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.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.shouldFlushHistoryOps.withArgs(this.doc_ops_length).returns(true);
-
-				return this.HistoryManager.recordAndFlushHistoryOps(
-					this.project_id, this.doc_id, this.ops, this.doc_ops_length, this.project_ops_length, this.callback
-				);
-			});
-
-			it("should not flush project changes", function() {
-				return this.HistoryManager.flushProjectChangesAsync.called.should.equal(false);
-			});
-
-			it("should record doc has history ops", function() {
-				return this.HistoryRedisManager.recordDocHasHistoryOps
-					.calledWith(this.project_id, this.doc_id, this.ops);
-			});
-
-			it("should flush doc changes", function() {
-				return this.HistoryManager.flushDocChangesAsync
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		describe("when recording doc has history ops errors", function() {
-			beforeEach(function() {
-				this.error = new Error("error");
-				this.HistoryRedisManager.recordDocHasHistoryOps =
-					sinon.stub().callsArgWith(3, this.error);
-
-				return this.HistoryManager.recordAndFlushHistoryOps(
-					this.project_id, this.doc_id, this.ops, this.doc_ops_length, this.project_ops_length, this.callback
-				);
-			});
-
-			it("should not flush doc changes", function() {
-				return this.HistoryManager.flushDocChangesAsync.called.should.equal(false);
-			});
-
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		return describe("shouldFlushHistoryOps", function() {
-			it("should return false if the number of ops is not known", function() {
-				return 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
-				return this.HistoryManager.shouldFlushHistoryOps(15, ['a', 'b', 'c'].length, 5).should.equal(true);
-			});
-
-			return 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
-				return this.HistoryManager.shouldFlushHistoryOps(17, ['a', 'b', 'c'].length, 5).should.equal(true);
-			});
-		});
-	});
-
-	return 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();
-			return this.HistoryManager.resyncProjectHistory(this.project_id, this.projectHistoryId, this.docs, this.files, this.callback);
-		});
-
-		it("should queue a project structure reync", function() {
-			return this.ProjectHistoryRedisManager.queueResyncProjectStructure
-				.calledWith(this.project_id, this.projectHistoryId, this.docs, this.files)
-				.should.equal(true);
-		});
-
-		it("should queue doc content reyncs", function() {
-			return this.DocumentManager
-				.resyncDocContentsWithLock
-				.calledWith(this.project_id, this.doc_id)
-				.should.equal(true);
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-});
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../../app/js/HistoryManager'
+)
+
+describe('HistoryManager', function () {
+  beforeEach(function () {
+    this.HistoryManager = SandboxedModule.require(modulePath, {
+      requires: {
+        request: (this.request = {}),
+        'settings-sharelatex': (this.Settings = {
+          apis: {
+            project_history: {
+              enabled: true,
+              url: 'http://project_history.example.com'
+            },
+            trackchanges: {
+              url: 'http://trackchanges.example.com'
+            }
+          }
+        }),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub(),
+          debug: sinon.stub()
+        }),
+        './DocumentManager': (this.DocumentManager = {}),
+        './HistoryRedisManager': (this.HistoryRedisManager = {}),
+        './RedisManager': (this.RedisManager = {}),
+        './ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
+        './Metrics': (this.metrics = { inc: sinon.stub() })
+      }
+    })
+    this.project_id = 'mock-project-id'
+    this.doc_id = 'mock-doc-id'
+    return (this.callback = sinon.stub())
+  })
+
+  describe('flushDocChangesAsync', function () {
+    beforeEach(function () {
+      return (this.request.post = sinon
+        .stub()
+        .callsArgWith(1, null, { statusCode: 204 }))
+    })
+
+    describe('when the project uses track changes', function () {
+      beforeEach(function () {
+        this.RedisManager.getHistoryType = sinon
+          .stub()
+          .yields(null, 'track-changes')
+        return this.HistoryManager.flushDocChangesAsync(
+          this.project_id,
+          this.doc_id
+        )
+      })
+
+      return it('should send a request to the track changes api', function () {
+        return this.request.post
+          .calledWith(
+            `${this.Settings.apis.trackchanges.url}/project/${this.project_id}/doc/${this.doc_id}/flush`
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('when the project uses project history and double flush is not disabled', function () {
+      beforeEach(function () {
+        this.RedisManager.getHistoryType = sinon
+          .stub()
+          .yields(null, 'project-history')
+        return this.HistoryManager.flushDocChangesAsync(
+          this.project_id,
+          this.doc_id
+        )
+      })
+
+      return it('should send a request to the track changes api', function () {
+        return this.request.post.called.should.equal(true)
+      })
+    })
+
+    return describe('when the project uses project history and double flush is disabled', function () {
+      beforeEach(function () {
+        this.Settings.disableDoubleFlush = true
+        this.RedisManager.getHistoryType = sinon
+          .stub()
+          .yields(null, 'project-history')
+        return this.HistoryManager.flushDocChangesAsync(
+          this.project_id,
+          this.doc_id
+        )
+      })
+
+      return it('should not send a request to the track changes api', function () {
+        return this.request.post.called.should.equal(false)
+      })
+    })
+  })
+
+  describe('flushProjectChangesAsync', function () {
+    beforeEach(function () {
+      this.request.post = sinon
+        .stub()
+        .callsArgWith(1, null, { statusCode: 204 })
+
+      return this.HistoryManager.flushProjectChangesAsync(this.project_id)
+    })
+
+    return it('should send a request to the project history api', function () {
+      return 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 () {
+        this.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 204 })
+        return this.HistoryManager.flushProjectChanges(this.project_id, {
+          background: true
+        })
+      })
+
+      return it('should send a request to the project history api', function () {
+        return this.request.post
+          .calledWith({
+            url: `${this.Settings.apis.project_history.url}/project/${this.project_id}/flush`,
+            qs: { background: true }
+          })
+          .should.equal(true)
+      })
+    })
+
+    return describe('with the skip_history_flush option', function () {
+      beforeEach(function () {
+        this.request.post = sinon.stub()
+        return this.HistoryManager.flushProjectChanges(this.project_id, {
+          skip_history_flush: true
+        })
+      })
+
+      return it('should not send a request to the project history api', function () {
+        return this.request.post.called.should.equal(false)
+      })
+    })
+  })
+
+  describe('recordAndFlushHistoryOps', function () {
+    beforeEach(function () {
+      this.ops = ['mock-ops']
+      this.project_ops_length = 10
+      this.doc_ops_length = 5
+
+      this.HistoryManager.flushProjectChangesAsync = sinon.stub()
+      this.HistoryRedisManager.recordDocHasHistoryOps = sinon.stub().callsArg(3)
+      return (this.HistoryManager.flushDocChangesAsync = sinon.stub())
+    })
+
+    describe('with no ops', function () {
+      beforeEach(function () {
+        return this.HistoryManager.recordAndFlushHistoryOps(
+          this.project_id,
+          this.doc_id,
+          [],
+          this.doc_ops_length,
+          this.project_ops_length,
+          this.callback
+        )
+      })
+
+      it('should not flush project changes', function () {
+        return this.HistoryManager.flushProjectChangesAsync.called.should.equal(
+          false
+        )
+      })
+
+      it('should not record doc has history ops', function () {
+        return this.HistoryRedisManager.recordDocHasHistoryOps.called.should.equal(
+          false
+        )
+      })
+
+      it('should not flush doc changes', function () {
+        return this.HistoryManager.flushDocChangesAsync.called.should.equal(
+          false
+        )
+      })
+
+      return it('should call the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    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.shouldFlushHistoryOps
+          .withArgs(this.doc_ops_length)
+          .returns(false)
+
+        return this.HistoryManager.recordAndFlushHistoryOps(
+          this.project_id,
+          this.doc_id,
+          this.ops,
+          this.doc_ops_length,
+          this.project_ops_length,
+          this.callback
+        )
+      })
+
+      it('should flush project changes', function () {
+        return this.HistoryManager.flushProjectChangesAsync
+          .calledWith(this.project_id)
+          .should.equal(true)
+      })
+
+      it('should record doc has history ops', function () {
+        return this.HistoryRedisManager.recordDocHasHistoryOps.calledWith(
+          this.project_id,
+          this.doc_id,
+          this.ops
+        )
+      })
+
+      it('should not flush doc changes', function () {
+        return this.HistoryManager.flushDocChangesAsync.called.should.equal(
+          false
+        )
+      })
+
+      return it('should call the callback', function () {
+        return this.callback.called.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.shouldFlushHistoryOps
+          .withArgs(this.doc_ops_length)
+          .returns(true)
+
+        return this.HistoryManager.recordAndFlushHistoryOps(
+          this.project_id,
+          this.doc_id,
+          this.ops,
+          this.doc_ops_length,
+          this.project_ops_length,
+          this.callback
+        )
+      })
+
+      it('should not flush project changes', function () {
+        return this.HistoryManager.flushProjectChangesAsync.called.should.equal(
+          false
+        )
+      })
+
+      it('should record doc has history ops', function () {
+        return this.HistoryRedisManager.recordDocHasHistoryOps.calledWith(
+          this.project_id,
+          this.doc_id,
+          this.ops
+        )
+      })
+
+      it('should flush doc changes', function () {
+        return this.HistoryManager.flushDocChangesAsync
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    describe('when recording doc has history ops errors', function () {
+      beforeEach(function () {
+        this.error = new Error('error')
+        this.HistoryRedisManager.recordDocHasHistoryOps = sinon
+          .stub()
+          .callsArgWith(3, this.error)
+
+        return this.HistoryManager.recordAndFlushHistoryOps(
+          this.project_id,
+          this.doc_id,
+          this.ops,
+          this.doc_ops_length,
+          this.project_ops_length,
+          this.callback
+        )
+      })
+
+      it('should not flush doc changes', function () {
+        return this.HistoryManager.flushDocChangesAsync.called.should.equal(
+          false
+        )
+      })
+
+      return it('should call the callback with the error', function () {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    return describe('shouldFlushHistoryOps', function () {
+      it('should return false if the number of ops is not known', function () {
+        return 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
+        return this.HistoryManager.shouldFlushHistoryOps(
+          15,
+          ['a', 'b', 'c'].length,
+          5
+        ).should.equal(true)
+      })
+
+      return 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
+        return this.HistoryManager.shouldFlushHistoryOps(
+          17,
+          ['a', 'b', 'c'].length,
+          5
+        ).should.equal(true)
+      })
+    })
+  })
+
+  return 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()
+      return this.HistoryManager.resyncProjectHistory(
+        this.project_id,
+        this.projectHistoryId,
+        this.docs,
+        this.files,
+        this.callback
+      )
+    })
+
+    it('should queue a project structure reync', function () {
+      return this.ProjectHistoryRedisManager.queueResyncProjectStructure
+        .calledWith(
+          this.project_id,
+          this.projectHistoryId,
+          this.docs,
+          this.files
+        )
+        .should.equal(true)
+    })
+
+    it('should queue doc content reyncs', function () {
+      return this.DocumentManager.resyncDocContentsWithLock
+        .calledWith(this.project_id, this.doc_id)
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function () {
+      return this.callback.called.should.equal(true)
+    })
+  })
+})

+ 86 - 72
services/document-updater/test/unit/js/HistoryRedisManager/HistoryRedisManagerTests.js

@@ -11,79 +11,93 @@
  * 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 chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/HistoryRedisManager.js";
-const SandboxedModule = require('sandboxed-module');
-const Errors = require("../../../../app/js/Errors");
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/HistoryRedisManager.js'
+const SandboxedModule = require('sandboxed-module')
+const Errors = require('../../../../app/js/Errors')
 
-describe("HistoryRedisManager", function() {
-	beforeEach(function() {
-		this.rclient = {
-			auth() {},
-			exec: sinon.stub()
-		};
-		this.rclient.multi = () => this.rclient;
-		this.HistoryRedisManager = SandboxedModule.require(modulePath, { requires: {
-			"redis-sharelatex": { createClient: () => this.rclient
-		},
-			"settings-sharelatex": {
-				redis: {
-					history: (this.settings = {
-						key_schema: {
-							uncompressedHistoryOps({doc_id}) { return `UncompressedHistoryOps:${doc_id}`; },
-							docsWithHistoryOps({project_id}) { return `DocsWithHistoryOps:${project_id}`; }
-						}
-					})
-				}
-			},
-			"logger-sharelatex": { log() {} }
-		}
-	});
-		this.doc_id = "doc-id-123";
-		this.project_id = "project-id-123";
-		return this.callback = sinon.stub();
-	});
+describe('HistoryRedisManager', function () {
+  beforeEach(function () {
+    this.rclient = {
+      auth() {},
+      exec: sinon.stub()
+    }
+    this.rclient.multi = () => this.rclient
+    this.HistoryRedisManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'redis-sharelatex': { createClient: () => this.rclient },
+        'settings-sharelatex': {
+          redis: {
+            history: (this.settings = {
+              key_schema: {
+                uncompressedHistoryOps({ doc_id }) {
+                  return `UncompressedHistoryOps:${doc_id}`
+                },
+                docsWithHistoryOps({ project_id }) {
+                  return `DocsWithHistoryOps:${project_id}`
+                }
+              }
+            })
+          }
+        },
+        'logger-sharelatex': { log() {} }
+      }
+    })
+    this.doc_id = 'doc-id-123'
+    this.project_id = 'project-id-123'
+    return (this.callback = sinon.stub())
+  })
 
-	return describe("recordDocHasHistoryOps", function() {
-		beforeEach(function() {
-			this.ops = [{ op: [{ i: "foo", p: 4 }] },{ op: [{ i: "bar", p: 56 }] }];
-			return this.rclient.sadd = sinon.stub().yields();
-		});
-		
-		describe("with ops", function() {
-			beforeEach(function(done) {
-				return this.HistoryRedisManager.recordDocHasHistoryOps(this.project_id, this.doc_id, this.ops, (...args) => {
-					this.callback(...Array.from(args || []));
-					return done();
-				});
-			});
-			
-			return it("should add the doc_id to the set of which records the project docs", function() {
-				return this.rclient.sadd
-					.calledWith(`DocsWithHistoryOps:${this.project_id}`, this.doc_id)
-					.should.equal(true);
-			});
-		});
+  return describe('recordDocHasHistoryOps', function () {
+    beforeEach(function () {
+      this.ops = [{ op: [{ i: 'foo', p: 4 }] }, { op: [{ i: 'bar', p: 56 }] }]
+      return (this.rclient.sadd = sinon.stub().yields())
+    })
 
-		return describe("with no ops", function() {
-			beforeEach(function(done) {
-				return this.HistoryRedisManager.recordDocHasHistoryOps(this.project_id, this.doc_id, [], (...args) => {
-					this.callback(...Array.from(args || []));
-					return done();
-				});
-			});
-			
-			it("should not add the doc_id to the set of which records the project docs", function() {
-				return this.rclient.sadd
-					.called
-					.should.equal(false);
-			});
+    describe('with ops', function () {
+      beforeEach(function (done) {
+        return this.HistoryRedisManager.recordDocHasHistoryOps(
+          this.project_id,
+          this.doc_id,
+          this.ops,
+          (...args) => {
+            this.callback(...Array.from(args || []))
+            return done()
+          }
+        )
+      })
 
-			return it("should call the callback with an error", function() {
-				return this.callback.calledWith(new Error("cannot push no ops")).should.equal(true);
-			});
-		});
-	});
-});
+      return it('should add the doc_id to the set of which records the project docs', function () {
+        return this.rclient.sadd
+          .calledWith(`DocsWithHistoryOps:${this.project_id}`, this.doc_id)
+          .should.equal(true)
+      })
+    })
+
+    return describe('with no ops', function () {
+      beforeEach(function (done) {
+        return this.HistoryRedisManager.recordDocHasHistoryOps(
+          this.project_id,
+          this.doc_id,
+          [],
+          (...args) => {
+            this.callback(...Array.from(args || []))
+            return done()
+          }
+        )
+      })
+
+      it('should not add the doc_id to the set of which records the project docs', function () {
+        return this.rclient.sadd.called.should.equal(false)
+      })
+
+      return it('should call the callback with an error', function () {
+        return this.callback
+          .calledWith(new Error('cannot push no ops'))
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 940 - 803
services/document-updater/test/unit/js/HttpController/HttpControllerTests.js

@@ -10,806 +10,943 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/HttpController.js";
-const SandboxedModule = require('sandboxed-module');
-const Errors = require("../../../../app/js/Errors.js");
-
-describe("HttpController", function() {
-	beforeEach(function() {
-		let Timer;
-		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 = (Timer = (function() {
-			Timer = class Timer {
-				static initClass() {
-					this.prototype.done = sinon.stub();
-				}
-			};
-			Timer.initClass();
-			return Timer;
-		})());
-		this.project_id = "project-id-123";
-		this.doc_id = "doc-id-123";
-		this.next = sinon.stub();
-		return 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';
-			return this.req = {
-				params: {
-					project_id: this.project_id,
-					doc_id: this.doc_id
-				}
-			};
-		});
-
-		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);
-				return this.HttpController.getDoc(this.req, this.res, this.next);
-			});
-
-			it("should get the doc", function() {
-				return this.DocumentManager.getDocAndRecentOpsWithLock
-					.calledWith(this.project_id, this.doc_id, -1)
-					.should.equal(true);
-			});
-
-			it("should return the doc as JSON", function() {
-				return 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() {
-				return this.logger.log
-					.calledWith({doc_id: this.doc_id, project_id: this.project_id}, "getting doc via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return 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}`};
-				return this.HttpController.getDoc(this.req, this.res, this.next);
-			});
-
-			it("should get the doc", function() {
-				return this.DocumentManager.getDocAndRecentOpsWithLock
-					.calledWith(this.project_id, this.doc_id, this.fromVersion)
-					.should.equal(true);
-			});
-
-			it("should return the doc as JSON", function() {
-				return 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() {
-				return this.logger.log
-					.calledWith({doc_id: this.doc_id, project_id: this.project_id}, "getting doc via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return 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);
-				return this.HttpController.getDoc(this.req, this.res, this.next);
-			});
-
-			return it("should call next with NotFoundError", function() {
-				return this.next
-					.calledWith(new Errors.NotFoundError("not found"))
-					.should.equal(true);
-			});
-		});
-
-		return describe("when an errors occurs", function() {
-			beforeEach(function() {
-				this.DocumentManager.getDocAndRecentOpsWithLock = sinon.stub().callsArgWith(3, new Error("oops"), null, null);
-				return this.HttpController.getDoc(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("setDoc", function() {
-		beforeEach(function() {
-			this.lines = ["one", "two", "three"];
-			this.source = "dropbox";
-			this.user_id = "user-id-123";
-			return this.req = {
-				headers: {},
-				params: {
-					project_id: this.project_id,
-					doc_id: this.doc_id
-				},
-				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);
-				return this.HttpController.setDoc(this.req, this.res, this.next);
-			});
-
-			it("should set the doc", function() {
-				return 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() {
-				return this.res.sendStatus
-					.calledWith(204)
-					.should.equal(true);
-			});
-
-			it("should log the request", function() {
-				return this.logger.log
-					.calledWith({doc_id: this.doc_id, project_id: this.project_id, lines: this.lines, source: this.source, user_id: this.user_id, undoing: this.undoing}, "setting doc via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return 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"));
-				return this.HttpController.setDoc(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-
-		return 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);
-				return this.HttpController.setDoc(this.req, this.res, this.next);
-			});
-
-			it('should send back a 406 response', function() {
-				return this.res.sendStatus.calledWith(406).should.equal(true);
-			});
-
-			return it('should not call setDocWithLock', function() {
-				return this.DocumentManager.setDocWithLock.callCount.should.equal(0);
-			});
-		});
-	});
-
-	describe("flushProject", function() {
-		beforeEach(function() {
-			return this.req = {
-				params: {
-					project_id: this.project_id
-				}
-			};
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.ProjectManager.flushProjectWithLocks = sinon.stub().callsArgWith(1);
-				return this.HttpController.flushProject(this.req, this.res, this.next);
-			});
-
-			it("should flush the project", function() {
-				return this.ProjectManager.flushProjectWithLocks
-					.calledWith(this.project_id)
-					.should.equal(true);
-			});
-
-			it("should return a successful No Content response", function() {
-				return this.res.sendStatus
-					.calledWith(204)
-					.should.equal(true);
-			});
-
-			it("should log the request", function() {
-				return this.logger.log
-					.calledWith({project_id: this.project_id}, "flushing project via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-		});
-
-		return describe("when an errors occurs", function() {
-			beforeEach(function() {
-				this.ProjectManager.flushProjectWithLocks = sinon.stub().callsArgWith(1, new Error("oops"));
-				return this.HttpController.flushProject(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("flushDocIfLoaded", function() {
-		beforeEach(function() {
-			this.lines = ["one", "two", "three"];
-			this.version = 42;
-			return this.req = {
-				params: {
-					project_id: this.project_id,
-					doc_id: this.doc_id
-				}
-			};
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.DocumentManager.flushDocIfLoadedWithLock = sinon.stub().callsArgWith(2);
-				return this.HttpController.flushDocIfLoaded(this.req, this.res, this.next);
-			});
-
-			it("should flush the doc", function() {
-				return this.DocumentManager.flushDocIfLoadedWithLock
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should return a successful No Content response", function() {
-				return this.res.sendStatus
-					.calledWith(204)
-					.should.equal(true);
-			});
-
-			it("should log the request", function() {
-				return this.logger.log
-					.calledWith({doc_id: this.doc_id, project_id: this.project_id}, "flushing doc via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-		});
-
-		return describe("when an errors occurs", function() {
-			beforeEach(function() {
-				this.DocumentManager.flushDocIfLoadedWithLock = sinon.stub().callsArgWith(2, new Error("oops"));
-				return this.HttpController.flushDocIfLoaded(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("deleteDoc", function() {
-		beforeEach(function() {
-			return this.req = {
-				params: {
-					project_id: this.project_id,
-					doc_id: this.doc_id
-				},
-				query: {}
-			};});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.DocumentManager.flushAndDeleteDocWithLock = sinon.stub().callsArgWith(3);
-				return this.HttpController.deleteDoc(this.req, this.res, this.next);
-			});
-
-			it("should flush and delete the doc", function() {
-				return this.DocumentManager.flushAndDeleteDocWithLock
-					.calledWith(this.project_id, this.doc_id, { ignoreFlushErrors: false })
-					.should.equal(true);
-			});
-
-			it("should flush project history", function() {
-				return this.HistoryManager.flushProjectChangesAsync
-					.calledWithExactly(this.project_id)
-					.should.equal(true);
-			});
-
-			it("should return a successful No Content response", function() {
-				return this.res.sendStatus
-					.calledWith(204)
-					.should.equal(true);
-			});
-
-			it("should log the request", function() {
-				return this.logger.log
-					.calledWith({doc_id: this.doc_id, project_id: this.project_id}, "deleting doc via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return 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();
-				return this.HttpController.deleteDoc(this.req, this.res, this.next);
-			});
-
-			it("should delete the doc", function() {
-				return this.DocumentManager.flushAndDeleteDocWithLock
-					.calledWith(this.project_id, this.doc_id, { ignoreFlushErrors: true })
-					.should.equal(true);
-			});
-
-			return it("should return a successful No Content response", function() {
-				return this.res.sendStatus.calledWith(204).should.equal(true);
-			});
-		});
-
-		return describe("when an errors occurs", function() {
-			beforeEach(function() {
-				this.DocumentManager.flushAndDeleteDocWithLock = sinon.stub().callsArgWith(3, new Error("oops"));
-				return this.HttpController.deleteDoc(this.req, this.res, this.next);
-			});
-
-			it("should flush project history", function() {
-				return this.HistoryManager.flushProjectChangesAsync
-					.calledWithExactly(this.project_id)
-					.should.equal(true);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("deleteProject", function() {
-		beforeEach(function() {
-			return this.req = {
-				params: {
-					project_id: this.project_id
-				}
-			};
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.ProjectManager.flushAndDeleteProjectWithLocks = sinon.stub().callsArgWith(2);
-				return this.HttpController.deleteProject(this.req, this.res, this.next);
-			});
-
-			it("should delete the project", function() {
-				return this.ProjectManager.flushAndDeleteProjectWithLocks
-					.calledWith(this.project_id)
-					.should.equal(true);
-			});
-
-			it("should return a successful No Content response", function() {
-				return this.res.sendStatus
-					.calledWith(204)
-					.should.equal(true);
-			});
-
-			it("should log the request", function() {
-				return this.logger.log
-					.calledWith({project_id: this.project_id}, "deleting project via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return 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};
-				return this.HttpController.deleteProject(this.req, this.res, this.next);
-			});
-
-			return it("should queue the flush and delete", function() {
-				return this.ProjectManager.queueFlushAndDeleteProject
-					.calledWith(this.project_id)
-					.should.equal(true);
-			});
-		});
-
-		return describe("when an errors occurs", function() {
-			beforeEach(function() {
-				this.ProjectManager.flushAndDeleteProjectWithLocks = sinon.stub().callsArgWith(2, new Error("oops"));
-				return this.HttpController.deleteProject(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("acceptChanges", function() {
-		beforeEach(function() {
-			return this.req = {
-				params: {
-					project_id: this.project_id,
-					doc_id: this.doc_id,
-					change_id: (this.change_id = "mock-change-od-1")
-				}
-			};
-		});
-
-		describe("successfully with a single change", function() {
-			beforeEach(function() {
-				this.DocumentManager.acceptChangesWithLock = sinon.stub().callsArgWith(3);
-				return this.HttpController.acceptChanges(this.req, this.res, this.next);
-			});
-
-			it("should accept the change", function() {
-				return 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() {
-				return this.res.sendStatus
-					.calledWith(204)
-					.should.equal(true);
-			});
-
-			it("should log the request", function() {
-				return this.logger.log
-					.calledWith({project_id: this.project_id, doc_id: this.doc_id}, "accepting 1 changes via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return 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);
-				return this.HttpController.acceptChanges(this.req, this.res, this.next);
-			});
-
-			it("should accept the changes in the body payload", function() {
-				return this.DocumentManager.acceptChangesWithLock
-					.calledWith(this.project_id, this.doc_id, this.change_ids)
-					.should.equal(true);
-			});
-
-			return it("should log the request with the correct number of changes", function() {
-				return this.logger.log
-					.calledWith({project_id: this.project_id, doc_id: this.doc_id}, `accepting ${ this.change_ids.length } changes via http`)
-					.should.equal(true);
-			});
-		});
-
-		return describe("when an errors occurs", function() {
-			beforeEach(function() {
-				this.DocumentManager.acceptChangesWithLock = sinon.stub().callsArgWith(3, new Error("oops"));
-				return this.HttpController.acceptChanges(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("deleteComment", function() {
-		beforeEach(function() {
-			return this.req = {
-				params: {
-					project_id: this.project_id,
-					doc_id: this.doc_id,
-					comment_id: (this.comment_id = "mock-comment-id")
-				}
-			};
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.DocumentManager.deleteCommentWithLock = sinon.stub().callsArgWith(3);
-				return this.HttpController.deleteComment(this.req, this.res, this.next);
-			});
-
-			it("should accept the change", function() {
-				return 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() {
-				return this.res.sendStatus
-					.calledWith(204)
-					.should.equal(true);
-			});
-
-			it("should log the request", function() {
-				return this.logger.log
-					.calledWith({project_id: this.project_id, doc_id: this.doc_id, comment_id: this.comment_id}, "deleting comment via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-		});
-
-		return describe("when an errors occurs", function() {
-			beforeEach(function() {
-				this.DocumentManager.deleteCommentWithLock = sinon.stub().callsArgWith(3, new Error("oops"));
-				return this.HttpController.deleteComment(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.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}];
-			return this.req = {
-				params: {
-					project_id: this.project_id
-				},
-				query: {
-					state: this.state
-				}
-			};
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.ProjectManager.getProjectDocsAndFlushIfOld = sinon.stub().callsArgWith(3,null, this.docs);
-				return this.HttpController.getProjectDocsAndFlushIfOld(this.req, this.res, this.next);
-			});
-
-			it("should get docs from the project manager", function() {
-				return this.ProjectManager.getProjectDocsAndFlushIfOld
-					.calledWith(this.project_id, this.state, {})
-					.should.equal(true);
-			});
-
-			it("should return a successful response", function() {
-				return this.res.send
-					.calledWith(this.docs)
-					.should.equal(true);
-			});
-
-			it("should log the request", function() {
-				return this.logger.log
-					.calledWith({project_id: this.project_id, exclude: []}, "getting docs via http")
-					.should.equal(true);
-			});
-
-			it("should log the response", function() {
-				return this.logger.log
-					.calledWith({project_id: this.project_id, result: ["1234:23", "4567:45"]}, "got docs via http")
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return 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"));
-				return this.HttpController.getProjectDocsAndFlushIfOld(this.req, this.res, this.next);
-			});
-
-			return it("should return an HTTP 409 Conflict response", function() {
-				return this.res.sendStatus
-					.calledWith(409)
-					.should.equal(true);
-			});
-		});
-
-		return describe("when an error occurs", function() {
-			beforeEach(function() {
-				this.ProjectManager.getProjectDocsAndFlushIfOld = sinon.stub().callsArgWith(3, new Error("oops"));
-				return this.HttpController.getProjectDocsAndFlushIfOld(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("updateProject", function() {
-		beforeEach(function() {
-			this.projectHistoryId = "history-id-123";
-			this.userId = "user-id-123";
-			this.docUpdates = sinon.stub();
-			this.fileUpdates = sinon.stub();
-			this.version = 1234567;
-			return this.req = {
-				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().callsArgWith(6);
-				return this.HttpController.updateProject(this.req, this.res, this.next);
-			});
-
-			it("should accept the change", function() {
-				return this.ProjectManager.updateProjectWithLocks
-					.calledWith(this.project_id, this.projectHistoryId, this.userId, this.docUpdates, this.fileUpdates, this.version)
-					.should.equal(true);
-			});
-
-			it("should return a successful No Content response", function() {
-				return this.res.sendStatus
-					.calledWith(204)
-					.should.equal(true);
-			});
-
-			return it("should time the request", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-		});
-
-		return describe("when an errors occurs", function() {
-			beforeEach(function() {
-				this.ProjectManager.updateProjectWithLocks = sinon.stub().callsArgWith(6, new Error("oops"));
-				return this.HttpController.updateProject(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	return describe("resyncProjectHistory", function() {
-		beforeEach(function() {
-			this.projectHistoryId = "history-id-123";
-			this.docs = sinon.stub();
-			this.files = sinon.stub();
-			this.fileUpdates = sinon.stub();
-			return this.req = {
-				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);
-				return this.HttpController.resyncProjectHistory(this.req, this.res, this.next);
-			});
-
-			it("should accept the change", function() {
-				return this.HistoryManager.resyncProjectHistory
-					.calledWith(this.project_id, this.projectHistoryId, this.docs, this.files)
-					.should.equal(true);
-			});
-
-			return it("should return a successful No Content response", function() {
-				return this.res.sendStatus
-					.calledWith(204)
-					.should.equal(true);
-			});
-		});
-
-		return describe("when an errors occurs", function() {
-			beforeEach(function() {
-				this.HistoryManager.resyncProjectHistory = sinon.stub().callsArgWith(4, new Error("oops"));
-				return this.HttpController.resyncProjectHistory(this.req, this.res, this.next);
-			});
-
-			return it("should call next with the error", function() {
-				return this.next
-					.calledWith(new Error("oops"))
-					.should.equal(true);
-			});
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/HttpController.js'
+const SandboxedModule = require('sandboxed-module')
+const Errors = require('../../../../app/js/Errors.js')
+
+describe('HttpController', function () {
+  beforeEach(function () {
+    let Timer
+    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 = Timer = (function () {
+      Timer = class Timer {
+        static initClass() {
+          this.prototype.done = sinon.stub()
+        }
+      }
+      Timer.initClass()
+      return Timer
+    })()
+    this.project_id = 'project-id-123'
+    this.doc_id = 'doc-id-123'
+    this.next = sinon.stub()
+    return (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'
+      return (this.req = {
+        params: {
+          project_id: this.project_id,
+          doc_id: this.doc_id
+        }
+      })
+    })
+
+    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
+          )
+        return this.HttpController.getDoc(this.req, this.res, this.next)
+      })
+
+      it('should get the doc', function () {
+        return this.DocumentManager.getDocAndRecentOpsWithLock
+          .calledWith(this.project_id, this.doc_id, -1)
+          .should.equal(true)
+      })
+
+      it('should return the doc as JSON', function () {
+        return 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 () {
+        return this.logger.log
+          .calledWith(
+            { doc_id: this.doc_id, project_id: this.project_id },
+            'getting doc via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return 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}` }
+        return this.HttpController.getDoc(this.req, this.res, this.next)
+      })
+
+      it('should get the doc', function () {
+        return this.DocumentManager.getDocAndRecentOpsWithLock
+          .calledWith(this.project_id, this.doc_id, this.fromVersion)
+          .should.equal(true)
+      })
+
+      it('should return the doc as JSON', function () {
+        return 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 () {
+        return this.logger.log
+          .calledWith(
+            { doc_id: this.doc_id, project_id: this.project_id },
+            'getting doc via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return 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)
+        return this.HttpController.getDoc(this.req, this.res, this.next)
+      })
+
+      return it('should call next with NotFoundError', function () {
+        return this.next
+          .calledWith(new Errors.NotFoundError('not found'))
+          .should.equal(true)
+      })
+    })
+
+    return describe('when an errors occurs', function () {
+      beforeEach(function () {
+        this.DocumentManager.getDocAndRecentOpsWithLock = sinon
+          .stub()
+          .callsArgWith(3, new Error('oops'), null, null)
+        return this.HttpController.getDoc(this.req, this.res, this.next)
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+  })
+
+  describe('setDoc', function () {
+    beforeEach(function () {
+      this.lines = ['one', 'two', 'three']
+      this.source = 'dropbox'
+      this.user_id = 'user-id-123'
+      return (this.req = {
+        headers: {},
+        params: {
+          project_id: this.project_id,
+          doc_id: this.doc_id
+        },
+        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)
+        return this.HttpController.setDoc(this.req, this.res, this.next)
+      })
+
+      it('should set the doc', function () {
+        return 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 () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+
+      it('should log the request', function () {
+        return this.logger.log
+          .calledWith(
+            {
+              doc_id: this.doc_id,
+              project_id: this.project_id,
+              lines: this.lines,
+              source: this.source,
+              user_id: this.user_id,
+              undoing: this.undoing
+            },
+            'setting doc via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return 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'))
+        return this.HttpController.setDoc(this.req, this.res, this.next)
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+
+    return 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)
+        return this.HttpController.setDoc(this.req, this.res, this.next)
+      })
+
+      it('should send back a 406 response', function () {
+        return this.res.sendStatus.calledWith(406).should.equal(true)
+      })
+
+      return it('should not call setDocWithLock', function () {
+        return this.DocumentManager.setDocWithLock.callCount.should.equal(0)
+      })
+    })
+  })
+
+  describe('flushProject', function () {
+    beforeEach(function () {
+      return (this.req = {
+        params: {
+          project_id: this.project_id
+        }
+      })
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.ProjectManager.flushProjectWithLocks = sinon.stub().callsArgWith(1)
+        return this.HttpController.flushProject(this.req, this.res, this.next)
+      })
+
+      it('should flush the project', function () {
+        return this.ProjectManager.flushProjectWithLocks
+          .calledWith(this.project_id)
+          .should.equal(true)
+      })
+
+      it('should return a successful No Content response', function () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+
+      it('should log the request', function () {
+        return this.logger.log
+          .calledWith(
+            { project_id: this.project_id },
+            'flushing project via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+    })
+
+    return describe('when an errors occurs', function () {
+      beforeEach(function () {
+        this.ProjectManager.flushProjectWithLocks = sinon
+          .stub()
+          .callsArgWith(1, new Error('oops'))
+        return this.HttpController.flushProject(this.req, this.res, this.next)
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+  })
+
+  describe('flushDocIfLoaded', function () {
+    beforeEach(function () {
+      this.lines = ['one', 'two', 'three']
+      this.version = 42
+      return (this.req = {
+        params: {
+          project_id: this.project_id,
+          doc_id: this.doc_id
+        }
+      })
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.DocumentManager.flushDocIfLoadedWithLock = sinon
+          .stub()
+          .callsArgWith(2)
+        return this.HttpController.flushDocIfLoaded(
+          this.req,
+          this.res,
+          this.next
+        )
+      })
+
+      it('should flush the doc', function () {
+        return this.DocumentManager.flushDocIfLoadedWithLock
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should return a successful No Content response', function () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+
+      it('should log the request', function () {
+        return this.logger.log
+          .calledWith(
+            { doc_id: this.doc_id, project_id: this.project_id },
+            'flushing doc via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+    })
+
+    return describe('when an errors occurs', function () {
+      beforeEach(function () {
+        this.DocumentManager.flushDocIfLoadedWithLock = sinon
+          .stub()
+          .callsArgWith(2, new Error('oops'))
+        return this.HttpController.flushDocIfLoaded(
+          this.req,
+          this.res,
+          this.next
+        )
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+  })
+
+  describe('deleteDoc', function () {
+    beforeEach(function () {
+      return (this.req = {
+        params: {
+          project_id: this.project_id,
+          doc_id: this.doc_id
+        },
+        query: {}
+      })
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.DocumentManager.flushAndDeleteDocWithLock = sinon
+          .stub()
+          .callsArgWith(3)
+        return this.HttpController.deleteDoc(this.req, this.res, this.next)
+      })
+
+      it('should flush and delete the doc', function () {
+        return this.DocumentManager.flushAndDeleteDocWithLock
+          .calledWith(this.project_id, this.doc_id, {
+            ignoreFlushErrors: false
+          })
+          .should.equal(true)
+      })
+
+      it('should flush project history', function () {
+        return this.HistoryManager.flushProjectChangesAsync
+          .calledWithExactly(this.project_id)
+          .should.equal(true)
+      })
+
+      it('should return a successful No Content response', function () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+
+      it('should log the request', function () {
+        return this.logger.log
+          .calledWith(
+            { doc_id: this.doc_id, project_id: this.project_id },
+            'deleting doc via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return 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()
+        return this.HttpController.deleteDoc(this.req, this.res, this.next)
+      })
+
+      it('should delete the doc', function () {
+        return this.DocumentManager.flushAndDeleteDocWithLock
+          .calledWith(this.project_id, this.doc_id, { ignoreFlushErrors: true })
+          .should.equal(true)
+      })
+
+      return it('should return a successful No Content response', function () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+    })
+
+    return describe('when an errors occurs', function () {
+      beforeEach(function () {
+        this.DocumentManager.flushAndDeleteDocWithLock = sinon
+          .stub()
+          .callsArgWith(3, new Error('oops'))
+        return this.HttpController.deleteDoc(this.req, this.res, this.next)
+      })
+
+      it('should flush project history', function () {
+        return this.HistoryManager.flushProjectChangesAsync
+          .calledWithExactly(this.project_id)
+          .should.equal(true)
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+  })
+
+  describe('deleteProject', function () {
+    beforeEach(function () {
+      return (this.req = {
+        params: {
+          project_id: this.project_id
+        }
+      })
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.ProjectManager.flushAndDeleteProjectWithLocks = sinon
+          .stub()
+          .callsArgWith(2)
+        return this.HttpController.deleteProject(this.req, this.res, this.next)
+      })
+
+      it('should delete the project', function () {
+        return this.ProjectManager.flushAndDeleteProjectWithLocks
+          .calledWith(this.project_id)
+          .should.equal(true)
+      })
+
+      it('should return a successful No Content response', function () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+
+      it('should log the request', function () {
+        return this.logger.log
+          .calledWith(
+            { project_id: this.project_id },
+            'deleting project via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return 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 }
+        return this.HttpController.deleteProject(this.req, this.res, this.next)
+      })
+
+      return it('should queue the flush and delete', function () {
+        return this.ProjectManager.queueFlushAndDeleteProject
+          .calledWith(this.project_id)
+          .should.equal(true)
+      })
+    })
+
+    return describe('when an errors occurs', function () {
+      beforeEach(function () {
+        this.ProjectManager.flushAndDeleteProjectWithLocks = sinon
+          .stub()
+          .callsArgWith(2, new Error('oops'))
+        return this.HttpController.deleteProject(this.req, this.res, this.next)
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+  })
+
+  describe('acceptChanges', function () {
+    beforeEach(function () {
+      return (this.req = {
+        params: {
+          project_id: this.project_id,
+          doc_id: this.doc_id,
+          change_id: (this.change_id = 'mock-change-od-1')
+        }
+      })
+    })
+
+    describe('successfully with a single change', function () {
+      beforeEach(function () {
+        this.DocumentManager.acceptChangesWithLock = sinon
+          .stub()
+          .callsArgWith(3)
+        return this.HttpController.acceptChanges(this.req, this.res, this.next)
+      })
+
+      it('should accept the change', function () {
+        return 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 () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+
+      it('should log the request', function () {
+        return this.logger.log
+          .calledWith(
+            { project_id: this.project_id, doc_id: this.doc_id },
+            'accepting 1 changes via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return 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)
+        return this.HttpController.acceptChanges(this.req, this.res, this.next)
+      })
+
+      it('should accept the changes in the body payload', function () {
+        return this.DocumentManager.acceptChangesWithLock
+          .calledWith(this.project_id, this.doc_id, this.change_ids)
+          .should.equal(true)
+      })
+
+      return it('should log the request with the correct number of changes', function () {
+        return this.logger.log
+          .calledWith(
+            { project_id: this.project_id, doc_id: this.doc_id },
+            `accepting ${this.change_ids.length} changes via http`
+          )
+          .should.equal(true)
+      })
+    })
+
+    return describe('when an errors occurs', function () {
+      beforeEach(function () {
+        this.DocumentManager.acceptChangesWithLock = sinon
+          .stub()
+          .callsArgWith(3, new Error('oops'))
+        return this.HttpController.acceptChanges(this.req, this.res, this.next)
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+  })
+
+  describe('deleteComment', function () {
+    beforeEach(function () {
+      return (this.req = {
+        params: {
+          project_id: this.project_id,
+          doc_id: this.doc_id,
+          comment_id: (this.comment_id = 'mock-comment-id')
+        }
+      })
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.DocumentManager.deleteCommentWithLock = sinon
+          .stub()
+          .callsArgWith(3)
+        return this.HttpController.deleteComment(this.req, this.res, this.next)
+      })
+
+      it('should accept the change', function () {
+        return 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 () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+
+      it('should log the request', function () {
+        return this.logger.log
+          .calledWith(
+            {
+              project_id: this.project_id,
+              doc_id: this.doc_id,
+              comment_id: this.comment_id
+            },
+            'deleting comment via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+    })
+
+    return describe('when an errors occurs', function () {
+      beforeEach(function () {
+        this.DocumentManager.deleteCommentWithLock = sinon
+          .stub()
+          .callsArgWith(3, new Error('oops'))
+        return this.HttpController.deleteComment(this.req, this.res, this.next)
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).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 }
+      ]
+      return (this.req = {
+        params: {
+          project_id: this.project_id
+        },
+        query: {
+          state: this.state
+        }
+      })
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.ProjectManager.getProjectDocsAndFlushIfOld = sinon
+          .stub()
+          .callsArgWith(3, null, this.docs)
+        return this.HttpController.getProjectDocsAndFlushIfOld(
+          this.req,
+          this.res,
+          this.next
+        )
+      })
+
+      it('should get docs from the project manager', function () {
+        return this.ProjectManager.getProjectDocsAndFlushIfOld
+          .calledWith(this.project_id, this.state, {})
+          .should.equal(true)
+      })
+
+      it('should return a successful response', function () {
+        return this.res.send.calledWith(this.docs).should.equal(true)
+      })
+
+      it('should log the request', function () {
+        return this.logger.log
+          .calledWith(
+            { project_id: this.project_id, exclude: [] },
+            'getting docs via http'
+          )
+          .should.equal(true)
+      })
+
+      it('should log the response', function () {
+        return this.logger.log
+          .calledWith(
+            { project_id: this.project_id, result: ['1234:23', '4567:45'] },
+            'got docs via http'
+          )
+          .should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return 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')
+          )
+        return this.HttpController.getProjectDocsAndFlushIfOld(
+          this.req,
+          this.res,
+          this.next
+        )
+      })
+
+      return it('should return an HTTP 409 Conflict response', function () {
+        return this.res.sendStatus.calledWith(409).should.equal(true)
+      })
+    })
+
+    return describe('when an error occurs', function () {
+      beforeEach(function () {
+        this.ProjectManager.getProjectDocsAndFlushIfOld = sinon
+          .stub()
+          .callsArgWith(3, new Error('oops'))
+        return this.HttpController.getProjectDocsAndFlushIfOld(
+          this.req,
+          this.res,
+          this.next
+        )
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+  })
+
+  describe('updateProject', function () {
+    beforeEach(function () {
+      this.projectHistoryId = 'history-id-123'
+      this.userId = 'user-id-123'
+      this.docUpdates = sinon.stub()
+      this.fileUpdates = sinon.stub()
+      this.version = 1234567
+      return (this.req = {
+        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()
+          .callsArgWith(6)
+        return this.HttpController.updateProject(this.req, this.res, this.next)
+      })
+
+      it('should accept the change', function () {
+        return this.ProjectManager.updateProjectWithLocks
+          .calledWith(
+            this.project_id,
+            this.projectHistoryId,
+            this.userId,
+            this.docUpdates,
+            this.fileUpdates,
+            this.version
+          )
+          .should.equal(true)
+      })
+
+      it('should return a successful No Content response', function () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+
+      return it('should time the request', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+    })
+
+    return describe('when an errors occurs', function () {
+      beforeEach(function () {
+        this.ProjectManager.updateProjectWithLocks = sinon
+          .stub()
+          .callsArgWith(6, new Error('oops'))
+        return this.HttpController.updateProject(this.req, this.res, this.next)
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+  })
+
+  return describe('resyncProjectHistory', function () {
+    beforeEach(function () {
+      this.projectHistoryId = 'history-id-123'
+      this.docs = sinon.stub()
+      this.files = sinon.stub()
+      this.fileUpdates = sinon.stub()
+      return (this.req = {
+        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)
+        return this.HttpController.resyncProjectHistory(
+          this.req,
+          this.res,
+          this.next
+        )
+      })
+
+      it('should accept the change', function () {
+        return this.HistoryManager.resyncProjectHistory
+          .calledWith(
+            this.project_id,
+            this.projectHistoryId,
+            this.docs,
+            this.files
+          )
+          .should.equal(true)
+      })
+
+      return it('should return a successful No Content response', function () {
+        return this.res.sendStatus.calledWith(204).should.equal(true)
+      })
+    })
+
+    return describe('when an errors occurs', function () {
+      beforeEach(function () {
+        this.HistoryManager.resyncProjectHistory = sinon
+          .stub()
+          .callsArgWith(4, new Error('oops'))
+        return this.HttpController.resyncProjectHistory(
+          this.req,
+          this.res,
+          this.next
+        )
+      })
+
+      return it('should call next with the error', function () {
+        return this.next.calledWith(new Error('oops')).should.equal(true)
+      })
+    })
+  })
+})

+ 50 - 52
services/document-updater/test/unit/js/LockManager/CheckingTheLock.js

@@ -11,59 +11,57 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-require('coffee-script');
-const sinon = require('sinon');
-const assert = require('assert');
-const path = require('path');
-const modulePath = path.join(__dirname, '../../../../app/js/LockManager.js');
-const project_id = 1234;
-const doc_id     = 5678;
-const blockingKey = `Blocking:${doc_id}`;
-const SandboxedModule = require('sandboxed-module');
+require('coffee-script')
+const sinon = require('sinon')
+const assert = require('assert')
+const path = require('path')
+const modulePath = path.join(__dirname, '../../../../app/js/LockManager.js')
+const project_id = 1234
+const doc_id = 5678
+const blockingKey = `Blocking:${doc_id}`
+const SandboxedModule = require('sandboxed-module')
 
-describe('LockManager - checking the lock', function(){
+describe('LockManager - checking the lock', function () {
+  let Profiler
+  const existsStub = sinon.stub()
 
-	let Profiler;
-	const existsStub = sinon.stub();
-	
-	const mocks = {
-		"logger-sharelatex": { log() {}
-	},
-		"redis-sharelatex": {
-			createClient(){
-				return {
-					auth() {},
-					exists: existsStub
-				};
-			}
-		},
-		"./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});
+  const mocks = {
+    'logger-sharelatex': { log() {} },
+    'redis-sharelatex': {
+      createClient() {
+        return {
+          auth() {},
+          exists: existsStub
+        }
+      }
+    },
+    './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(doc_id, (err, free) => {
-			free.should.equal(true);
-			return done();
-		});
-	});
+  it('should return true if the key does not exists', function (done) {
+    existsStub.yields(null, '0')
+    return LockManager.checkLock(doc_id, (err, free) => {
+      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(doc_id, (err, free) => {
-			free.should.equal(false);
-			return done();
-		});
-	});
-});
+  return it('should return false if the key does exists', function (done) {
+    existsStub.yields(null, '1')
+    return LockManager.checkLock(doc_id, (err, free) => {
+      free.should.equal(false)
+      return done()
+    })
+  })
+})

+ 81 - 70
services/document-updater/test/unit/js/LockManager/ReleasingTheLock.js

@@ -11,79 +11,90 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-require('coffee-script');
-const sinon = require('sinon');
-const assert = require('assert');
-const path = require('path');
-const modulePath = path.join(__dirname, '../../../../app/js/LockManager.js');
-const project_id = 1234;
-const doc_id     = 5678;
-const SandboxedModule = require('sandboxed-module');
+require('coffee-script')
+const sinon = require('sinon')
+const assert = require('assert')
+const path = require('path')
+const modulePath = path.join(__dirname, '../../../../app/js/LockManager.js')
+const project_id = 1234
+const doc_id = 5678
+const SandboxedModule = require('sandboxed-module')
 
-describe('LockManager - releasing the lock', function(){
-	beforeEach(function() {
-		let Profiler;
-		this.client = {
-			auth() {},
-			eval: sinon.stub()
-		};
-		const mocks = {
-			"logger-sharelatex": {
-				log() {},
-				error() {}
-			},
-			"redis-sharelatex": {
-				createClient : () => this.client
-			},
-			"settings-sharelatex": {
-				redis: {
-					lock: {
-						key_schema: {
-							blockingKey({doc_id}) { return `Blocking:${doc_id}`; }
-						}
-					}
-				}
-			},
-			"./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('LockManager - releasing the lock', function () {
+  beforeEach(function () {
+    let Profiler
+    this.client = {
+      auth() {},
+      eval: sinon.stub()
+    }
+    const mocks = {
+      'logger-sharelatex': {
+        log() {},
+        error() {}
+      },
+      'redis-sharelatex': {
+        createClient: () => this.client
+      },
+      'settings-sharelatex': {
+        redis: {
+          lock: {
+            key_schema: {
+              blockingKey({ doc_id }) {
+                return `Blocking:${doc_id}`
+              }
+            }
+          }
+        }
+      },
+      './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(doc_id, this.lockValue, this.callback);
-		});
+  describe('when the lock is current', function () {
+    beforeEach(function () {
+      this.client.eval = sinon.stub().yields(null, 1)
+      return this.LockManager.releaseLock(doc_id, this.lockValue, this.callback)
+    })
 
-		it('should clear the data from redis', function() {
-			return this.client.eval.calledWith(this.LockManager.unlockScript, 1, `Blocking:${doc_id}`, this.lockValue).should.equal(true);
-		});
+    it('should clear the data from redis', function () {
+      return this.client.eval
+        .calledWith(
+          this.LockManager.unlockScript,
+          1,
+          `Blocking:${doc_id}`,
+          this.lockValue
+        )
+        .should.equal(true)
+    })
 
-		return it('should call the callback', function() {
-			return this.callback.called.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(doc_id, this.lockValue, this.callback);
-		});
+  return describe('when the lock has expired', function () {
+    beforeEach(function () {
+      this.client.eval = sinon.stub().yields(null, 0)
+      return this.LockManager.releaseLock(doc_id, this.lockValue, this.callback)
+    })
 
-		return it('should return an error if the lock has expired', function() {
-			return this.callback.calledWith(new Error("tried to release timed out lock")).should.equal(true);
-		});
-	});
-});
+    return it('should return an error if the lock has expired', function () {
+      return this.callback
+        .calledWith(new Error('tried to release timed out lock'))
+        .should.equal(true)
+    })
+  })
+})

+ 100 - 102
services/document-updater/test/unit/js/LockManager/getLockTests.js

@@ -14,116 +14,114 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/LockManager.js";
-const SandboxedModule = require('sandboxed-module');
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+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: {
-			"logger-sharelatex": { log() {}
-		},
-			"redis-sharelatex": {
-				createClient : () => {
-					return {auth() {}};
-				}
-			},
-			"./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();
-			});
-		});
+describe('LockManager - getting the lock', function () {
+  beforeEach(function () {
+    let Profiler
+    this.LockManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': { log() {} },
+        'redis-sharelatex': {
+          createClient: () => {
+            return { auth() {} }
+          }
+        },
+        './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')
+  })
 
-		it("should try to get the lock", function() {
-			return this.LockManager.tryLock
-				.calledWith(this.doc_id)
-				.should.equal(true);
-		});
+  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 only need to try once", function() {
-			return this.LockManager.tryLock.callCount.should.equal(1);
-		});
+    it('should try to get the lock', function () {
+      return this.LockManager.tryLock.calledWith(this.doc_id).should.equal(true)
+    })
 
-		return it("should return the callback with the lock value", function() {
-			return this.callback.calledWith(null, this.lockValue).should.equal(true);
-		});
-	});
+    it('should only need to try once', function () {
+      return this.LockManager.tryLock.callCount.should.equal(1)
+    })
 
-	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 = (doc_id, callback) => {
-				if (callback == null) { callback = function(error, isFree) {}; }
-				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 it('should return the callback with the lock value', function () {
+      return this.callback.calledWith(null, this.lockValue).should.equal(true)
+    })
+  })
 
-			return this.LockManager.getLock(this.doc_id, (...args) => {
-				this.callback(...Array.from(args || []));
-				return done();
-			});
-		});
+  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 = (doc_id, callback) => {
+        if (callback == null) {
+          callback = function (error, isFree) {}
+        }
+        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')
 
-		it("should call tryLock multiple times until free", function() {
-			return (this.LockManager.tryLock.callCount > 1).should.equal(true);
-		});
+      return this.LockManager.getLock(this.doc_id, (...args) => {
+        this.callback(...Array.from(args || []))
+        return done()
+      })
+    })
 
-		return it("should return the callback with the lock value", function() {
-			return this.callback.calledWith(null, this.lockValue).should.equal(true);
-		});
-	});
+    it('should call tryLock multiple times until free', function () {
+      return (this.LockManager.tryLock.callCount > 1).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() {
-			const e = new Error("Timeout");
-			e.doc_id = this.doc_id;
-			return this.callback.calledWith(e).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 () {
+      const e = new Error('Timeout')
+      e.doc_id = this.doc_id
+      return this.callback.calledWith(e).should.equal(true)
+    })
+  })
+})

+ 123 - 112
services/document-updater/test/unit/js/LockManager/tryLockTests.js

@@ -11,129 +11,140 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/LockManager.js";
-const SandboxedModule = require('sandboxed-module');
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/LockManager.js'
+const SandboxedModule = require('sandboxed-module')
 
-describe('LockManager - trying the lock', function() {
-	beforeEach(function() {
-		let Profiler;
-		this.LockManager = SandboxedModule.require(modulePath, { requires: {
-			"logger-sharelatex": { log() {}
-		},
-			"redis-sharelatex": {
-				createClient : () => {
-					return {
-						auth() {},
-						set: (this.set = sinon.stub())
-					};
-				}
-			},
-			"./Metrics": {inc() {}},
-			"settings-sharelatex": {
-				redis: {
-					lock: {
-						key_schema: {
-							blockingKey({doc_id}) { return `Blocking:${doc_id}`; }
-						}
-					}
-				}
-			},
-			"./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;
-			})()))
-		}
-	}
-		);
+describe('LockManager - trying the lock', function () {
+  beforeEach(function () {
+    let Profiler
+    this.LockManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': { log() {} },
+        'redis-sharelatex': {
+          createClient: () => {
+            return {
+              auth() {},
+              set: (this.set = sinon.stub())
+            }
+          }
+        },
+        './Metrics': { inc() {} },
+        'settings-sharelatex': {
+          redis: {
+            lock: {
+              key_schema: {
+                blockingKey({ doc_id }) {
+                  return `Blocking:${doc_id}`
+                }
+              }
+            }
+          }
+        },
+        './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);
-		});
+    this.callback = sinon.stub()
+    return (this.doc_id = 'doc-id-123')
+  })
 
-		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);
-		});
+  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)
+    })
 
-		return it("should return the callback with true and the lock value", function() {
-			return this.callback.calledWith(null, true, this.lockValue).should.equal(true);
-		});
-	});
+    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)
+    })
 
-	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 true and the lock value', function () {
+      return this.callback
+        .calledWith(null, true, this.lockValue)
+        .should.equal(true)
+    })
+  })
 
-		return it("should return the callback with false", function() {
-			return this.callback.calledWith(null, false).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 describe("when it takes a long time for redis to set the lock", function() {
-		beforeEach(function() {
-			this.Profiler.prototype.end = () => 7000; // take a long time
-			this.Profiler.prototype.log = sinon.stub().returns({ end: this.Profiler.prototype.end });
-			this.lockValue = "mock-lock-value";
-			this.LockManager.randomLock = sinon.stub().returns(this.lockValue);
-			this.LockManager.releaseLock = sinon.stub().callsArgWith(2,null);
-			return this.set.callsArgWith(5, null, "OK");
-		});
+    return it('should return the callback with false', function () {
+      return this.callback.calledWith(null, false).should.equal(true)
+    })
+  })
 
-		describe("in all cases", function() {
-			beforeEach(function() {
-				return this.LockManager.tryLock(this.doc_id, this.callback);
-			});
+  return describe('when it takes a long time for redis to set the lock', function () {
+    beforeEach(function () {
+      this.Profiler.prototype.end = () => 7000 // take a long time
+      this.Profiler.prototype.log = sinon
+        .stub()
+        .returns({ end: this.Profiler.prototype.end })
+      this.lockValue = 'mock-lock-value'
+      this.LockManager.randomLock = sinon.stub().returns(this.lockValue)
+      this.LockManager.releaseLock = sinon.stub().callsArgWith(2, null)
+      return this.set.callsArgWith(5, null, 'OK')
+    })
 
-			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);
-			});
+    describe('in all cases', function () {
+      beforeEach(function () {
+        return this.LockManager.tryLock(this.doc_id, this.callback)
+      })
 
-			return it("should try to release the lock", function() {
-				return this.LockManager.releaseLock.calledWith(this.doc_id, this.lockValue).should.equal(true);
-			});
-		});
+      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)
+      })
 
-		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 try to release the lock', function () {
+        return this.LockManager.releaseLock
+          .calledWith(this.doc_id, this.lockValue)
+          .should.equal(true)
+      })
+    })
 
-			return it("should return the callback with false", function() {
-				return this.callback.calledWith(null, false).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 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 false', function () {
+        return this.callback.calledWith(null, false).should.equal(true)
+      })
+    })
 
-			return it("should return the callback with an error", function() {
-				const e = new Error("tried to release timed out lock");
-				return this.callback.calledWith(e).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 () {
+        const e = new Error('tried to release timed out lock')
+        return this.callback.calledWith(e).should.equal(true)
+      })
+    })
+  })
+})

+ 424 - 298
services/document-updater/test/unit/js/PersistenceManager/PersistenceManagerTests.js

@@ -10,301 +10,427 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/PersistenceManager.js";
-const SandboxedModule = require('sandboxed-module');
-const Errors = require("../../../../app/js/Errors");
-
-describe("PersistenceManager", function() {
-	beforeEach(function() {
-		let Timer;
-		this.request = sinon.stub();
-		this.request.defaults = () => this.request;
-		this.PersistenceManager = SandboxedModule.require(modulePath, { requires: {
-			"requestretry": this.request,
-			"settings-sharelatex": (this.Settings = {}),
-			"./Metrics": (this.Metrics = {
-				Timer: (Timer = (function() {
-					Timer = class Timer {
-						static initClass() {
-							this.prototype.done = sinon.stub();
-						}
-					};
-					Timer.initClass();
-					return Timer;
-				})()),
-				inc: sinon.stub()
-			}),
-			"logger-sharelatex": (this.logger = {log: sinon.stub(), err: sinon.stub()})
-		}
-	});
-		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';
-		return this.Settings.apis = {
-			web: {
-				url: (this.url = "www.example.com"),
-				user: (this.user = "sharelatex"),
-				pass: (this.pass = "password")
-			}
-		};
-	});
-
-	describe("getDoc", function() {
-		beforeEach(function() {
-			return this.webResponse = {
-				lines: this.lines,
-				version: this.version,
-				ranges: this.ranges,
-				pathname: this.pathname,
-				projectHistoryId: this.projectHistoryId
-			};});
-
-		describe("with a successful response from the web api", function() {
-			beforeEach(function() {
-				this.request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify(this.webResponse));
-				return this.PersistenceManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should call the web api", function() {
-				return 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() {
-				return this.callback
-					.calledWith(null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId)
-					.should.equal(true);
-			});
-
-			it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-
-			return it("should increment the metric", function() {
-				return this.Metrics.inc.calledWith("getDoc", 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);
-				return this.PersistenceManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should return the error", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-
-			it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-
-			return it("should increment the metric", function() {
-				return 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}, "");
-				return this.PersistenceManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should return a NotFoundError", function() {
-				return this.callback.calledWith(new Errors.NotFoundError("not found")).should.equal(true);
-			});
-
-			it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-
-			return it("should increment the metric", function() {
-				return this.Metrics.inc.calledWith("getDoc", 1, {status: 404}).should.equal(true);
-			});
-		});
-
-		describe("when the request returns an error status code", function() {
-			beforeEach(function() {
-				this.request.callsArgWith(1, null, {statusCode: 500}, "");
-				return this.PersistenceManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should return an error", function() {
-				return this.callback.calledWith(new Error("web api error")).should.equal(true);
-			});
-
-			it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-
-			return it("should increment the metric", function() {
-				return 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));
-				return this.PersistenceManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			return it("should return and error", function() {
-				return this.callback.calledWith(new Error("web API response had no doc lines")).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));
-				return this.PersistenceManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			return it("should return and error", function() {
-				return this.callback.calledWith(new Error("web API response had no valid doc version")).should.equal(true);
-			});
-		});
-
-		return 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));
-				return this.PersistenceManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			return it("should return and error", function() {
-				return this.callback.calledWith(new Error("web API response had no valid doc pathname")).should.equal(true);
-			});
-		});
-	});
-
-	return describe("setDoc", function() {
-		describe("with a successful response from the web api", function() {
-			beforeEach(function() {
-				this.request.callsArgWith(1, null, {statusCode: 200});
-				return 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() {
-				return 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() {
-				return this.callback.calledWith(null).should.equal(true);
-			});
-
-			it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-
-			return it("should increment the metric", function() {
-				return 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);
-				return this.PersistenceManager.setDoc(this.project_id, this.doc_id, this.lines, this.version, this.ranges, this.lastUpdatedAt, this.lastUpdatedBy, this.callback);
-			});
-
-			it("should return the error", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-
-			it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-
-			return it("should increment the metric", function() {
-				return 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}, "");
-				return 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() {
-				return this.callback.calledWith(new Errors.NotFoundError("not found")).should.equal(true);
-			});
-
-			it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-
-			return it("should increment the metric", function() {
-				return this.Metrics.inc.calledWith("setDoc", 1, {status: 404}).should.equal(true);
-			});
-		});
-
-		return describe("when the request returns an error status code", function() {
-			beforeEach(function() {
-				this.request.callsArgWith(1, null, {statusCode: 500}, "");
-				return 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() {
-				return this.callback.calledWith(new Error("web api error")).should.equal(true);
-			});
-
-			it("should time the execution", function() {
-				return this.Metrics.Timer.prototype.done.called.should.equal(true);
-			});
-
-			return it("should increment the metric", function() {
-				return this.Metrics.inc.calledWith("setDoc", 1, {status: 500}).should.equal(true);
-			});
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/PersistenceManager.js'
+const SandboxedModule = require('sandboxed-module')
+const Errors = require('../../../../app/js/Errors')
+
+describe('PersistenceManager', function () {
+  beforeEach(function () {
+    let Timer
+    this.request = sinon.stub()
+    this.request.defaults = () => this.request
+    this.PersistenceManager = SandboxedModule.require(modulePath, {
+      requires: {
+        requestretry: this.request,
+        'settings-sharelatex': (this.Settings = {}),
+        './Metrics': (this.Metrics = {
+          Timer: (Timer = (function () {
+            Timer = class Timer {
+              static initClass() {
+                this.prototype.done = sinon.stub()
+              }
+            }
+            Timer.initClass()
+            return Timer
+          })()),
+          inc: sinon.stub()
+        }),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          err: sinon.stub()
+        })
+      }
+    })
+    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'
+    return (this.Settings.apis = {
+      web: {
+        url: (this.url = 'www.example.com'),
+        user: (this.user = 'sharelatex'),
+        pass: (this.pass = 'password')
+      }
+    })
+  })
+
+  describe('getDoc', function () {
+    beforeEach(function () {
+      return (this.webResponse = {
+        lines: this.lines,
+        version: this.version,
+        ranges: this.ranges,
+        pathname: this.pathname,
+        projectHistoryId: this.projectHistoryId
+      })
+    })
+
+    describe('with a successful response from the web api', function () {
+      beforeEach(function () {
+        this.request.callsArgWith(
+          1,
+          null,
+          { statusCode: 200 },
+          JSON.stringify(this.webResponse)
+        )
+        return this.PersistenceManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should call the web api', function () {
+        return 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 () {
+        return this.callback
+          .calledWith(
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId
+          )
+          .should.equal(true)
+      })
+
+      it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+
+      return it('should increment the metric', function () {
+        return this.Metrics.inc
+          .calledWith('getDoc', 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)
+        return this.PersistenceManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should return the error', function () {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+
+      it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+
+      return it('should increment the metric', function () {
+        return 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 }, '')
+        return this.PersistenceManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should return a NotFoundError', function () {
+        return this.callback
+          .calledWith(new Errors.NotFoundError('not found'))
+          .should.equal(true)
+      })
+
+      it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+
+      return it('should increment the metric', function () {
+        return this.Metrics.inc
+          .calledWith('getDoc', 1, { status: 404 })
+          .should.equal(true)
+      })
+    })
+
+    describe('when the request returns an error status code', function () {
+      beforeEach(function () {
+        this.request.callsArgWith(1, null, { statusCode: 500 }, '')
+        return this.PersistenceManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should return an error', function () {
+        return this.callback
+          .calledWith(new Error('web api error'))
+          .should.equal(true)
+      })
+
+      it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+
+      return it('should increment the metric', function () {
+        return 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)
+        )
+        return this.PersistenceManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      return it('should return and error', function () {
+        return this.callback
+          .calledWith(new Error('web API response had no doc lines'))
+          .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)
+        )
+        return this.PersistenceManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      return it('should return and error', function () {
+        return this.callback
+          .calledWith(new Error('web API response had no valid doc version'))
+          .should.equal(true)
+      })
+    })
+
+    return 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)
+        )
+        return this.PersistenceManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      return it('should return and error', function () {
+        return this.callback
+          .calledWith(new Error('web API response had no valid doc pathname'))
+          .should.equal(true)
+      })
+    })
+  })
+
+  return describe('setDoc', function () {
+    describe('with a successful response from the web api', function () {
+      beforeEach(function () {
+        this.request.callsArgWith(1, null, { statusCode: 200 })
+        return 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 () {
+        return 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 () {
+        return this.callback.calledWith(null).should.equal(true)
+      })
+
+      it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+
+      return it('should increment the metric', function () {
+        return 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)
+        return this.PersistenceManager.setDoc(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          this.ranges,
+          this.lastUpdatedAt,
+          this.lastUpdatedBy,
+          this.callback
+        )
+      })
+
+      it('should return the error', function () {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+
+      it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+
+      return it('should increment the metric', function () {
+        return 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 }, '')
+        return 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 () {
+        return this.callback
+          .calledWith(new Errors.NotFoundError('not found'))
+          .should.equal(true)
+      })
+
+      it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+
+      return it('should increment the metric', function () {
+        return this.Metrics.inc
+          .calledWith('setDoc', 1, { status: 404 })
+          .should.equal(true)
+      })
+    })
+
+    return describe('when the request returns an error status code', function () {
+      beforeEach(function () {
+        this.request.callsArgWith(1, null, { statusCode: 500 }, '')
+        return 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 () {
+        return this.callback
+          .calledWith(new Error('web api error'))
+          .should.equal(true)
+      })
+
+      it('should time the execution', function () {
+        return this.Metrics.Timer.prototype.done.called.should.equal(true)
+      })
+
+      return it('should increment the metric', function () {
+        return this.Metrics.inc
+          .calledWith('setDoc', 1, { status: 500 })
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 188 - 146
services/document-updater/test/unit/js/ProjectHistoryRedisManager/ProjectHistoryRedisManagerTests.js

@@ -11,149 +11,191 @@
  * 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 chai = require('chai');
-const should = chai.should();
-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.callback = sinon.stub();
-		this.rclient = {};
-		tk.freeze(new Date());
-		return this.ProjectHistoryRedisManager = SandboxedModule.require(modulePath, {
-			requires: {
-				"settings-sharelatex": (this.settings = {
-					redis: {
-						project_history: {
-							key_schema: {
-								projectHistoryOps({project_id}) { return `ProjectHistory:Ops:${project_id}`; },
-								projectHistoryFirstOpTimestamp({project_id}) { return `ProjectHistory:FirstOpTimestamp:${project_id}`; }
-							}
-						}
-					}
-				}),
-				"redis-sharelatex": {
-					createClient: () => this.rclient
-				},
-				"logger-sharelatex": {
-					log() {}
-				},
-				"./Metrics": (this.metrics = { summary: sinon.stub()})
-			},
-			globals: {
-				JSON: (this.JSON = JSON)
-			}
-		}
-		);
-	});
-
-	afterEach(function() { return tk.reset(); });
-
-	describe("queueOps", function() {
-		beforeEach(function() {
-			this.ops = ["mock-op-1", "mock-op-2"];
-			this.multi = {exec: sinon.stub()};
-			this.multi.rpush = sinon.stub();
-			this.multi.setnx = sinon.stub();
-			this.rclient.multi = () => this.multi;
-			// @rclient = multi: () => @multi
-			return this.ProjectHistoryRedisManager.queueOps(this.project_id, ...Array.from(this.ops), this.callback);
-		});
-
-		it("should queue an update", function() {
-			return this.multi.rpush
-				.calledWithExactly(
-					`ProjectHistory:Ops:${this.project_id}`,
-					this.ops[0],
-					this.ops[1]
-				).should.equal(true);
-		});
-
-		return it("should set the queue timestamp if not present", function() {
-			return this.multi.setnx
-				.calledWithExactly(
-					`ProjectHistory:FirstOpTimestamp:${this.project_id}`,
-					Date.now()
-				).should.equal(true);
-		});
-	});
-
-	describe("queueRenameEntity", function() {
-		beforeEach(function() {
-			this.file_id = 1234;
-
-			this.rawUpdate = {
-				pathname: (this.pathname = '/old'),
-				newPathname: (this.newPathname = '/new'),
-				version: (this.version = 2)
-			};
-
-			this.ProjectHistoryRedisManager.queueOps = sinon.stub();
-			return this.ProjectHistoryRedisManager.queueRenameEntity(this.project_id, this.projectHistoryId, 'file', this.file_id, this.user_id, this.rawUpdate, this.callback);
-		});
-
-		return it("should queue an update", function() {
-			const update = {
-				pathname: this.pathname,
-				new_pathname: this.newPathname,
-				meta: {
-					user_id: this.user_id,
-					ts: new Date()
-				},
-				version: this.version,
-				projectHistoryId: this.projectHistoryId,
-				file: this.file_id
-			};
-
-			return this.ProjectHistoryRedisManager.queueOps
-				.calledWithExactly(this.project_id, this.JSON.stringify(update), this.callback)
-				.should.equal(true);
-		});
-	});
-
-	return describe("queueAddEntity", function() {
-		beforeEach(function() {
-			this.rclient.rpush = sinon.stub().yields();
-			this.doc_id = 1234;
-
-			this.rawUpdate = {
-				pathname: (this.pathname = '/old'),
-				docLines: (this.docLines = 'a\nb'),
-				version: (this.version = 2),
-				url: (this.url = 'filestore.example.com')
-			};
-
-			this.ProjectHistoryRedisManager.queueOps = sinon.stub();
-			return this.ProjectHistoryRedisManager.queueAddEntity(this.project_id, this.projectHistoryId, 'doc', this.doc_id, this.user_id, this.rawUpdate, this.callback);
-		});
-
-		it("should queue an update", function() {
-			const update = {
-				pathname: this.pathname,
-				docLines: this.docLines,
-				url: this.url,
-				meta: {
-					user_id: this.user_id,
-					ts: new Date()
-				},
-				version: this.version,
-				projectHistoryId: this.projectHistoryId,
-				doc: this.doc_id
-			};
-
-			return this.ProjectHistoryRedisManager.queueOps
-				.calledWithExactly(this.project_id, this.JSON.stringify(update), this.callback)
-				.should.equal(true);
-		});
-
-		describe("queueResyncProjectStructure", function() { return it("should queue an update", function() {}); });
-
-		return describe("queueResyncDocContent", function() { return it("should queue an update", function() {}); });
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+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.callback = sinon.stub()
+    this.rclient = {}
+    tk.freeze(new Date())
+    return (this.ProjectHistoryRedisManager = SandboxedModule.require(
+      modulePath,
+      {
+        requires: {
+          'settings-sharelatex': (this.settings = {
+            redis: {
+              project_history: {
+                key_schema: {
+                  projectHistoryOps({ project_id }) {
+                    return `ProjectHistory:Ops:${project_id}`
+                  },
+                  projectHistoryFirstOpTimestamp({ project_id }) {
+                    return `ProjectHistory:FirstOpTimestamp:${project_id}`
+                  }
+                }
+              }
+            }
+          }),
+          'redis-sharelatex': {
+            createClient: () => this.rclient
+          },
+          'logger-sharelatex': {
+            log() {}
+          },
+          './Metrics': (this.metrics = { summary: sinon.stub() })
+        },
+        globals: {
+          JSON: (this.JSON = JSON)
+        }
+      }
+    ))
+  })
+
+  afterEach(function () {
+    return tk.reset()
+  })
+
+  describe('queueOps', function () {
+    beforeEach(function () {
+      this.ops = ['mock-op-1', 'mock-op-2']
+      this.multi = { exec: sinon.stub() }
+      this.multi.rpush = sinon.stub()
+      this.multi.setnx = sinon.stub()
+      this.rclient.multi = () => this.multi
+      // @rclient = multi: () => @multi
+      return this.ProjectHistoryRedisManager.queueOps(
+        this.project_id,
+        ...Array.from(this.ops),
+        this.callback
+      )
+    })
+
+    it('should queue an update', function () {
+      return this.multi.rpush
+        .calledWithExactly(
+          `ProjectHistory:Ops:${this.project_id}`,
+          this.ops[0],
+          this.ops[1]
+        )
+        .should.equal(true)
+    })
+
+    return it('should set the queue timestamp if not present', function () {
+      return this.multi.setnx
+        .calledWithExactly(
+          `ProjectHistory:FirstOpTimestamp:${this.project_id}`,
+          Date.now()
+        )
+        .should.equal(true)
+    })
+  })
+
+  describe('queueRenameEntity', function () {
+    beforeEach(function () {
+      this.file_id = 1234
+
+      this.rawUpdate = {
+        pathname: (this.pathname = '/old'),
+        newPathname: (this.newPathname = '/new'),
+        version: (this.version = 2)
+      }
+
+      this.ProjectHistoryRedisManager.queueOps = sinon.stub()
+      return this.ProjectHistoryRedisManager.queueRenameEntity(
+        this.project_id,
+        this.projectHistoryId,
+        'file',
+        this.file_id,
+        this.user_id,
+        this.rawUpdate,
+        this.callback
+      )
+    })
+
+    return it('should queue an update', function () {
+      const update = {
+        pathname: this.pathname,
+        new_pathname: this.newPathname,
+        meta: {
+          user_id: this.user_id,
+          ts: new Date()
+        },
+        version: this.version,
+        projectHistoryId: this.projectHistoryId,
+        file: this.file_id
+      }
+
+      return this.ProjectHistoryRedisManager.queueOps
+        .calledWithExactly(
+          this.project_id,
+          this.JSON.stringify(update),
+          this.callback
+        )
+        .should.equal(true)
+    })
+  })
+
+  return describe('queueAddEntity', function () {
+    beforeEach(function () {
+      this.rclient.rpush = sinon.stub().yields()
+      this.doc_id = 1234
+
+      this.rawUpdate = {
+        pathname: (this.pathname = '/old'),
+        docLines: (this.docLines = 'a\nb'),
+        version: (this.version = 2),
+        url: (this.url = 'filestore.example.com')
+      }
+
+      this.ProjectHistoryRedisManager.queueOps = sinon.stub()
+      return this.ProjectHistoryRedisManager.queueAddEntity(
+        this.project_id,
+        this.projectHistoryId,
+        'doc',
+        this.doc_id,
+        this.user_id,
+        this.rawUpdate,
+        this.callback
+      )
+    })
+
+    it('should queue an update', function () {
+      const update = {
+        pathname: this.pathname,
+        docLines: this.docLines,
+        url: this.url,
+        meta: {
+          user_id: this.user_id,
+          ts: new Date()
+        },
+        version: this.version,
+        projectHistoryId: this.projectHistoryId,
+        doc: this.doc_id
+      }
+
+      return this.ProjectHistoryRedisManager.queueOps
+        .calledWithExactly(
+          this.project_id,
+          this.JSON.stringify(update),
+          this.callback
+        )
+        .should.equal(true)
+    })
+
+    describe('queueResyncProjectStructure', function () {
+      return it('should queue an update', function () {})
+    })
+
+    return describe('queueResyncDocContent', function () {
+      return it('should queue an update', function () {})
+    })
+  })
+})

+ 130 - 105
services/document-updater/test/unit/js/ProjectManager/flushAndDeleteProjectTests.js

@@ -12,121 +12,146 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/ProjectManager.js";
-const SandboxedModule = require('sandboxed-module');
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/ProjectManager.js'
+const SandboxedModule = require('sandboxed-module')
 
-describe("ProjectManager - flushAndDeleteProject", function() {
-	beforeEach(function() {
-		let Timer;
-		this.ProjectManager = SandboxedModule.require(modulePath, { requires: {
-			"./RedisManager": (this.RedisManager = {}),
-			"./ProjectHistoryRedisManager": (this.ProjectHistoryRedisManager = {}),
-			"./DocumentManager": (this.DocumentManager = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			"./HistoryManager": (this.HistoryManager =
-				{flushProjectChanges: sinon.stub().callsArg(2)}),
-			"./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('ProjectManager - flushAndDeleteProject', function () {
+  beforeEach(function () {
+    let Timer
+    this.ProjectManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './RedisManager': (this.RedisManager = {}),
+        './ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
+        './DocumentManager': (this.DocumentManager = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        './HistoryManager': (this.HistoryManager = {
+          flushProjectChanges: sinon.stub().callsArg(2)
+        }),
+        './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();
-			});
-		});
+  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 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((doc_id) =>
-				this.DocumentManager.flushAndDeleteDocWithLock
-					.calledWith(this.project_id, doc_id, {})
-					.should.equal(true));
-		});
+    it('should delete each doc in the project', function () {
+      return Array.from(this.doc_ids).map((doc_id) =>
+        this.DocumentManager.flushAndDeleteDocWithLock
+          .calledWith(this.project_id, doc_id, {})
+          .should.equal(true)
+      )
+    })
 
-		it("should flush project history", function() {
-			return this.HistoryManager.flushProjectChanges
-				.calledWith(this.project_id, {})
-				.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);
-		});
+    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 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((project_id, doc_id, options, callback) => {
-				if (doc_id === "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();
-			});
-		});
+  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(
+        (project_id, doc_id, options, callback) => {
+          if (doc_id === '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((doc_id) =>
-				this.DocumentManager.flushAndDeleteDocWithLock
-					.calledWith(this.project_id, doc_id, {})
-					.should.equal(true));
-		});
+    it('should still flush each doc in the project', function () {
+      return Array.from(this.doc_ids).map((doc_id) =>
+        this.DocumentManager.flushAndDeleteDocWithLock
+          .calledWith(this.project_id, doc_id, {})
+          .should.equal(true)
+      )
+    })
 
-		it("should still flush project history", function() {
-			return this.HistoryManager.flushProjectChanges
-				.calledWith(this.project_id, {})
-				.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, project_id: this.project_id, doc_id: "doc-id-1"}, "error deleting doc")
-				.should.equal(true);
-		});
+    it('should record the error', function () {
+      return this.logger.error
+        .calledWith(
+          { err: this.error, project_id: this.project_id, doc_id: 'doc-id-1' },
+          'error deleting doc'
+        )
+        .should.equal(true)
+    })
 
-		it("should call the callback with an error", function() {
-			return this.callback.calledWith(new Error()).should.equal(true);
-		});
+    it('should call the callback with an error', function () {
+      return this.callback.calledWith(new Error()).should.equal(true)
+    })
 
-		return it("should time the execution", function() {
-			return this.Metrics.Timer.prototype.done.called.should.equal(true);
-		});
-	});
-});
+    return it('should time the execution', function () {
+      return this.Metrics.Timer.prototype.done.called.should.equal(true)
+    })
+  })
+})

+ 121 - 97
services/document-updater/test/unit/js/ProjectManager/flushProjectTests.js

@@ -14,109 +14,133 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/ProjectManager.js";
-const SandboxedModule = require('sandboxed-module');
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/ProjectManager.js'
+const SandboxedModule = require('sandboxed-module')
 
-describe("ProjectManager - flushProject", function() {
-	beforeEach(function() {
-		let Timer;
-		this.ProjectManager = SandboxedModule.require(modulePath, { requires: {
-			"./RedisManager": (this.RedisManager = {}),
-			"./ProjectHistoryRedisManager": (this.ProjectHistoryRedisManager = {}),
-			"./DocumentManager": (this.DocumentManager = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			"./HistoryManager": (this.HistoryManager = {}),
-			"./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('ProjectManager - flushProject', function () {
+  beforeEach(function () {
+    let Timer
+    this.ProjectManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './RedisManager': (this.RedisManager = {}),
+        './ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
+        './DocumentManager': (this.DocumentManager = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        './HistoryManager': (this.HistoryManager = {}),
+        './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();
-			});
-		});
+  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((doc_id) =>
-				this.DocumentManager.flushDocIfLoadedWithLock
-					.calledWith(this.project_id, doc_id)
-					.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).should.equal(true);
-		});
+    it('should flush each doc in the project', function () {
+      return Array.from(this.doc_ids).map((doc_id) =>
+        this.DocumentManager.flushDocIfLoadedWithLock
+          .calledWith(this.project_id, doc_id)
+          .should.equal(true)
+      )
+    })
 
-		return it("should time the execution", function() {
-			return this.Metrics.Timer.prototype.done.called.should.equal(true);
-		});
-	});
+    it('should call the callback without error', function () {
+      return this.callback.calledWith(null).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((project_id, doc_id, callback) => {
-				if (callback == null) { callback = function(error) {}; }
-				if (doc_id === "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((doc_id) =>
-				this.DocumentManager.flushDocIfLoadedWithLock
-					.calledWith(this.project_id, doc_id)
-					.should.equal(true));
-		});
+    return it('should time the execution', function () {
+      return this.Metrics.Timer.prototype.done.called.should.equal(true)
+    })
+  })
 
-		it("should record the error", function() {
-			return this.logger.error
-				.calledWith({err: this.error, project_id: this.project_id, doc_id: "doc-id-1"}, "error flushing doc")
-				.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(
+        (project_id, doc_id, callback) => {
+          if (callback == null) {
+            callback = function (error) {}
+          }
+          if (doc_id === '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 call the callback with an error", function() {
-			return this.callback.calledWith(new Error()).should.equal(true);
-		});
+    it('should still flush each doc in the project', function () {
+      return Array.from(this.doc_ids).map((doc_id) =>
+        this.DocumentManager.flushDocIfLoadedWithLock
+          .calledWith(this.project_id, doc_id)
+          .should.equal(true)
+      )
+    })
 
-		return it("should time the execution", function() {
-			return this.Metrics.Timer.prototype.done.called.should.equal(true);
-		});
-	});
-});
+    it('should record the error', function () {
+      return this.logger.error
+        .calledWith(
+          { err: this.error, project_id: this.project_id, doc_id: 'doc-id-1' },
+          'error flushing doc'
+        )
+        .should.equal(true)
+    })
+
+    it('should call the callback with an error', function () {
+      return this.callback.calledWith(new Error()).should.equal(true)
+    })
+
+    return it('should time the execution', function () {
+      return this.Metrics.Timer.prototype.done.called.should.equal(true)
+    })
+  })
+})

+ 212 - 155
services/document-updater/test/unit/js/ProjectManager/getProjectDocsTests.js

@@ -10,158 +10,215 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-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.ProjectManager = SandboxedModule.require(modulePath, { requires: {
-			"./RedisManager": (this.RedisManager = {}),
-			"./ProjectHistoryRedisManager": (this.ProjectHistoryRedisManager = {}),
-			"./DocumentManager": (this.DocumentManager = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			"./HistoryManager": (this.HistoryManager = {}),
-			"./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";
-		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(new Errors.ProjectStateChangedError("project state changed")).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, project_id: this.project_id, doc_id: "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(new Error("oops")).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);
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+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.ProjectManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './RedisManager': (this.RedisManager = {}),
+        './ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
+        './DocumentManager': (this.DocumentManager = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        './HistoryManager': (this.HistoryManager = {}),
+        './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'
+    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(
+          new Errors.ProjectStateChangedError('project state changed')
+        )
+        .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, project_id: this.project_id, doc_id: '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(new Error('oops')).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)
+    })
+  })
+})

+ 390 - 236
services/document-updater/test/unit/js/ProjectManager/updateProjectTests.js

@@ -10,239 +10,393 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/ProjectManager.js";
-const SandboxedModule = require('sandboxed-module');
-const _ = require('lodash');
-
-describe("ProjectManager", function() {
-	beforeEach(function() {
-		let Timer;
-		this.ProjectManager = SandboxedModule.require(modulePath, { requires: {
-			"./RedisManager": (this.RedisManager = {}),
-			"./ProjectHistoryRedisManager": (this.ProjectHistoryRedisManager = {}),
-			"./DocumentManager": (this.DocumentManager = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			"./HistoryManager": (this.HistoryManager = {}),
-			"./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";
-		this.projectHistoryId = 'history-id-123';
-		this.user_id = "user-id-123";
-		this.version = 1234567;
-		this.HistoryManager.shouldFlushHistoryOps = sinon.stub().returns(false);
-		this.HistoryManager.flushProjectChangesAsync = sinon.stub();
-		return this.callback = sinon.stub();
-	});
-
-	return describe("updateProjectWithLocks", function() {
-		describe("rename operations", function() {
-			beforeEach(function() {
-				this.firstDocUpdate = {
-					id: 1,
-					pathname: 'foo',
-					newPathname: 'foo'
-				};
-				this.secondDocUpdate = {
-					id: 2,
-					pathname: 'bar',
-					newPathname: 'bar2'
-				};
-				this.docUpdates = [ this.firstDocUpdate, this.secondDocUpdate ];
-				this.firstFileUpdate = {
-					id: 2,
-					pathname: 'bar',
-					newPathname: 'bar2'
-				};
-				this.fileUpdates = [ this.firstFileUpdate ];
-				this.DocumentManager.renameDocWithLock = sinon.stub().yields();
-				return this.ProjectHistoryRedisManager.queueRenameEntity = sinon.stub().yields();
-			});
-
-			describe("successfully", function() {
-				beforeEach(function() {
-					return this.ProjectManager.updateProjectWithLocks(this.project_id, this.projectHistoryId, this.user_id, this.docUpdates, this.fileUpdates, this.version, 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);
-					return 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`});
-					return this.ProjectHistoryRedisManager.queueRenameEntity
-						.calledWith(this.project_id, this.projectHistoryId, 'file', this.firstFileUpdate.id, this.user_id, firstFileUpdateWithVersion)
-						.should.equal(true);
-				});
-
-				it("should not flush the history", function() {
-					return this.HistoryManager.flushProjectChangesAsync
-						.calledWith(this.project_id)
-						.should.equal(false);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-
-			describe("when renaming a doc fails", function() {
-				beforeEach(function() {
-					this.error = new Error('error');
-					this.DocumentManager.renameDocWithLock = sinon.stub().yields(this.error);
-					return this.ProjectManager.updateProjectWithLocks(this.project_id, this.projectHistoryId, this.user_id, this.docUpdates, this.fileUpdates, this.version, this.callback);
-				});
-
-				return it("should call the callback with the error", function() {
-					return 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 = sinon.stub().yields(this.error);
-					return this.ProjectManager.updateProjectWithLocks(this.project_id, this.projectHistoryId, this.user_id, this.docUpdates, this.fileUpdates, this.version, this.callback);
-				});
-
-				return it("should call the callback with the error", function() {
-					return this.callback.calledWith(this.error).should.equal(true);
-				});
-			});
-
-			return describe("with enough ops to flush", function() {
-				beforeEach(function() {
-					this.HistoryManager.shouldFlushHistoryOps = sinon.stub().returns(true);
-					return this.ProjectManager.updateProjectWithLocks(this.project_id, this.projectHistoryId, this.user_id, this.docUpdates, this.fileUpdates, this.version, this.callback);
-				});
-
-				return it("should flush the history", function() {
-					return this.HistoryManager.flushProjectChangesAsync
-						.calledWith(this.project_id)
-						.should.equal(true);
-				});
-			});
-		});
-
-		return describe("add operations", function() {
-			beforeEach(function() {
-				this.firstDocUpdate = {
-					id: 1,
-					docLines: "a\nb"
-				};
-				this.secondDocUpdate = {
-					id: 2,
-					docLines: "a\nb"
-				};
-				this.docUpdates = [ this.firstDocUpdate, this.secondDocUpdate ];
-				this.firstFileUpdate = {
-					id: 3,
-					url: 'filestore.example.com/2'
-				};
-				this.secondFileUpdate = {
-					id: 4,
-					url: 'filestore.example.com/3'
-				};
-				this.fileUpdates = [ this.firstFileUpdate, this.secondFileUpdate ];
-				return this.ProjectHistoryRedisManager.queueAddEntity = sinon.stub().yields();
-			});
-
-			describe("successfully", function() {
-				beforeEach(function() {
-					return this.ProjectManager.updateProjectWithLocks(this.project_id, this.projectHistoryId, this.user_id, this.docUpdates, this.fileUpdates, this.version, 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)
-						.should.equal(true);
-					return this.ProjectHistoryRedisManager.queueAddEntity.getCall(1)
-						.calledWith(this.project_id, this.projectHistoryId, 'doc', this.secondDocUpdate.id, this.user_id, secondDocUpdateWithVersion)
-						.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)
-						.should.equal(true);
-					return this.ProjectHistoryRedisManager.queueAddEntity.getCall(3)
-						.calledWith(this.project_id, this.projectHistoryId, 'file', this.secondFileUpdate.id, this.user_id, secondFileUpdateWithVersion)
-						.should.equal(true);
-				});
-
-				it("should not flush the history", function() {
-					return this.HistoryManager.flushProjectChangesAsync
-						.calledWith(this.project_id)
-						.should.equal(false);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-
-			describe("when adding a doc fails", function() {
-				beforeEach(function() {
-					this.error = new Error('error');
-					this.ProjectHistoryRedisManager.queueAddEntity = sinon.stub().yields(this.error);
-					return this.ProjectManager.updateProjectWithLocks(this.project_id, this.projectHistoryId, this.user_id, this.docUpdates, this.fileUpdates, this.version, this.callback);
-				});
-
-				return it("should call the callback with the error", function() {
-					return 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 = sinon.stub().yields(this.error);
-					return this.ProjectManager.updateProjectWithLocks(this.project_id, this.projectHistoryId, this.user_id, this.docUpdates, this.fileUpdates, this.version, this.callback);
-				});
-
-				return it("should call the callback with the error", function() {
-					return this.callback.calledWith(this.error).should.equal(true);
-				});
-			});
-
-			return describe("with enough ops to flush", function() {
-				beforeEach(function() {
-					this.HistoryManager.shouldFlushHistoryOps = sinon.stub().returns(true);
-					return this.ProjectManager.updateProjectWithLocks(this.project_id, this.projectHistoryId, this.user_id, this.docUpdates, this.fileUpdates, this.version, this.callback);
-				});
-
-				return it("should flush the history", function() {
-					return this.HistoryManager.flushProjectChangesAsync
-						.calledWith(this.project_id)
-						.should.equal(true);
-				});
-			});
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/ProjectManager.js'
+const SandboxedModule = require('sandboxed-module')
+const _ = require('lodash')
+
+describe('ProjectManager', function () {
+  beforeEach(function () {
+    let Timer
+    this.ProjectManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './RedisManager': (this.RedisManager = {}),
+        './ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
+        './DocumentManager': (this.DocumentManager = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        './HistoryManager': (this.HistoryManager = {}),
+        './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'
+    this.projectHistoryId = 'history-id-123'
+    this.user_id = 'user-id-123'
+    this.version = 1234567
+    this.HistoryManager.shouldFlushHistoryOps = sinon.stub().returns(false)
+    this.HistoryManager.flushProjectChangesAsync = sinon.stub()
+    return (this.callback = sinon.stub())
+  })
+
+  return describe('updateProjectWithLocks', function () {
+    describe('rename operations', function () {
+      beforeEach(function () {
+        this.firstDocUpdate = {
+          id: 1,
+          pathname: 'foo',
+          newPathname: 'foo'
+        }
+        this.secondDocUpdate = {
+          id: 2,
+          pathname: 'bar',
+          newPathname: 'bar2'
+        }
+        this.docUpdates = [this.firstDocUpdate, this.secondDocUpdate]
+        this.firstFileUpdate = {
+          id: 2,
+          pathname: 'bar',
+          newPathname: 'bar2'
+        }
+        this.fileUpdates = [this.firstFileUpdate]
+        this.DocumentManager.renameDocWithLock = sinon.stub().yields()
+        return (this.ProjectHistoryRedisManager.queueRenameEntity = sinon
+          .stub()
+          .yields())
+      })
+
+      describe('successfully', function () {
+        beforeEach(function () {
+          return this.ProjectManager.updateProjectWithLocks(
+            this.project_id,
+            this.projectHistoryId,
+            this.user_id,
+            this.docUpdates,
+            this.fileUpdates,
+            this.version,
+            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)
+          return 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` }
+          )
+          return this.ProjectHistoryRedisManager.queueRenameEntity
+            .calledWith(
+              this.project_id,
+              this.projectHistoryId,
+              'file',
+              this.firstFileUpdate.id,
+              this.user_id,
+              firstFileUpdateWithVersion
+            )
+            .should.equal(true)
+        })
+
+        it('should not flush the history', function () {
+          return this.HistoryManager.flushProjectChangesAsync
+            .calledWith(this.project_id)
+            .should.equal(false)
+        })
+
+        return it('should call the callback', function () {
+          return this.callback.called.should.equal(true)
+        })
+      })
+
+      describe('when renaming a doc fails', function () {
+        beforeEach(function () {
+          this.error = new Error('error')
+          this.DocumentManager.renameDocWithLock = sinon
+            .stub()
+            .yields(this.error)
+          return this.ProjectManager.updateProjectWithLocks(
+            this.project_id,
+            this.projectHistoryId,
+            this.user_id,
+            this.docUpdates,
+            this.fileUpdates,
+            this.version,
+            this.callback
+          )
+        })
+
+        return it('should call the callback with the error', function () {
+          return 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 = sinon
+            .stub()
+            .yields(this.error)
+          return this.ProjectManager.updateProjectWithLocks(
+            this.project_id,
+            this.projectHistoryId,
+            this.user_id,
+            this.docUpdates,
+            this.fileUpdates,
+            this.version,
+            this.callback
+          )
+        })
+
+        return it('should call the callback with the error', function () {
+          return this.callback.calledWith(this.error).should.equal(true)
+        })
+      })
+
+      return describe('with enough ops to flush', function () {
+        beforeEach(function () {
+          this.HistoryManager.shouldFlushHistoryOps = sinon.stub().returns(true)
+          return this.ProjectManager.updateProjectWithLocks(
+            this.project_id,
+            this.projectHistoryId,
+            this.user_id,
+            this.docUpdates,
+            this.fileUpdates,
+            this.version,
+            this.callback
+          )
+        })
+
+        return it('should flush the history', function () {
+          return this.HistoryManager.flushProjectChangesAsync
+            .calledWith(this.project_id)
+            .should.equal(true)
+        })
+      })
+    })
+
+    return describe('add operations', function () {
+      beforeEach(function () {
+        this.firstDocUpdate = {
+          id: 1,
+          docLines: 'a\nb'
+        }
+        this.secondDocUpdate = {
+          id: 2,
+          docLines: 'a\nb'
+        }
+        this.docUpdates = [this.firstDocUpdate, this.secondDocUpdate]
+        this.firstFileUpdate = {
+          id: 3,
+          url: 'filestore.example.com/2'
+        }
+        this.secondFileUpdate = {
+          id: 4,
+          url: 'filestore.example.com/3'
+        }
+        this.fileUpdates = [this.firstFileUpdate, this.secondFileUpdate]
+        return (this.ProjectHistoryRedisManager.queueAddEntity = sinon
+          .stub()
+          .yields())
+      })
+
+      describe('successfully', function () {
+        beforeEach(function () {
+          return this.ProjectManager.updateProjectWithLocks(
+            this.project_id,
+            this.projectHistoryId,
+            this.user_id,
+            this.docUpdates,
+            this.fileUpdates,
+            this.version,
+            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
+            )
+            .should.equal(true)
+          return this.ProjectHistoryRedisManager.queueAddEntity
+            .getCall(1)
+            .calledWith(
+              this.project_id,
+              this.projectHistoryId,
+              'doc',
+              this.secondDocUpdate.id,
+              this.user_id,
+              secondDocUpdateWithVersion
+            )
+            .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
+            )
+            .should.equal(true)
+          return this.ProjectHistoryRedisManager.queueAddEntity
+            .getCall(3)
+            .calledWith(
+              this.project_id,
+              this.projectHistoryId,
+              'file',
+              this.secondFileUpdate.id,
+              this.user_id,
+              secondFileUpdateWithVersion
+            )
+            .should.equal(true)
+        })
+
+        it('should not flush the history', function () {
+          return this.HistoryManager.flushProjectChangesAsync
+            .calledWith(this.project_id)
+            .should.equal(false)
+        })
+
+        return it('should call the callback', function () {
+          return this.callback.called.should.equal(true)
+        })
+      })
+
+      describe('when adding a doc fails', function () {
+        beforeEach(function () {
+          this.error = new Error('error')
+          this.ProjectHistoryRedisManager.queueAddEntity = sinon
+            .stub()
+            .yields(this.error)
+          return this.ProjectManager.updateProjectWithLocks(
+            this.project_id,
+            this.projectHistoryId,
+            this.user_id,
+            this.docUpdates,
+            this.fileUpdates,
+            this.version,
+            this.callback
+          )
+        })
+
+        return it('should call the callback with the error', function () {
+          return 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 = sinon
+            .stub()
+            .yields(this.error)
+          return this.ProjectManager.updateProjectWithLocks(
+            this.project_id,
+            this.projectHistoryId,
+            this.user_id,
+            this.docUpdates,
+            this.fileUpdates,
+            this.version,
+            this.callback
+          )
+        })
+
+        return it('should call the callback with the error', function () {
+          return this.callback.calledWith(this.error).should.equal(true)
+        })
+      })
+
+      return describe('with enough ops to flush', function () {
+        beforeEach(function () {
+          this.HistoryManager.shouldFlushHistoryOps = sinon.stub().returns(true)
+          return this.ProjectManager.updateProjectWithLocks(
+            this.project_id,
+            this.projectHistoryId,
+            this.user_id,
+            this.docUpdates,
+            this.fileUpdates,
+            this.version,
+            this.callback
+          )
+        })
+
+        return it('should flush the history', function () {
+          return this.HistoryManager.flushProjectChangesAsync
+            .calledWith(this.project_id)
+            .should.equal(true)
+        })
+      })
+    })
+  })
+})

+ 503 - 371
services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js

@@ -12,392 +12,524 @@
  * 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 chai = require('chai');
-const should = chai.should();
-const {
-    expect
-} = chai;
-const modulePath = "../../../../app/js/RangesManager.js";
-const SandboxedModule = require('sandboxed-module');
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/RangesManager.js'
+const SandboxedModule = require('sandboxed-module')
 
-describe("RangesManager", function() {
-	beforeEach(function() {
-		this.RangesManager = SandboxedModule.require(modulePath, {
-			requires: {
-				"logger-sharelatex": (this.logger = { error: sinon.stub(), log: sinon.stub(), warn: sinon.stub() })
-			}
-		});
+describe('RangesManager', function () {
+  beforeEach(function () {
+    this.RangesManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': (this.logger = {
+          error: sinon.stub(),
+          log: sinon.stub(),
+          warn: sinon.stub()
+        })
+      }
+    })
 
-		this.doc_id = "doc-id-123";
-		this.project_id = "project-id-123";
-		this.user_id = "user-id-123";
-		return this.callback = sinon.stub();
-	});
+    this.doc_id = 'doc-id-123'
+    this.project_id = 'project-id-123'
+    this.user_id = 'user-id-123'
+    return (this.callback = sinon.stub())
+  })
 
-	describe("applyUpdate", function() {
-		beforeEach(function() {
-			this.updates = [{
-				meta: {
-					user_id: this.user_id
-				},
-				op: [{
-					i: "two ",
-					p: 4
-				}]
-			}];
-			this.entries = {
-				comments: [{
-					op: {
-						c: "three ",
-						p: 4
-					},
-					metadata: {
-						user_id: this.user_id
-					}
-				}],
-				changes: [{
-					op: {
-						i: "five",
-						p: 15
-					},
-					metadata: {
-						user_id: this.user_id
-					}
-				}]
-			};
-			return this.newDocLines = ["one two three four five"];}); // old is "one three four five"
-		
-		describe("successfully", function() {
-			beforeEach(function() {
-				return this.RangesManager.applyUpdate(this.project_id, this.doc_id, this.entries, this.updates, this.newDocLines, this.callback);
-			});
-			
-			return it("should return the modified the comments and changes", function() {
-				this.callback.called.should.equal(true);
-				const [error, entries, ranges_were_collapsed] = Array.from(this.callback.args[0]);
-				expect(error).to.be.null;
-				expect(ranges_were_collapsed).to.equal(false);
-				entries.comments[0].op.should.deep.equal({
-					c: "three ",
-					p: 8
-				});
-				return entries.changes[0].op.should.deep.equal({
-					i: "five",
-					p: 19
-				});
-		});
-	});
-		
-		describe("with empty comments", function() {
-			beforeEach(function() {
-				this.entries.comments = [];
-				return this.RangesManager.applyUpdate(this.project_id, this.doc_id, this.entries, this.updates, this.newDocLines, this.callback);
-			});
-			
-			return it("should return an object with no comments", function() {
-				// Save space in redis and don't store just {}
-				this.callback.called.should.equal(true);
-				const [error, entries] = Array.from(this.callback.args[0]);
-				expect(error).to.be.null;
-				return expect(entries.comments).to.be.undefined;
-			});
-		});
-		
-		describe("with empty changes", function() {
-			beforeEach(function() {
-				this.entries.changes = [];
-				return this.RangesManager.applyUpdate(this.project_id, this.doc_id, this.entries, this.updates, this.newDocLines, this.callback);
-			});
-			
-			return it("should return an object with no changes", function() {
-				// Save space in redis and don't store just {}
-				this.callback.called.should.equal(true);
-				const [error, entries] = Array.from(this.callback.args[0]);
-				expect(error).to.be.null;
-				return expect(entries.changes).to.be.undefined;
-			});
-		});
-	
-		describe("with too many comments", function() {
-			beforeEach(function() {
-				this.RangesManager.MAX_COMMENTS = 2;
-				this.updates = [{
-					meta: {
-						user_id: this.user_id
-					},
-					op: [{
-						c: "one",
-						p: 0,
-						t: "thread-id-1"
-					}]
-				}];
-				this.entries = {
-					comments: [{
-						op: {
-							c: "three ",
-							p: 4,
-							t: "thread-id-2"
-						},
-						metadata: {
-							user_id: this.user_id
-						}
-					}, {
-						op: {
-							c: "four ",
-							p: 10,
-							t: "thread-id-3"
-						},
-						metadata: {
-							user_id: this.user_id
-						}
-					}],
-					changes: []
-				};
-				return this.RangesManager.applyUpdate(this.project_id, this.doc_id, this.entries, this.updates, this.newDocLines, this.callback);
-			});
-			
-			return it("should return an error", function() {
-				this.callback.called.should.equal(true);
-				const [error, entries] = Array.from(this.callback.args[0]);
-				expect(error).to.not.be.null;
-				return expect(error.message).to.equal("too many comments or tracked changes");
-			});
-		});
-		
-		describe("with too many changes", function() {
-			beforeEach(function() {
-				this.RangesManager.MAX_CHANGES = 2;
-				this.updates = [{
-					meta: {
-						user_id: this.user_id,
-						tc: "track-changes-id-yes"
-					},
-					op: [{
-						i: "one ",
-						p: 0
-					}]
-				}];
-				this.entries = {
-					changes: [{
-						op: {
-							i: "three",
-							p: 4
-						},
-						metadata: {
-							user_id: this.user_id
-						}
-					}, {
-						op: {
-							i: "four",
-							p: 10
-						},
-						metadata: {
-							user_id: this.user_id
-						}
-					}],
-					comments: []
-				};
-				this.newDocLines = ["one two three four"];
-				return this.RangesManager.applyUpdate(this.project_id, this.doc_id, this.entries, this.updates, this.newDocLines, this.callback);
-			});
-			
-			return it("should return an error", function() {
-				// Save space in redis and don't store just {}
-				this.callback.called.should.equal(true);
-				const [error, entries] = Array.from(this.callback.args[0]);
-				expect(error).to.not.be.null;
-				return expect(error.message).to.equal("too many comments or tracked changes");
-			});
-		});
-		
-		describe("inconsistent changes", function() {
-			beforeEach(function() {
-				this.updates = [{
-					meta: {
-						user_id: this.user_id
-					},
-					op: [{
-						c: "doesn't match",
-						p: 0
-					}]
-				}];
-				return this.RangesManager.applyUpdate(this.project_id, this.doc_id, this.entries, this.updates, this.newDocLines, this.callback);
-			});
-			
-			return it("should return an error", function() {
-				// Save space in redis and don't store just {}
-				this.callback.called.should.equal(true);
-				const [error, entries] = Array.from(this.callback.args[0]);
-				expect(error).to.not.be.null;
-				return expect(error.message).to.equal("Change ({\"op\":{\"i\":\"five\",\"p\":15},\"metadata\":{\"user_id\":\"user-id-123\"}}) doesn't match text (\"our \")");
-			});
-		});
+  describe('applyUpdate', function () {
+    beforeEach(function () {
+      this.updates = [
+        {
+          meta: {
+            user_id: this.user_id
+          },
+          op: [
+            {
+              i: 'two ',
+              p: 4
+            }
+          ]
+        }
+      ]
+      this.entries = {
+        comments: [
+          {
+            op: {
+              c: 'three ',
+              p: 4
+            },
+            metadata: {
+              user_id: this.user_id
+            }
+          }
+        ],
+        changes: [
+          {
+            op: {
+              i: 'five',
+              p: 15
+            },
+            metadata: {
+              user_id: this.user_id
+            }
+          }
+        ]
+      }
+      return (this.newDocLines = ['one two three four five'])
+    }) // old is "one three four five"
 
+    describe('successfully', function () {
+      beforeEach(function () {
+        return this.RangesManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.entries,
+          this.updates,
+          this.newDocLines,
+          this.callback
+        )
+      })
 
-		return describe("with an update that collapses a range", function() {
-			beforeEach(function() {
-				this.updates = [{
-					meta: {
-						user_id: this.user_id
-					},
-					op: [{
-						d: "one",
-						p: 0,
-						t: "thread-id-1"
-					}]
-				}];
-				this.entries = {
-					comments: [{
-						op: {
-							c: "n",
-							p: 1,
-							t: "thread-id-2"
-						},
-						metadata: {
-							user_id: this.user_id
-						}
-					}],
-					changes: []
-				};
-				return this.RangesManager.applyUpdate(this.project_id, this.doc_id, this.entries, this.updates, this.newDocLines, this.callback);
-			});
+      return it('should return the modified the comments and changes', function () {
+        this.callback.called.should.equal(true)
+        const [error, entries, ranges_were_collapsed] = Array.from(
+          this.callback.args[0]
+        )
+        expect(error).to.be.null
+        expect(ranges_were_collapsed).to.equal(false)
+        entries.comments[0].op.should.deep.equal({
+          c: 'three ',
+          p: 8
+        })
+        return entries.changes[0].op.should.deep.equal({
+          i: 'five',
+          p: 19
+        })
+      })
+    })
 
-			return it("should return ranges_were_collapsed == true", function() {
-				this.callback.called.should.equal(true);
-				const [error, entries, ranges_were_collapsed] = Array.from(this.callback.args[0]);
-				return expect(ranges_were_collapsed).to.equal(true);
-			});
-		});
-	});
+    describe('with empty comments', function () {
+      beforeEach(function () {
+        this.entries.comments = []
+        return this.RangesManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.entries,
+          this.updates,
+          this.newDocLines,
+          this.callback
+        )
+      })
 
-	return describe("acceptChanges", function() {
-		beforeEach(function() {
-			this.RangesManager = SandboxedModule.require(modulePath, {
-				requires: {
-					"logger-sharelatex": (this.logger = { error: sinon.stub(), log: sinon.stub(), warn: sinon.stub() }),
-					"./RangesTracker":(this.RangesTracker = SandboxedModule.require("../../../../app/js/RangesTracker.js"))
-				}
-			}
-			);
+      return it('should return an object with no comments', function () {
+        // Save space in redis and don't store just {}
+        this.callback.called.should.equal(true)
+        const [error, entries] = Array.from(this.callback.args[0])
+        expect(error).to.be.null
+        return expect(entries.comments).to.be.undefined
+      })
+    })
 
-			this.ranges = {
-				comments: [],
-				changes: [{
-					id: "a1",
-					op: {
-						i: "lorem",
-						p: 0
-					}
-				}, {
-					id: "a2",
-					op: {
-						i: "ipsum",
-						p: 10
-					}
-				}, {
-					id: "a3",
-					op: {
-						i: "dolor",
-						p: 20
-					}
-				}, {
-					id: "a4",
-					op: {
-						i: "sit",
-						p: 30
-					}
-				}, {
-					id: "a5",
-					op: {
-						i: "amet",
-						p: 40
-					}
-				}]
-			};
-			return this.removeChangeIdsSpy = sinon.spy(this.RangesTracker.prototype, "removeChangeIds");
-		});
+    describe('with empty changes', function () {
+      beforeEach(function () {
+        this.entries.changes = []
+        return this.RangesManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.entries,
+          this.updates,
+          this.newDocLines,
+          this.callback
+        )
+      })
 
-		describe("successfully with a single change", function() {
-			beforeEach(function(done) {
-				this.change_ids = [ this.ranges.changes[1].id ];
-				return this.RangesManager.acceptChanges(this.change_ids, this.ranges, (err, ranges) => { 
-					this.rangesResponse = ranges;
-					return done();
-				});
-			});
+      return it('should return an object with no changes', function () {
+        // Save space in redis and don't store just {}
+        this.callback.called.should.equal(true)
+        const [error, entries] = Array.from(this.callback.args[0])
+        expect(error).to.be.null
+        return expect(entries.changes).to.be.undefined
+      })
+    })
 
-			it("should log the call with the correct number of changes", function() {
-				return this.logger.log
-					.calledWith("accepting 1 changes in ranges")
-					.should.equal(true);
-			});
+    describe('with too many comments', function () {
+      beforeEach(function () {
+        this.RangesManager.MAX_COMMENTS = 2
+        this.updates = [
+          {
+            meta: {
+              user_id: this.user_id
+            },
+            op: [
+              {
+                c: 'one',
+                p: 0,
+                t: 'thread-id-1'
+              }
+            ]
+          }
+        ]
+        this.entries = {
+          comments: [
+            {
+              op: {
+                c: 'three ',
+                p: 4,
+                t: 'thread-id-2'
+              },
+              metadata: {
+                user_id: this.user_id
+              }
+            },
+            {
+              op: {
+                c: 'four ',
+                p: 10,
+                t: 'thread-id-3'
+              },
+              metadata: {
+                user_id: this.user_id
+              }
+            }
+          ],
+          changes: []
+        }
+        return this.RangesManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.entries,
+          this.updates,
+          this.newDocLines,
+          this.callback
+        )
+      })
 
-			it("should delegate the change removal to the ranges tracker", function() {
-				return this.removeChangeIdsSpy
-					.calledWith(this.change_ids)
-					.should.equal(true);
-			});
+      return it('should return an error', function () {
+        this.callback.called.should.equal(true)
+        const [error, entries] = Array.from(this.callback.args[0])
+        expect(error).to.not.be.null
+        return expect(error.message).to.equal(
+          'too many comments or tracked changes'
+        )
+      })
+    })
 
-			it("should remove the change", function() {
-				return expect(this.rangesResponse.changes
-					.find(change => change.id ===  this.ranges.changes[1].id))
-					.to.be.undefined;
-			});
+    describe('with too many changes', function () {
+      beforeEach(function () {
+        this.RangesManager.MAX_CHANGES = 2
+        this.updates = [
+          {
+            meta: {
+              user_id: this.user_id,
+              tc: 'track-changes-id-yes'
+            },
+            op: [
+              {
+                i: 'one ',
+                p: 0
+              }
+            ]
+          }
+        ]
+        this.entries = {
+          changes: [
+            {
+              op: {
+                i: 'three',
+                p: 4
+              },
+              metadata: {
+                user_id: this.user_id
+              }
+            },
+            {
+              op: {
+                i: 'four',
+                p: 10
+              },
+              metadata: {
+                user_id: this.user_id
+              }
+            }
+          ],
+          comments: []
+        }
+        this.newDocLines = ['one two three four']
+        return this.RangesManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.entries,
+          this.updates,
+          this.newDocLines,
+          this.callback
+        )
+      })
 
-			it("should return the original number of changes minus 1", function() {
-				return this.rangesResponse.changes.length
-					.should.equal(this.ranges.changes.length - 1);
-			});
-					
-			return it("should not touch other changes", function() {
-				return [ 0, 2, 3, 4].map((i) =>
-					expect(this.rangesResponse.changes
-						.find(change => change.id ===  this.ranges.changes[i].id))
-						.to.deep.equal(this.ranges.changes[i]));
-		});
-	});
+      return it('should return an error', function () {
+        // Save space in redis and don't store just {}
+        this.callback.called.should.equal(true)
+        const [error, entries] = Array.from(this.callback.args[0])
+        expect(error).to.not.be.null
+        return expect(error.message).to.equal(
+          'too many comments or tracked changes'
+        )
+      })
+    })
 
-		return describe("successfully with multiple changes", function() {
-			beforeEach(function(done) {
-				this.change_ids = [ this.ranges.changes[1].id, this.ranges.changes[3].id, this.ranges.changes[4].id ];
-				return this.RangesManager.acceptChanges(this.change_ids, this.ranges, (err, ranges) => { 
-					this.rangesResponse = ranges;
-					return done();
-				});
-			});
+    describe('inconsistent changes', function () {
+      beforeEach(function () {
+        this.updates = [
+          {
+            meta: {
+              user_id: this.user_id
+            },
+            op: [
+              {
+                c: "doesn't match",
+                p: 0
+              }
+            ]
+          }
+        ]
+        return this.RangesManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.entries,
+          this.updates,
+          this.newDocLines,
+          this.callback
+        )
+      })
 
-			it("should log the call with the correct number of changes", function() {
-				return this.logger.log
-					.calledWith(`accepting ${ this.change_ids.length } changes in ranges`)
-					.should.equal(true);
-			});
+      return it('should return an error', function () {
+        // Save space in redis and don't store just {}
+        this.callback.called.should.equal(true)
+        const [error, entries] = Array.from(this.callback.args[0])
+        expect(error).to.not.be.null
+        return expect(error.message).to.equal(
+          'Change ({"op":{"i":"five","p":15},"metadata":{"user_id":"user-id-123"}}) doesn\'t match text ("our ")'
+        )
+      })
+    })
 
-			it("should delegate the change removal to the ranges tracker", function() {
-				return this.removeChangeIdsSpy
-					.calledWith(this.change_ids)
-					.should.equal(true);
-			});
+    return describe('with an update that collapses a range', function () {
+      beforeEach(function () {
+        this.updates = [
+          {
+            meta: {
+              user_id: this.user_id
+            },
+            op: [
+              {
+                d: 'one',
+                p: 0,
+                t: 'thread-id-1'
+              }
+            ]
+          }
+        ]
+        this.entries = {
+          comments: [
+            {
+              op: {
+                c: 'n',
+                p: 1,
+                t: 'thread-id-2'
+              },
+              metadata: {
+                user_id: this.user_id
+              }
+            }
+          ],
+          changes: []
+        }
+        return this.RangesManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.entries,
+          this.updates,
+          this.newDocLines,
+          this.callback
+        )
+      })
 
-			it("should remove the changes", function() {
-				return [ 1, 3, 4].map((i) =>
-					expect(this.rangesResponse.changes
-						.find(change => change.id ===  this.ranges.changes[1].id))
-						.to.be.undefined);
-			});
-			
-			it("should return the original number of changes minus the number of accepted changes", function() {
-				return this.rangesResponse.changes.length
-					.should.equal(this.ranges.changes.length - 3);
-			});
+      return it('should return ranges_were_collapsed == true', function () {
+        this.callback.called.should.equal(true)
+        const [error, entries, ranges_were_collapsed] = Array.from(
+          this.callback.args[0]
+        )
+        return expect(ranges_were_collapsed).to.equal(true)
+      })
+    })
+  })
 
-			return it("should not touch other changes", function() {
-				return [ 0, 2 ].map((i) =>
-					expect(this.rangesResponse.changes
-						.find(change => change.id ===  this.ranges.changes[i].id))
-						.to.deep.equal(this.ranges.changes[i]));
-		});
-	});
-});
-});
-					
+  return describe('acceptChanges', function () {
+    beforeEach(function () {
+      this.RangesManager = SandboxedModule.require(modulePath, {
+        requires: {
+          'logger-sharelatex': (this.logger = {
+            error: sinon.stub(),
+            log: sinon.stub(),
+            warn: sinon.stub()
+          }),
+          './RangesTracker': (this.RangesTracker = SandboxedModule.require(
+            '../../../../app/js/RangesTracker.js'
+          ))
+        }
+      })
+
+      this.ranges = {
+        comments: [],
+        changes: [
+          {
+            id: 'a1',
+            op: {
+              i: 'lorem',
+              p: 0
+            }
+          },
+          {
+            id: 'a2',
+            op: {
+              i: 'ipsum',
+              p: 10
+            }
+          },
+          {
+            id: 'a3',
+            op: {
+              i: 'dolor',
+              p: 20
+            }
+          },
+          {
+            id: 'a4',
+            op: {
+              i: 'sit',
+              p: 30
+            }
+          },
+          {
+            id: 'a5',
+            op: {
+              i: 'amet',
+              p: 40
+            }
+          }
+        ]
+      }
+      return (this.removeChangeIdsSpy = sinon.spy(
+        this.RangesTracker.prototype,
+        'removeChangeIds'
+      ))
+    })
+
+    describe('successfully with a single change', function () {
+      beforeEach(function (done) {
+        this.change_ids = [this.ranges.changes[1].id]
+        return this.RangesManager.acceptChanges(
+          this.change_ids,
+          this.ranges,
+          (err, ranges) => {
+            this.rangesResponse = ranges
+            return done()
+          }
+        )
+      })
+
+      it('should log the call with the correct number of changes', function () {
+        return this.logger.log
+          .calledWith('accepting 1 changes in ranges')
+          .should.equal(true)
+      })
+
+      it('should delegate the change removal to the ranges tracker', function () {
+        return this.removeChangeIdsSpy
+          .calledWith(this.change_ids)
+          .should.equal(true)
+      })
+
+      it('should remove the change', function () {
+        return expect(
+          this.rangesResponse.changes.find(
+            (change) => change.id === this.ranges.changes[1].id
+          )
+        ).to.be.undefined
+      })
+
+      it('should return the original number of changes minus 1', function () {
+        return this.rangesResponse.changes.length.should.equal(
+          this.ranges.changes.length - 1
+        )
+      })
+
+      return it('should not touch other changes', function () {
+        return [0, 2, 3, 4].map((i) =>
+          expect(
+            this.rangesResponse.changes.find(
+              (change) => change.id === this.ranges.changes[i].id
+            )
+          ).to.deep.equal(this.ranges.changes[i])
+        )
+      })
+    })
+
+    return describe('successfully with multiple changes', function () {
+      beforeEach(function (done) {
+        this.change_ids = [
+          this.ranges.changes[1].id,
+          this.ranges.changes[3].id,
+          this.ranges.changes[4].id
+        ]
+        return this.RangesManager.acceptChanges(
+          this.change_ids,
+          this.ranges,
+          (err, ranges) => {
+            this.rangesResponse = ranges
+            return done()
+          }
+        )
+      })
+
+      it('should log the call with the correct number of changes', function () {
+        return this.logger.log
+          .calledWith(`accepting ${this.change_ids.length} changes in ranges`)
+          .should.equal(true)
+      })
+
+      it('should delegate the change removal to the ranges tracker', function () {
+        return this.removeChangeIdsSpy
+          .calledWith(this.change_ids)
+          .should.equal(true)
+      })
+
+      it('should remove the changes', function () {
+        return [1, 3, 4].map(
+          (i) =>
+            expect(
+              this.rangesResponse.changes.find(
+                (change) => change.id === this.ranges.changes[1].id
+              )
+            ).to.be.undefined
+        )
+      })
+
+      it('should return the original number of changes minus the number of accepted changes', function () {
+        return this.rangesResponse.changes.length.should.equal(
+          this.ranges.changes.length - 3
+        )
+      })
+
+      return it('should not touch other changes', function () {
+        return [0, 2].map((i) =>
+          expect(
+            this.rangesResponse.changes.find(
+              (change) => change.id === this.ranges.changes[i].id
+            )
+          ).to.deep.equal(this.ranges.changes[i])
+        )
+      })
+    })
+  })
+})

+ 109 - 111
services/document-updater/test/unit/js/RateLimitManager/RateLimitManager.js

@@ -10,129 +10,127 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const {
-    expect
-} = chai;
-const modulePath = "../../../../app/js/RateLimitManager.js";
-const SandboxedModule = require('sandboxed-module');
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = 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: {
-			"logger-sharelatex": (this.logger = { log: sinon.stub() }),
-			"settings-sharelatex": (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('RateLimitManager', function () {
+  beforeEach(function () {
+    let Timer
+    this.RateLimitManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': (this.logger = { log: sinon.stub() }),
+        'settings-sharelatex': (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);
-		});
+  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 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);
-		});
+    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);
-		});
-	});
+    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();
-			});
-		});
+  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 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 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);
-		});
+    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 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();
-			});
-		});
+  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 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 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);
-		});
+    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);
-		});
-	});
-});
+    return it('should finish with worker count of three', function () {
+      return expect(this.RateLimiter.ActiveWorkerCount).to.equal(3)
+    })
+  })
+})

+ 159 - 124
services/document-updater/test/unit/js/RealTimeRedisManager/RealTimeRedisManagerTests.js

@@ -10,127 +10,162 @@
  * 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 chai = require('chai');
-const should = chai.should();
-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: {
-			"redis-sharelatex": { createClient: config =>  (config.name === 'pubsub') ? this.pubsubClient : this.rclient
-		},
-			"settings-sharelatex": {
-				redis: {
-					documentupdater: (this.settings = {
-						key_schema: {
-							pendingUpdates({doc_id}) { return `PendingUpdates:${doc_id}`; }
-						}
-					}),
-					pubsub: {
-						name: "pubsub"
-					}
-				}
-			},
-			"logger-sharelatex": { log() {} },
-			"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()})
-		}
-	});
-
-		this.doc_id = "doc-id-123";
-		this.project_id = "project-id-123";
-		return this.callback = sinon.stub();
-	});
-
-	describe("getPendingUpdatesForDoc", function() {
-		beforeEach(function() {
-			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().callsArgWith(0, null, [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().callsArgWith(0, null, [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(new Error("JSON parse error")).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);
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+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: {
+        'redis-sharelatex': {
+          createClient: (config) =>
+            config.name === 'pubsub' ? this.pubsubClient : this.rclient
+        },
+        'settings-sharelatex': {
+          redis: {
+            documentupdater: (this.settings = {
+              key_schema: {
+                pendingUpdates({ doc_id }) {
+                  return `PendingUpdates:${doc_id}`
+                }
+              }
+            }),
+            pubsub: {
+              name: 'pubsub'
+            }
+          }
+        },
+        'logger-sharelatex': { log() {} },
+        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() })
+      }
+    })
+
+    this.doc_id = 'doc-id-123'
+    this.project_id = 'project-id-123'
+    return (this.callback = sinon.stub())
+  })
+
+  describe('getPendingUpdatesForDoc', function () {
+    beforeEach(function () {
+      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()
+          .callsArgWith(0, null, [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()
+          .callsArgWith(0, null, [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(new Error('JSON parse error'))
+          .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)
+    })
+  })
+})

+ 1405 - 994
services/document-updater/test/unit/js/RedisManager/RedisManagerTests.js

@@ -11,997 +11,1408 @@
  * 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 chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/RedisManager.js";
-const SandboxedModule = require('sandboxed-module');
-const Errors = require("../../../../app/js/Errors");
-const crypto = require("crypto");
-const tk = require("timekeeper");
-
-describe("RedisManager", function() {
-	beforeEach(function() {
-		let Timer;
-		this.multi = {exec: sinon.stub()};
-		this.rclient = {multi: () => this.multi};
-		tk.freeze(new Date());
-		this.RedisManager = SandboxedModule.require(modulePath, {
-			requires: {
-				"logger-sharelatex": (this.logger = { error: sinon.stub(), log: sinon.stub(), warn: sinon.stub() }),
-				"./ProjectHistoryRedisManager": (this.ProjectHistoryRedisManager = {}),
-				"settings-sharelatex": (this.settings = {
-					documentupdater: {logHashErrors: {write:true, read:true}},
-					apis: {
-						project_history: {enabled: true}
-					},
-					redis: {
-						documentupdater: {
-							key_schema: {
-								blockingKey({doc_id}) { return `Blocking:${doc_id}`; },
-								docLines({doc_id}) { return `doclines:${doc_id}`; },
-								docOps({doc_id}) { return `DocOps:${doc_id}`; },
-								docVersion({doc_id}) { return `DocVersion:${doc_id}`; },
-								docHash({doc_id}) { return `DocHash:${doc_id}`; },
-								projectKey({doc_id}) { return `ProjectId:${doc_id}`; },
-								pendingUpdates({doc_id}) { return `PendingUpdates:${doc_id}`; },
-								docsInProject({project_id}) { return `DocsIn:${project_id}`; },
-								ranges({doc_id}) { return `Ranges:${doc_id}`; },
-								pathname({doc_id}) { return `Pathname:${doc_id}`; },
-								projectHistoryId({doc_id}) { return `ProjectHistoryId:${doc_id}`; },
-								projectHistoryType({doc_id}) { return `ProjectHistoryType:${doc_id}`; },
-								projectState({project_id}) { return `ProjectState:${project_id}`; },
-								unflushedTime({doc_id}) { return `UnflushedTime:${doc_id}`; },
-								lastUpdatedBy({doc_id}) { return `lastUpdatedBy:${doc_id}`; },
-								lastUpdatedAt({doc_id}) { return `lastUpdatedAt:${doc_id}`; }
-							}
-						},
-						history: {
-							key_schema: {
-								uncompressedHistoryOps({doc_id}) { return `UncompressedHistoryOps:${doc_id}`; },
-								docsWithHistoryOps({project_id}) { return `DocsWithHistoryOps:${project_id}`; }
-							}
-						}
-					}
-				}),
-				"redis-sharelatex": {
-					createClient: () => this.rclient
-				},
-				"./Metrics": (this.metrics = {
-					inc: sinon.stub(),
-					summary: sinon.stub(),
-					Timer: (Timer = class Timer {
-						constructor() {
-							this.start = new Date();
-						}
-
-						done() {
-							const timeSpan = new Date - this.start;
-							return timeSpan;
-						}
-					})
-				}),
-				"./Errors": Errors
-			},
-			globals: {
-				JSON: (this.JSON = JSON)
-			}
-		}
-		);
-
-		this.doc_id = "doc-id-123";
-		this.project_id = "project-id-123";
-		this.projectHistoryId = 123;
-		return this.callback = sinon.stub();
-	});
-
-	afterEach(function() { return tk.reset(); });
-
-	describe("getDoc", function() {
-		beforeEach(function() {
-			this.lines = ["one", "two", "three", "これは"]; // include some utf8
-			this.jsonlines = JSON.stringify(this.lines);
-			this.version = 42;
-			this.hash = crypto.createHash('sha1').update(this.jsonlines,'utf8').digest('hex');
-			this.ranges = { comments: "mock", entries: "mock" };
-			this.json_ranges = JSON.stringify(this.ranges);
-			this.unflushed_time = 12345;
-			this.pathname = '/a/b/c.tex';
-			this.multi.get = sinon.stub();
-			this.multi.exec = sinon.stub().callsArgWith(0, null, [this.jsonlines, this.version, this.hash, this.project_id, this.json_ranges, this.pathname, this.projectHistoryId.toString(), this.unflushed_time]);
-			return this.rclient.sadd = sinon.stub().yields(null, 0);
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				return this.RedisManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should get the lines from redis", function() {
-				return this.multi.get
-					.calledWith(`doclines:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should get the version from", function() {
-				return this.multi.get
-					.calledWith(`DocVersion:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it('should get the hash', function() {
-				return this.multi.get
-					.calledWith(`DocHash:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should get the ranges", function() {
-				return this.multi.get
-					.calledWith(`Ranges:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should get the unflushed time", function() {
-				return this.multi.get
-					.calledWith(`UnflushedTime:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should get the pathname", function() {
-				return this.multi.get
-					.calledWith(`Pathname:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should get the projectHistoryId as an integer", function() {
-				return this.multi.get
-					.calledWith(`ProjectHistoryId:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should get lastUpdatedAt", function() {
-				return this.multi.get
-					.calledWith(`lastUpdatedAt:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should get lastUpdatedBy", function() {
-				return this.multi.get
-					.calledWith(`lastUpdatedBy:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should check if the document is in the DocsIn set", function() {
-				return this.rclient.sadd
-					.calledWith(`DocsIn:${this.project_id}`)
-					.should.equal(true);
-			});
-
-			it('should return the document', function() {
-				return this.callback
-					.calledWithExactly(null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, this.unflushed_time, this.lastUpdatedAt, this.lastUpdatedBy)
-					.should.equal(true);
-			});
-
-			return it('should not log any errors', function() {
-				return this.logger.error.calledWith()
-					.should.equal(false);
-			});
-		});
-
-		describe("when the document is not present", function() {
-			beforeEach(function() {
-				this.multi.exec = sinon.stub().callsArgWith(0, null, [null, null, null, null, null, null, null, null, null, null]);
-				this.rclient.sadd = sinon.stub().yields();
-				return this.RedisManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should not check if the document is in the DocsIn set", function() {
-				return this.rclient.sadd
-					.calledWith(`DocsIn:${this.project_id}`)
-					.should.equal(false);
-			});
-
-			it('should return an empty result', function() {
-				return this.callback
-					.calledWithExactly(null, null, 0, {}, null, null, null, null, null)
-					.should.equal(true);
-			});
-
-			return it('should not log any errors', function() {
-				return this.logger.error.calledWith()
-					.should.equal(false);
-			});
-		});
-
-		describe("when the document is missing from the DocsIn set", function() {
-			beforeEach(function() {
-				this.rclient.sadd = sinon.stub().yields(null, 1);
-				return this.RedisManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it('should log an error', function() {
-				return this.logger.error.calledWith()
-					.should.equal(true);
-			});
-
-			return it('should return the document', function() {
-				return this.callback
-					.calledWithExactly(null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, this.unflushed_time, this.lastUpdatedAt, this.lastUpdatedBy)
-					.should.equal(true);
-			});
-		});
-
-		describe("with a corrupted document", function() {
-			beforeEach(function() {
-				this.badHash = "INVALID-HASH-VALUE";
-				this.multi.exec = sinon.stub().callsArgWith(0, null, [this.jsonlines, this.version, this.badHash, this.project_id, this.json_ranges]);
-				return this.RedisManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			it('should log a hash error', function() {
-				return this.logger.error.calledWith()
-					.should.equal(true);
-			});
-
-			return it('should return the document', function() {
-				return this.callback
-					.calledWith(null, this.lines, this.version, this.ranges)
-					.should.equal(true);
-			});
-		});
-
-
-		describe("with a slow request to redis", function() {
-			beforeEach(function() {
-				this.multi.exec = sinon.stub().callsArgWith(0, null, [this.jsonlines, this.version, this.badHash, this.project_id, this.json_ranges, this.pathname, this.unflushed_time]);
-				this.clock = sinon.useFakeTimers();
-				this.multi.exec = cb => {
-					this.clock.tick(6000);
-					return cb(null, [this.jsonlines, this.version, this.another_project_id, this.json_ranges, this.pathname, this.unflushed_time]);
-				};
-
-				return this.RedisManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			afterEach(function() {
-				return this.clock.restore();
-			});
-
-			return it('should return an error', function() {
-				return this.callback
-					.calledWith(new Error("redis getDoc exceeded timeout"))
-					.should.equal(true);
-			});
-		});
-
-		return describe("getDoc with an invalid project id", function() {
-			beforeEach(function() {
-				this.another_project_id = "project-id-456";
-				this.multi.exec = sinon.stub().callsArgWith(0, null, [this.jsonlines, this.version, this.hash, this.another_project_id, this.json_ranges, this.pathname, this.unflushed_time]);
-				return this.RedisManager.getDoc(this.project_id, this.doc_id, this.callback);
-			});
-
-			return it('should return an error', function() {
-				return this.callback
-					.calledWith(new Errors.NotFoundError("not found"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("getPreviousDocOpsTests", function() {
-		describe("with a start and an end value", function() {
-			beforeEach(function() {
-				this.first_version_in_redis = 30;
-				this.version = 70;
-				this.length = this.version - this.first_version_in_redis;
-				this.start  = 50;
-				this.end    = 60;
-				this.ops = [
-					{ "mock": "op-1" },
-					{ "mock": "op-2" }
-				];
-				this.jsonOps = this.ops.map(op => JSON.stringify(op));
-				this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length);
-				this.rclient.get = sinon.stub().callsArgWith(1, null, this.version.toString());
-				this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps);
-				return this.RedisManager.getPreviousDocOps(this.doc_id, this.start, this.end, this.callback);
-			});
-
-			it("should get the length of the existing doc ops", function() {
-				return this.rclient.llen
-					.calledWith(`DocOps:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should get the current version of the doc", function() {
-				return this.rclient.get
-					.calledWith(`DocVersion:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			it("should get the appropriate docs ops", function() {
-				return this.rclient.lrange
-					.calledWith(`DocOps:${this.doc_id}`, this.start - this.first_version_in_redis, this.end - this.first_version_in_redis)
-					.should.equal(true);
-			});
-
-			return it("should return the docs with the doc ops deserialized", function() {
-				return this.callback.calledWith(null, this.ops).should.equal(true);
-			});
-		});
-
-		describe("with an end value of -1", function() {
-			beforeEach(function() {
-				this.first_version_in_redis = 30;
-				this.version = 70;
-				this.length = this.version - this.first_version_in_redis;
-				this.start  = 50;
-				this.end    = -1;
-				this.ops = [
-					{ "mock": "op-1" },
-					{ "mock": "op-2" }
-				];
-				this.jsonOps = this.ops.map(op => JSON.stringify(op));
-				this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length);
-				this.rclient.get = sinon.stub().callsArgWith(1, null, this.version.toString());
-				this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps);
-				return this.RedisManager.getPreviousDocOps(this.doc_id, this.start, this.end, this.callback);
-			});
-
-			it("should get the appropriate docs ops to the end of list", function() {
-				return this.rclient.lrange
-					.calledWith(`DocOps:${this.doc_id}`, this.start - this.first_version_in_redis, -1)
-					.should.equal(true);
-			});
-
-			return it("should return the docs with the doc ops deserialized", function() {
-				return this.callback.calledWith(null, this.ops).should.equal(true);
-			});
-		});
-
-		describe("when the requested range is not in Redis", function() {
-			beforeEach(function() {
-				this.first_version_in_redis = 30;
-				this.version = 70;
-				this.length = this.version - this.first_version_in_redis;
-				this.start  = 20;
-				this.end    = -1;
-				this.ops = [
-					{ "mock": "op-1" },
-					{ "mock": "op-2" }
-				];
-				this.jsonOps = this.ops.map(op => JSON.stringify(op));
-				this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length);
-				this.rclient.get = sinon.stub().callsArgWith(1, null, this.version.toString());
-				this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps);
-				return this.RedisManager.getPreviousDocOps(this.doc_id, this.start, this.end, this.callback);
-			});
-
-			it("should return an error", function() {
-				return this.callback.calledWith(new Errors.OpRangeNotAvailableError("doc ops range is not loaded in redis")).should.equal(true);
-			});
-
-			return it("should log out the problem", function() {
-				return this.logger.warn.called.should.equal(true);
-			});
-		});
-
-		return describe("with a slow request to redis", function() {
-			beforeEach(function() {
-				this.first_version_in_redis = 30;
-				this.version = 70;
-				this.length = this.version - this.first_version_in_redis;
-				this.start  = 50;
-				this.end    = 60;
-				this.ops = [
-					{ "mock": "op-1" },
-					{ "mock": "op-2" }
-				];
-				this.jsonOps = this.ops.map(op => JSON.stringify(op));
-				this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length);
-				this.rclient.get = sinon.stub().callsArgWith(1, null, this.version.toString());
-				this.clock = sinon.useFakeTimers();
-				this.rclient.lrange = (key, start, end, cb) => {
-					this.clock.tick(6000);
-					return cb(null, this.jsonOps);
-				};
-				return this.RedisManager.getPreviousDocOps(this.doc_id, this.start, this.end, this.callback);
-			});
-
-			afterEach(function() {
-				return this.clock.restore();
-			});
-
-			return it('should return an error', function() {
-				return this.callback
-					.calledWith(new Error("redis getPreviousDocOps exceeded timeout"))
-					.should.equal(true);
-			});
-		});
-	});
-
-
-	describe("updateDocument", function() {
-		beforeEach(function() {
-			this.lines = ["one", "two", "three", "これは"];
-			this.ops = [{ op: [{ i: "foo", p: 4 }] },{ op: [{ i: "bar", p: 8 }] }];
-			this.version = 42;
-			this.hash = crypto.createHash('sha1').update(JSON.stringify(this.lines),'utf8').digest('hex');
-			this.ranges = { comments: "mock", entries: "mock" };
-			this.updateMeta = { user_id: 'last-author-fake-id' };
-			this.doc_update_list_length = sinon.stub();
-			this.project_update_list_length = sinon.stub();
-
-			this.RedisManager.getDocVersion = sinon.stub();
-			this.multi.set = sinon.stub();
-			this.multi.rpush = sinon.stub();
-			this.multi.expire = sinon.stub();
-			this.multi.ltrim = sinon.stub();
-			this.multi.del = sinon.stub();
-			this.multi.exec = sinon.stub().callsArgWith(0, null,
-				[this.hash, null, null, null, null, null, null, this.doc_update_list_length, null, null]
-			);
-			return this.ProjectHistoryRedisManager.queueOps = sinon.stub().callsArgWith(
-				this.ops.length + 1, null, this.project_update_list_length
-			);
-		});
-
-		describe("with a consistent version", function() {
-			beforeEach(function() {});
-
-
-			describe("with project history enabled", function() {
-				beforeEach(function() {
-					this.settings.apis.project_history.enabled = true;
-					this.RedisManager.getDocVersion.withArgs(this.doc_id).yields(null, this.version - this.ops.length);
-					return this.RedisManager.updateDocument(this.project_id, this.doc_id, this.lines, this.version, this.ops, this.ranges, this.updateMeta, this.callback);
-				});
-
-				it("should get the current doc version to check for consistency", function() {
-					return this.RedisManager.getDocVersion
-						.calledWith(this.doc_id)
-						.should.equal(true);
-				});
-
-				it("should set the doclines", function() {
-					return this.multi.set
-						.calledWith(`doclines:${this.doc_id}`, JSON.stringify(this.lines))
-						.should.equal(true);
-				});
-
-				it("should set the version", function() {
-					return this.multi.set
-						.calledWith(`DocVersion:${this.doc_id}`, this.version)
-						.should.equal(true);
-				});
-
-				it("should set the hash", function() {
-					return this.multi.set
-						.calledWith(`DocHash:${this.doc_id}`, this.hash)
-						.should.equal(true);
-				});
-
-				it("should set the ranges", function() {
-					return this.multi.set
-						.calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
-						.should.equal(true);
-				});
-
-				it("should set the unflushed time", function() {
-					return this.multi.set
-						.calledWith(`UnflushedTime:${this.doc_id}`, Date.now(), "NX")
-						.should.equal(true);
-				});
-
-				it("should set the last updated time", function() {
-					return this.multi.set
-						.calledWith(`lastUpdatedAt:${this.doc_id}`, Date.now())
-						.should.equal(true);
-				});
-
-				it("should set the last updater", function() {
-					return this.multi.set
-						.calledWith(`lastUpdatedBy:${this.doc_id}`, 'last-author-fake-id')
-						.should.equal(true);
-				});
-
-				it("should push the doc op into the doc ops list", function() {
-					return this.multi.rpush
-						.calledWith(`DocOps:${this.doc_id}`, JSON.stringify(this.ops[0]), JSON.stringify(this.ops[1]))
-						.should.equal(true);
-				});
-
-				it("should renew the expiry ttl on the doc ops array", function() {
-					return this.multi.expire
-						.calledWith(`DocOps:${this.doc_id}`, this.RedisManager.DOC_OPS_TTL)
-						.should.equal(true);
-				});
-
-				it("should truncate the list to 100 members", function() {
-					return this.multi.ltrim
-						.calledWith(`DocOps:${this.doc_id}`, -this.RedisManager.DOC_OPS_MAX_LENGTH, -1)
-						.should.equal(true);
-				});
-
-				it("should push the updates into the history ops list", function() {
-					return this.multi.rpush
-						.calledWith(`UncompressedHistoryOps:${this.doc_id}`, JSON.stringify(this.ops[0]), JSON.stringify(this.ops[1]))
-						.should.equal(true);
-				});
-
-				it("should push the updates into the project history ops list", function() {
-					return this.ProjectHistoryRedisManager.queueOps
-						.calledWith(this.project_id, JSON.stringify(this.ops[0]))
-						.should.equal(true);
-				});
-
-				it("should call the callback", function() {
-					return this.callback
-						.calledWith(null, this.doc_update_list_length, this.project_update_list_length)
-						.should.equal(true);
-				});
-
-				return it('should not log any errors', function() {
-					return this.logger.error.calledWith()
-						.should.equal(false);
-				});
-			});
-
-			describe("with project history disabled", function() {
-				beforeEach(function() {
-					this.settings.apis.project_history.enabled = false;
-					this.RedisManager.getDocVersion.withArgs(this.doc_id).yields(null, this.version - this.ops.length);
-					return this.RedisManager.updateDocument(this.project_id, this.doc_id, this.lines, this.version, this.ops, this.ranges, this.updateMeta, this.callback);
-				});
-
-				it("should not push the updates into the project history ops list", function() {
-					return this.ProjectHistoryRedisManager.queueOps.called.should.equal(false);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback
-						.calledWith(null, this.doc_update_list_length)
-						.should.equal(true);
-				});
-			});
-
-			return describe("with a doc using project history only", function() {
-				beforeEach(function() {
-					this.RedisManager.getDocVersion.withArgs(this.doc_id).yields(null, this.version - this.ops.length, 'project-history');
-					return this.RedisManager.updateDocument(this.project_id, this.doc_id, this.lines, this.version, this.ops, this.ranges, this.updateMeta, this.callback);
-				});
-
-				it("should not push the updates to the track-changes ops list", function() {
-					return this.multi.rpush
-						.calledWith(`UncompressedHistoryOps:${this.doc_id}`)
-						.should.equal(false);
-				});
-
-				it("should push the updates into the project history ops list", function() {
-					return this.ProjectHistoryRedisManager.queueOps
-						.calledWith(this.project_id, JSON.stringify(this.ops[0]))
-						.should.equal(true);
-				});
-
-				return it("should call the callback with the project update count only", function() {
-					return this.callback
-						.calledWith(null, undefined, this.project_update_list_length)
-						.should.equal(true);
-				});
-			});
-		});
-
-		describe("with an inconsistent version", function() {
-			beforeEach(function() {
-				this.RedisManager.getDocVersion.withArgs(this.doc_id).yields(null, this.version - this.ops.length - 1);
-				return this.RedisManager.updateDocument(this.project_id, this.doc_id, this.lines, this.version, this.ops, this.ranges, this.updateMeta, this.callback);
-			});
-
-			it("should not call multi.exec", function() {
-				return this.multi.exec.called.should.equal(false);
-			});
-
-			return it("should call the callback with an error", function() {
-				return this.callback
-					.calledWith(new Error(`Version mismatch. '${this.doc_id}' is corrupted.`))
-					.should.equal(true);
-			});
-		});
-
-		describe("with no updates", function() {
-			beforeEach(function() {
-				this.RedisManager.getDocVersion.withArgs(this.doc_id).yields(null, this.version);
-				return this.RedisManager.updateDocument(this.project_id, this.doc_id, this.lines, this.version, [], this.ranges, this.updateMeta, this.callback);
-			});
-
-			it("should not try to enqueue doc updates", function() {
-				return this.multi.rpush
-					.called
-					.should.equal(false);
-			});
-
-			it("should not try to enqueue project updates", function() {
-				return this.ProjectHistoryRedisManager.queueOps
-					.called
-					.should.equal(false);
-			});
-
-			return it("should still set the doclines", function() {
-				return this.multi.set
-					.calledWith(`doclines:${this.doc_id}`, JSON.stringify(this.lines))
-					.should.equal(true);
-			});
-		});
-
-		describe("with empty ranges", function() {
-			beforeEach(function() {
-				this.RedisManager.getDocVersion.withArgs(this.doc_id).yields(null, this.version - this.ops.length);
-				return this.RedisManager.updateDocument(this.project_id, this.doc_id, this.lines, this.version, this.ops, {}, this.updateMeta, this.callback);
-			});
-
-			it("should not set the ranges", function() {
-				return this.multi.set
-					.calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
-					.should.equal(false);
-			});
-
-			return it("should delete the ranges key", function() {
-				return this.multi.del
-					.calledWith(`Ranges:${this.doc_id}`)
-					.should.equal(true);
-			});
-		});
-
-		describe("with null bytes in the serialized doc lines", function() {
-			beforeEach(function() {
-				this.RedisManager.getDocVersion.withArgs(this.doc_id).yields(null, this.version - this.ops.length);
-				this._stringify = JSON.stringify;
-				this.JSON.stringify = () => '["bad bytes! \u0000 <- here"]';
-				return this.RedisManager.updateDocument(this.project_id, this.doc_id, this.lines, this.version, this.ops, this.ranges, this.updateMeta, this.callback);
-			});
-
-			afterEach(function() {
-				return this.JSON.stringify = this._stringify;
-			});
-
-			it("should log an error", function() {
-				return this.logger.error.called.should.equal(true);
-			});
-
-			return it("should call the callback with an error", function() {
-				return this.callback.calledWith(new Error("null bytes found in doc lines")).should.equal(true);
-			});
-		});
-
-		describe("with ranges that are too big", function() {
-			beforeEach(function() {
-				this.RedisManager.getDocVersion.withArgs(this.doc_id).yields(null, this.version - this.ops.length);
-				this.RedisManager._serializeRanges = sinon.stub().yields(new Error("ranges are too large"));
-				return this.RedisManager.updateDocument(this.project_id, this.doc_id, this.lines, this.version, this.ops, this.ranges, this.updateMeta, this.callback);
-			});
-
-			it('should log an error', function() {
-				return this.logger.error.called.should.equal(true);
-			});
-
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWith(new Error("ranges are too large")).should.equal(true);
-			});
-		});
-
-		return describe("without user id from meta", function() {
-			beforeEach(function() {
-				this.RedisManager.getDocVersion.withArgs(this.doc_id).yields(null, this.version - this.ops.length);
-				return this.RedisManager.updateDocument(this.project_id, this.doc_id, this.lines, this.version, this.ops, this.ranges, {}, this.callback);
-			});
-
-			it("should set the last updater to null", function() {
-				return this.multi.del
-					.calledWith(`lastUpdatedBy:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			return it("should still set the last updated time", function() {
-				return this.multi.set
-					.calledWith(`lastUpdatedAt:${this.doc_id}`, Date.now())
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("putDocInMemory", function() {
-		beforeEach(function() {
-			this.multi.set = sinon.stub();
-			this.rclient.sadd = sinon.stub().yields();
-			this.multi.del = sinon.stub();
-			this.lines = ["one", "two", "three", "これは"];
-			this.version = 42;
-			this.hash = crypto.createHash('sha1').update(JSON.stringify(this.lines),'utf8').digest('hex');
-			this.multi.exec = sinon.stub().callsArgWith(0, null, [this.hash]);
-			this.ranges = { comments: "mock", entries: "mock" };
-			return this.pathname = '/a/b/c.tex';
-		});
-
-		describe("with non-empty ranges", function() {
-			beforeEach(function(done) {
-				return this.RedisManager.putDocInMemory(this.project_id, this.doc_id, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, done);
-			});
-
-			it("should set the lines", function() {
-				return this.multi.set
-					.calledWith(`doclines:${this.doc_id}`, JSON.stringify(this.lines))
-					.should.equal(true);
-			});
-
-			it("should set the version", function() {
-				return this.multi.set
-					.calledWith(`DocVersion:${this.doc_id}`, this.version)
-					.should.equal(true);
-			});
-
-			it("should set the hash", function() {
-				return this.multi.set
-					.calledWith(`DocHash:${this.doc_id}`, this.hash)
-					.should.equal(true);
-			});
-
-			it("should set the ranges", function() {
-				return this.multi.set
-					.calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
-					.should.equal(true);
-			});
-
-			it("should set the project_id for the doc", function() {
-				return this.multi.set
-					.calledWith(`ProjectId:${this.doc_id}`, this.project_id)
-					.should.equal(true);
-			});
-
-			it("should set the pathname for the doc", function() {
-				return this.multi.set
-					.calledWith(`Pathname:${this.doc_id}`, this.pathname)
-					.should.equal(true);
-			});
-
-			it("should set the projectHistoryId for the doc", function() {
-				return this.multi.set
-					.calledWith(`ProjectHistoryId:${this.doc_id}`, this.projectHistoryId)
-					.should.equal(true);
-			});
-
-			it("should add the doc_id to the project set", function() {
-				return this.rclient.sadd
-					.calledWith(`DocsIn:${this.project_id}`, this.doc_id)
-					.should.equal(true);
-			});
-
-			return it('should not log any errors', function() {
-				return this.logger.error.calledWith()
-					.should.equal(false);
-			});
-		});
-
-		describe("with empty ranges", function() {
-			beforeEach(function(done) {
-				return this.RedisManager.putDocInMemory(this.project_id, this.doc_id, this.lines, this.version, {}, this.pathname, this.projectHistoryId, done);
-			});
-
-			it("should delete the ranges key", function() {
-				return this.multi.del
-					.calledWith(`Ranges:${this.doc_id}`)
-					.should.equal(true);
-			});
-
-			return it("should not set the ranges", function() {
-				return this.multi.set
-					.calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
-					.should.equal(false);
-			});
-		});
-
-		describe("with null bytes in the serialized doc lines", function() {
-			beforeEach(function() {
-				this._stringify = JSON.stringify;
-				this.JSON.stringify = () => '["bad bytes! \u0000 <- here"]';
-				return this.RedisManager.putDocInMemory(this.project_id, this.doc_id, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, this.callback);
-			});
-
-			afterEach(function() {
-				return this.JSON.stringify = this._stringify;
-			});
-
-			it("should log an error", function() {
-				return this.logger.error.called.should.equal(true);
-			});
-
-			return it("should call the callback with an error", function() {
-				return this.callback.calledWith(new Error("null bytes found in doc lines")).should.equal(true);
-			});
-		});
-
-		return describe("with ranges that are too big", function() {
-			beforeEach(function() {
-				this.RedisManager._serializeRanges = sinon.stub().yields(new Error("ranges are too large"));
-				return this.RedisManager.putDocInMemory(this.project_id, this.doc_id, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId, this.callback);
-			});
-
-			it('should log an error', function() {
-				return this.logger.error.called.should.equal(true);
-			});
-
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWith(new Error("ranges are too large")).should.equal(true);
-			});
-		});
-	});
-
-	describe("removeDocFromMemory", function() {
-		beforeEach(function(done) {
-			this.multi.strlen = sinon.stub();
-			this.multi.del = sinon.stub();
-			this.multi.srem = sinon.stub();
-			this.multi.exec.yields();
-			return this.RedisManager.removeDocFromMemory(this.project_id, this.doc_id, done);
-		});
-
-		it("should check the length of the current doclines", function() {
-			return this.multi.strlen
-				.calledWith(`doclines:${this.doc_id}`)
-				.should.equal(true);
-		});
-
-		it("should delete the lines", function() {
-			return this.multi.del
-				.calledWith(`doclines:${this.doc_id}`)
-				.should.equal(true);
-		});
-
-		it("should delete the version", function() {
-			return this.multi.del
-				.calledWith(`DocVersion:${this.doc_id}`)
-				.should.equal(true);
-		});
-
-		it("should delete the hash", function() {
-			return this.multi.del
-				.calledWith(`DocHash:${this.doc_id}`)
-				.should.equal(true);
-		});
-
-		it("should delete the unflushed time", function() {
-			return this.multi.del
-				.calledWith(`UnflushedTime:${this.doc_id}`)
-				.should.equal(true);
-		});
-
-		it("should delete the project_id for the doc", function() {
-			return this.multi.del
-				.calledWith(`ProjectId:${this.doc_id}`)
-				.should.equal(true);
-		});
-
-		it("should remove the doc_id from the project set", function() {
-			return this.multi.srem
-				.calledWith(`DocsIn:${this.project_id}`, this.doc_id)
-				.should.equal(true);
-		});
-
-		it("should delete the pathname for the doc", function() {
-			return this.multi.del
-				.calledWith(`Pathname:${this.doc_id}`)
-				.should.equal(true);
-		});
-
-		it("should delete the pathname for the doc", function() {
-			return this.multi.del
-				.calledWith(`ProjectHistoryId:${this.doc_id}`)
-				.should.equal(true);
-		});
-
-		it("should delete lastUpdatedAt", function() {
-			return this.multi.del
-				.calledWith(`lastUpdatedAt:${this.doc_id}`)
-				.should.equal(true);
-		});
-
-		return it("should delete lastUpdatedBy", function() {
-			return this.multi.del
-				.calledWith(`lastUpdatedBy:${this.doc_id}`)
-				.should.equal(true);
-		});
-	});
-
-
-	describe("clearProjectState", function() {
-		beforeEach(function(done) {
-			this.rclient.del = sinon.stub().callsArg(1);
-			return this.RedisManager.clearProjectState(this.project_id, done);
-		});
-
-		return it("should delete the project state", function() {
-			return this.rclient.del
-				.calledWith(`ProjectState:${this.project_id}`)
-				.should.equal(true);
-		});
-	});
-
-	return describe("renameDoc", function() {
-		beforeEach(function() {
-			this.rclient.rpush = sinon.stub().yields();
-			this.rclient.set = sinon.stub().yields();
-			return this.update = {
-				id: this.doc_id,
-				pathname: (this.pathname = 'pathname'),
-				newPathname: (this.newPathname = 'new-pathname')
-			};
-		});
-
-		describe("the document is cached in redis", function() {
-			beforeEach(function() {
-				this.RedisManager.getDoc = sinon.stub().callsArgWith(2, null, 'lines', 'version');
-				this.ProjectHistoryRedisManager.queueRenameEntity = sinon.stub().yields();
-				return this.RedisManager.renameDoc(this.project_id, this.doc_id, this.userId, this.update, this.projectHistoryId, this.callback);
-			});
-
-			it("update the cached pathname", function() {
-				return this.rclient.set
-					.calledWith(`Pathname:${this.doc_id}`, this.newPathname)
-					.should.equal(true);
-			});
-
-			return it("should queue an update", function() {
-				return this.ProjectHistoryRedisManager.queueRenameEntity
-					.calledWithExactly(this.project_id, this.projectHistoryId, 'doc', this.doc_id, this.userId, this.update, this.callback)
-					.should.equal(true);
-			});
-		});
-
-		describe("the document is not cached in redis", function() {
-			beforeEach(function() {
-				this.RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null);
-				this.ProjectHistoryRedisManager.queueRenameEntity = sinon.stub().yields();
-				return this.RedisManager.renameDoc(this.project_id, this.doc_id, this.userId, this.update, this.projectHistoryId, this.callback);
-			});
-
-			it("does not update the cached pathname", function() {
-				return this.rclient.set.called.should.equal(false);
-			});
-
-			return it("should queue an update", function() {
-				return this.ProjectHistoryRedisManager.queueRenameEntity
-					.calledWithExactly(this.project_id, this.projectHistoryId, 'doc', this.doc_id, this.userId, this.update, this.callback)
-					.should.equal(true);
-			});
-		});
-
-		return describe("getDocVersion", function() {
-			beforeEach(function() {
-				return this.version = 12345;
-			});
-
-			describe("when the document does not have a project history type set", function() {
-				beforeEach(function() {
-					this.rclient.mget = sinon.stub().withArgs(`DocVersion:${this.doc_id}`, `ProjectHistoryType:${this.doc_id}`).callsArgWith(2, null, [`${this.version}`]);
-					return this.RedisManager.getDocVersion(this.doc_id, this.callback);
-				});
-
-				return it("should return the document version and an undefined history type", function() {
-					return this.callback.calledWithExactly(null, this.version, undefined).should.equal(true);
-				});
-			});
-
-			return describe("when the document has a project history type set", function() {
-				beforeEach(function() {
-					this.rclient.mget = sinon.stub().withArgs(`DocVersion:${this.doc_id}`, `ProjectHistoryType:${this.doc_id}`).callsArgWith(2, null, [`${this.version}`, 'project-history']);
-					return this.RedisManager.getDocVersion(this.doc_id, this.callback);
-				});
-
-				return it("should return the document version and history type", function() {
-					return this.callback.calledWithExactly(null, this.version, 'project-history').should.equal(true);
-				});
-			});
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/RedisManager.js'
+const SandboxedModule = require('sandboxed-module')
+const Errors = require('../../../../app/js/Errors')
+const crypto = require('crypto')
+const tk = require('timekeeper')
+
+describe('RedisManager', function () {
+  beforeEach(function () {
+    let Timer
+    this.multi = { exec: sinon.stub() }
+    this.rclient = { multi: () => this.multi }
+    tk.freeze(new Date())
+    this.RedisManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': (this.logger = {
+          error: sinon.stub(),
+          log: sinon.stub(),
+          warn: sinon.stub()
+        }),
+        './ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
+        'settings-sharelatex': (this.settings = {
+          documentupdater: { logHashErrors: { write: true, read: true } },
+          apis: {
+            project_history: { enabled: true }
+          },
+          redis: {
+            documentupdater: {
+              key_schema: {
+                blockingKey({ doc_id }) {
+                  return `Blocking:${doc_id}`
+                },
+                docLines({ doc_id }) {
+                  return `doclines:${doc_id}`
+                },
+                docOps({ doc_id }) {
+                  return `DocOps:${doc_id}`
+                },
+                docVersion({ doc_id }) {
+                  return `DocVersion:${doc_id}`
+                },
+                docHash({ doc_id }) {
+                  return `DocHash:${doc_id}`
+                },
+                projectKey({ doc_id }) {
+                  return `ProjectId:${doc_id}`
+                },
+                pendingUpdates({ doc_id }) {
+                  return `PendingUpdates:${doc_id}`
+                },
+                docsInProject({ project_id }) {
+                  return `DocsIn:${project_id}`
+                },
+                ranges({ doc_id }) {
+                  return `Ranges:${doc_id}`
+                },
+                pathname({ doc_id }) {
+                  return `Pathname:${doc_id}`
+                },
+                projectHistoryId({ doc_id }) {
+                  return `ProjectHistoryId:${doc_id}`
+                },
+                projectHistoryType({ doc_id }) {
+                  return `ProjectHistoryType:${doc_id}`
+                },
+                projectState({ project_id }) {
+                  return `ProjectState:${project_id}`
+                },
+                unflushedTime({ doc_id }) {
+                  return `UnflushedTime:${doc_id}`
+                },
+                lastUpdatedBy({ doc_id }) {
+                  return `lastUpdatedBy:${doc_id}`
+                },
+                lastUpdatedAt({ doc_id }) {
+                  return `lastUpdatedAt:${doc_id}`
+                }
+              }
+            },
+            history: {
+              key_schema: {
+                uncompressedHistoryOps({ doc_id }) {
+                  return `UncompressedHistoryOps:${doc_id}`
+                },
+                docsWithHistoryOps({ project_id }) {
+                  return `DocsWithHistoryOps:${project_id}`
+                }
+              }
+            }
+          }
+        }),
+        'redis-sharelatex': {
+          createClient: () => this.rclient
+        },
+        './Metrics': (this.metrics = {
+          inc: sinon.stub(),
+          summary: sinon.stub(),
+          Timer: (Timer = class Timer {
+            constructor() {
+              this.start = new Date()
+            }
+
+            done() {
+              const timeSpan = new Date() - this.start
+              return timeSpan
+            }
+          })
+        }),
+        './Errors': Errors
+      },
+      globals: {
+        JSON: (this.JSON = JSON)
+      }
+    })
+
+    this.doc_id = 'doc-id-123'
+    this.project_id = 'project-id-123'
+    this.projectHistoryId = 123
+    return (this.callback = sinon.stub())
+  })
+
+  afterEach(function () {
+    return tk.reset()
+  })
+
+  describe('getDoc', function () {
+    beforeEach(function () {
+      this.lines = ['one', 'two', 'three', 'これは'] // include some utf8
+      this.jsonlines = JSON.stringify(this.lines)
+      this.version = 42
+      this.hash = crypto
+        .createHash('sha1')
+        .update(this.jsonlines, 'utf8')
+        .digest('hex')
+      this.ranges = { comments: 'mock', entries: 'mock' }
+      this.json_ranges = JSON.stringify(this.ranges)
+      this.unflushed_time = 12345
+      this.pathname = '/a/b/c.tex'
+      this.multi.get = sinon.stub()
+      this.multi.exec = sinon
+        .stub()
+        .callsArgWith(0, null, [
+          this.jsonlines,
+          this.version,
+          this.hash,
+          this.project_id,
+          this.json_ranges,
+          this.pathname,
+          this.projectHistoryId.toString(),
+          this.unflushed_time
+        ])
+      return (this.rclient.sadd = sinon.stub().yields(null, 0))
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        return this.RedisManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the lines from redis', function () {
+        return this.multi.get
+          .calledWith(`doclines:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get the version from', function () {
+        return this.multi.get
+          .calledWith(`DocVersion:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get the hash', function () {
+        return this.multi.get
+          .calledWith(`DocHash:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get the ranges', function () {
+        return this.multi.get
+          .calledWith(`Ranges:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get the unflushed time', function () {
+        return this.multi.get
+          .calledWith(`UnflushedTime:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get the pathname', function () {
+        return this.multi.get
+          .calledWith(`Pathname:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get the projectHistoryId as an integer', function () {
+        return this.multi.get
+          .calledWith(`ProjectHistoryId:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get lastUpdatedAt', function () {
+        return this.multi.get
+          .calledWith(`lastUpdatedAt:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get lastUpdatedBy', function () {
+        return this.multi.get
+          .calledWith(`lastUpdatedBy:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should check if the document is in the DocsIn set', function () {
+        return this.rclient.sadd
+          .calledWith(`DocsIn:${this.project_id}`)
+          .should.equal(true)
+      })
+
+      it('should return the document', function () {
+        return this.callback
+          .calledWithExactly(
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId,
+            this.unflushed_time,
+            this.lastUpdatedAt,
+            this.lastUpdatedBy
+          )
+          .should.equal(true)
+      })
+
+      return it('should not log any errors', function () {
+        return this.logger.error.calledWith().should.equal(false)
+      })
+    })
+
+    describe('when the document is not present', function () {
+      beforeEach(function () {
+        this.multi.exec = sinon
+          .stub()
+          .callsArgWith(0, null, [
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null
+          ])
+        this.rclient.sadd = sinon.stub().yields()
+        return this.RedisManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should not check if the document is in the DocsIn set', function () {
+        return this.rclient.sadd
+          .calledWith(`DocsIn:${this.project_id}`)
+          .should.equal(false)
+      })
+
+      it('should return an empty result', function () {
+        return this.callback
+          .calledWithExactly(null, null, 0, {}, null, null, null, null, null)
+          .should.equal(true)
+      })
+
+      return it('should not log any errors', function () {
+        return this.logger.error.calledWith().should.equal(false)
+      })
+    })
+
+    describe('when the document is missing from the DocsIn set', function () {
+      beforeEach(function () {
+        this.rclient.sadd = sinon.stub().yields(null, 1)
+        return this.RedisManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should log an error', function () {
+        return this.logger.error.calledWith().should.equal(true)
+      })
+
+      return it('should return the document', function () {
+        return this.callback
+          .calledWithExactly(
+            null,
+            this.lines,
+            this.version,
+            this.ranges,
+            this.pathname,
+            this.projectHistoryId,
+            this.unflushed_time,
+            this.lastUpdatedAt,
+            this.lastUpdatedBy
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('with a corrupted document', function () {
+      beforeEach(function () {
+        this.badHash = 'INVALID-HASH-VALUE'
+        this.multi.exec = sinon
+          .stub()
+          .callsArgWith(0, null, [
+            this.jsonlines,
+            this.version,
+            this.badHash,
+            this.project_id,
+            this.json_ranges
+          ])
+        return this.RedisManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should log a hash error', function () {
+        return this.logger.error.calledWith().should.equal(true)
+      })
+
+      return it('should return the document', function () {
+        return this.callback
+          .calledWith(null, this.lines, this.version, this.ranges)
+          .should.equal(true)
+      })
+    })
+
+    describe('with a slow request to redis', function () {
+      beforeEach(function () {
+        this.multi.exec = sinon
+          .stub()
+          .callsArgWith(0, null, [
+            this.jsonlines,
+            this.version,
+            this.badHash,
+            this.project_id,
+            this.json_ranges,
+            this.pathname,
+            this.unflushed_time
+          ])
+        this.clock = sinon.useFakeTimers()
+        this.multi.exec = (cb) => {
+          this.clock.tick(6000)
+          return cb(null, [
+            this.jsonlines,
+            this.version,
+            this.another_project_id,
+            this.json_ranges,
+            this.pathname,
+            this.unflushed_time
+          ])
+        }
+
+        return this.RedisManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      afterEach(function () {
+        return this.clock.restore()
+      })
+
+      return it('should return an error', function () {
+        return this.callback
+          .calledWith(new Error('redis getDoc exceeded timeout'))
+          .should.equal(true)
+      })
+    })
+
+    return describe('getDoc with an invalid project id', function () {
+      beforeEach(function () {
+        this.another_project_id = 'project-id-456'
+        this.multi.exec = sinon
+          .stub()
+          .callsArgWith(0, null, [
+            this.jsonlines,
+            this.version,
+            this.hash,
+            this.another_project_id,
+            this.json_ranges,
+            this.pathname,
+            this.unflushed_time
+          ])
+        return this.RedisManager.getDoc(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      return it('should return an error', function () {
+        return this.callback
+          .calledWith(new Errors.NotFoundError('not found'))
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('getPreviousDocOpsTests', function () {
+    describe('with a start and an end value', function () {
+      beforeEach(function () {
+        this.first_version_in_redis = 30
+        this.version = 70
+        this.length = this.version - this.first_version_in_redis
+        this.start = 50
+        this.end = 60
+        this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
+        this.jsonOps = this.ops.map((op) => JSON.stringify(op))
+        this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
+        this.rclient.get = sinon
+          .stub()
+          .callsArgWith(1, null, this.version.toString())
+        this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps)
+        return this.RedisManager.getPreviousDocOps(
+          this.doc_id,
+          this.start,
+          this.end,
+          this.callback
+        )
+      })
+
+      it('should get the length of the existing doc ops', function () {
+        return this.rclient.llen
+          .calledWith(`DocOps:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get the current version of the doc', function () {
+        return this.rclient.get
+          .calledWith(`DocVersion:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should get the appropriate docs ops', function () {
+        return this.rclient.lrange
+          .calledWith(
+            `DocOps:${this.doc_id}`,
+            this.start - this.first_version_in_redis,
+            this.end - this.first_version_in_redis
+          )
+          .should.equal(true)
+      })
+
+      return it('should return the docs with the doc ops deserialized', function () {
+        return this.callback.calledWith(null, this.ops).should.equal(true)
+      })
+    })
+
+    describe('with an end value of -1', function () {
+      beforeEach(function () {
+        this.first_version_in_redis = 30
+        this.version = 70
+        this.length = this.version - this.first_version_in_redis
+        this.start = 50
+        this.end = -1
+        this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
+        this.jsonOps = this.ops.map((op) => JSON.stringify(op))
+        this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
+        this.rclient.get = sinon
+          .stub()
+          .callsArgWith(1, null, this.version.toString())
+        this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps)
+        return this.RedisManager.getPreviousDocOps(
+          this.doc_id,
+          this.start,
+          this.end,
+          this.callback
+        )
+      })
+
+      it('should get the appropriate docs ops to the end of list', function () {
+        return this.rclient.lrange
+          .calledWith(
+            `DocOps:${this.doc_id}`,
+            this.start - this.first_version_in_redis,
+            -1
+          )
+          .should.equal(true)
+      })
+
+      return it('should return the docs with the doc ops deserialized', function () {
+        return this.callback.calledWith(null, this.ops).should.equal(true)
+      })
+    })
+
+    describe('when the requested range is not in Redis', function () {
+      beforeEach(function () {
+        this.first_version_in_redis = 30
+        this.version = 70
+        this.length = this.version - this.first_version_in_redis
+        this.start = 20
+        this.end = -1
+        this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
+        this.jsonOps = this.ops.map((op) => JSON.stringify(op))
+        this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
+        this.rclient.get = sinon
+          .stub()
+          .callsArgWith(1, null, this.version.toString())
+        this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps)
+        return this.RedisManager.getPreviousDocOps(
+          this.doc_id,
+          this.start,
+          this.end,
+          this.callback
+        )
+      })
+
+      it('should return an error', function () {
+        return this.callback
+          .calledWith(
+            new Errors.OpRangeNotAvailableError(
+              'doc ops range is not loaded in redis'
+            )
+          )
+          .should.equal(true)
+      })
+
+      return it('should log out the problem', function () {
+        return this.logger.warn.called.should.equal(true)
+      })
+    })
+
+    return describe('with a slow request to redis', function () {
+      beforeEach(function () {
+        this.first_version_in_redis = 30
+        this.version = 70
+        this.length = this.version - this.first_version_in_redis
+        this.start = 50
+        this.end = 60
+        this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
+        this.jsonOps = this.ops.map((op) => JSON.stringify(op))
+        this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
+        this.rclient.get = sinon
+          .stub()
+          .callsArgWith(1, null, this.version.toString())
+        this.clock = sinon.useFakeTimers()
+        this.rclient.lrange = (key, start, end, cb) => {
+          this.clock.tick(6000)
+          return cb(null, this.jsonOps)
+        }
+        return this.RedisManager.getPreviousDocOps(
+          this.doc_id,
+          this.start,
+          this.end,
+          this.callback
+        )
+      })
+
+      afterEach(function () {
+        return this.clock.restore()
+      })
+
+      return it('should return an error', function () {
+        return this.callback
+          .calledWith(new Error('redis getPreviousDocOps exceeded timeout'))
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('updateDocument', function () {
+    beforeEach(function () {
+      this.lines = ['one', 'two', 'three', 'これは']
+      this.ops = [{ op: [{ i: 'foo', p: 4 }] }, { op: [{ i: 'bar', p: 8 }] }]
+      this.version = 42
+      this.hash = crypto
+        .createHash('sha1')
+        .update(JSON.stringify(this.lines), 'utf8')
+        .digest('hex')
+      this.ranges = { comments: 'mock', entries: 'mock' }
+      this.updateMeta = { user_id: 'last-author-fake-id' }
+      this.doc_update_list_length = sinon.stub()
+      this.project_update_list_length = sinon.stub()
+
+      this.RedisManager.getDocVersion = sinon.stub()
+      this.multi.set = sinon.stub()
+      this.multi.rpush = sinon.stub()
+      this.multi.expire = sinon.stub()
+      this.multi.ltrim = sinon.stub()
+      this.multi.del = sinon.stub()
+      this.multi.exec = sinon
+        .stub()
+        .callsArgWith(0, null, [
+          this.hash,
+          null,
+          null,
+          null,
+          null,
+          null,
+          null,
+          this.doc_update_list_length,
+          null,
+          null
+        ])
+      return (this.ProjectHistoryRedisManager.queueOps = sinon
+        .stub()
+        .callsArgWith(
+          this.ops.length + 1,
+          null,
+          this.project_update_list_length
+        ))
+    })
+
+    describe('with a consistent version', function () {
+      beforeEach(function () {})
+
+      describe('with project history enabled', function () {
+        beforeEach(function () {
+          this.settings.apis.project_history.enabled = true
+          this.RedisManager.getDocVersion
+            .withArgs(this.doc_id)
+            .yields(null, this.version - this.ops.length)
+          return this.RedisManager.updateDocument(
+            this.project_id,
+            this.doc_id,
+            this.lines,
+            this.version,
+            this.ops,
+            this.ranges,
+            this.updateMeta,
+            this.callback
+          )
+        })
+
+        it('should get the current doc version to check for consistency', function () {
+          return this.RedisManager.getDocVersion
+            .calledWith(this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should set the doclines', function () {
+          return this.multi.set
+            .calledWith(`doclines:${this.doc_id}`, JSON.stringify(this.lines))
+            .should.equal(true)
+        })
+
+        it('should set the version', function () {
+          return this.multi.set
+            .calledWith(`DocVersion:${this.doc_id}`, this.version)
+            .should.equal(true)
+        })
+
+        it('should set the hash', function () {
+          return this.multi.set
+            .calledWith(`DocHash:${this.doc_id}`, this.hash)
+            .should.equal(true)
+        })
+
+        it('should set the ranges', function () {
+          return this.multi.set
+            .calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
+            .should.equal(true)
+        })
+
+        it('should set the unflushed time', function () {
+          return this.multi.set
+            .calledWith(`UnflushedTime:${this.doc_id}`, Date.now(), 'NX')
+            .should.equal(true)
+        })
+
+        it('should set the last updated time', function () {
+          return this.multi.set
+            .calledWith(`lastUpdatedAt:${this.doc_id}`, Date.now())
+            .should.equal(true)
+        })
+
+        it('should set the last updater', function () {
+          return this.multi.set
+            .calledWith(`lastUpdatedBy:${this.doc_id}`, 'last-author-fake-id')
+            .should.equal(true)
+        })
+
+        it('should push the doc op into the doc ops list', function () {
+          return this.multi.rpush
+            .calledWith(
+              `DocOps:${this.doc_id}`,
+              JSON.stringify(this.ops[0]),
+              JSON.stringify(this.ops[1])
+            )
+            .should.equal(true)
+        })
+
+        it('should renew the expiry ttl on the doc ops array', function () {
+          return this.multi.expire
+            .calledWith(`DocOps:${this.doc_id}`, this.RedisManager.DOC_OPS_TTL)
+            .should.equal(true)
+        })
+
+        it('should truncate the list to 100 members', function () {
+          return this.multi.ltrim
+            .calledWith(
+              `DocOps:${this.doc_id}`,
+              -this.RedisManager.DOC_OPS_MAX_LENGTH,
+              -1
+            )
+            .should.equal(true)
+        })
+
+        it('should push the updates into the history ops list', function () {
+          return this.multi.rpush
+            .calledWith(
+              `UncompressedHistoryOps:${this.doc_id}`,
+              JSON.stringify(this.ops[0]),
+              JSON.stringify(this.ops[1])
+            )
+            .should.equal(true)
+        })
+
+        it('should push the updates into the project history ops list', function () {
+          return this.ProjectHistoryRedisManager.queueOps
+            .calledWith(this.project_id, JSON.stringify(this.ops[0]))
+            .should.equal(true)
+        })
+
+        it('should call the callback', function () {
+          return this.callback
+            .calledWith(
+              null,
+              this.doc_update_list_length,
+              this.project_update_list_length
+            )
+            .should.equal(true)
+        })
+
+        return it('should not log any errors', function () {
+          return this.logger.error.calledWith().should.equal(false)
+        })
+      })
+
+      describe('with project history disabled', function () {
+        beforeEach(function () {
+          this.settings.apis.project_history.enabled = false
+          this.RedisManager.getDocVersion
+            .withArgs(this.doc_id)
+            .yields(null, this.version - this.ops.length)
+          return this.RedisManager.updateDocument(
+            this.project_id,
+            this.doc_id,
+            this.lines,
+            this.version,
+            this.ops,
+            this.ranges,
+            this.updateMeta,
+            this.callback
+          )
+        })
+
+        it('should not push the updates into the project history ops list', function () {
+          return this.ProjectHistoryRedisManager.queueOps.called.should.equal(
+            false
+          )
+        })
+
+        return it('should call the callback', function () {
+          return this.callback
+            .calledWith(null, this.doc_update_list_length)
+            .should.equal(true)
+        })
+      })
+
+      return describe('with a doc using project history only', function () {
+        beforeEach(function () {
+          this.RedisManager.getDocVersion
+            .withArgs(this.doc_id)
+            .yields(null, this.version - this.ops.length, 'project-history')
+          return this.RedisManager.updateDocument(
+            this.project_id,
+            this.doc_id,
+            this.lines,
+            this.version,
+            this.ops,
+            this.ranges,
+            this.updateMeta,
+            this.callback
+          )
+        })
+
+        it('should not push the updates to the track-changes ops list', function () {
+          return this.multi.rpush
+            .calledWith(`UncompressedHistoryOps:${this.doc_id}`)
+            .should.equal(false)
+        })
+
+        it('should push the updates into the project history ops list', function () {
+          return this.ProjectHistoryRedisManager.queueOps
+            .calledWith(this.project_id, JSON.stringify(this.ops[0]))
+            .should.equal(true)
+        })
+
+        return it('should call the callback with the project update count only', function () {
+          return this.callback
+            .calledWith(null, undefined, this.project_update_list_length)
+            .should.equal(true)
+        })
+      })
+    })
+
+    describe('with an inconsistent version', function () {
+      beforeEach(function () {
+        this.RedisManager.getDocVersion
+          .withArgs(this.doc_id)
+          .yields(null, this.version - this.ops.length - 1)
+        return this.RedisManager.updateDocument(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          this.ops,
+          this.ranges,
+          this.updateMeta,
+          this.callback
+        )
+      })
+
+      it('should not call multi.exec', function () {
+        return this.multi.exec.called.should.equal(false)
+      })
+
+      return it('should call the callback with an error', function () {
+        return this.callback
+          .calledWith(
+            new Error(`Version mismatch. '${this.doc_id}' is corrupted.`)
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('with no updates', function () {
+      beforeEach(function () {
+        this.RedisManager.getDocVersion
+          .withArgs(this.doc_id)
+          .yields(null, this.version)
+        return this.RedisManager.updateDocument(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          [],
+          this.ranges,
+          this.updateMeta,
+          this.callback
+        )
+      })
+
+      it('should not try to enqueue doc updates', function () {
+        return this.multi.rpush.called.should.equal(false)
+      })
+
+      it('should not try to enqueue project updates', function () {
+        return this.ProjectHistoryRedisManager.queueOps.called.should.equal(
+          false
+        )
+      })
+
+      return it('should still set the doclines', function () {
+        return this.multi.set
+          .calledWith(`doclines:${this.doc_id}`, JSON.stringify(this.lines))
+          .should.equal(true)
+      })
+    })
+
+    describe('with empty ranges', function () {
+      beforeEach(function () {
+        this.RedisManager.getDocVersion
+          .withArgs(this.doc_id)
+          .yields(null, this.version - this.ops.length)
+        return this.RedisManager.updateDocument(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          this.ops,
+          {},
+          this.updateMeta,
+          this.callback
+        )
+      })
+
+      it('should not set the ranges', function () {
+        return this.multi.set
+          .calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
+          .should.equal(false)
+      })
+
+      return it('should delete the ranges key', function () {
+        return this.multi.del
+          .calledWith(`Ranges:${this.doc_id}`)
+          .should.equal(true)
+      })
+    })
+
+    describe('with null bytes in the serialized doc lines', function () {
+      beforeEach(function () {
+        this.RedisManager.getDocVersion
+          .withArgs(this.doc_id)
+          .yields(null, this.version - this.ops.length)
+        this._stringify = JSON.stringify
+        this.JSON.stringify = () => '["bad bytes! \u0000 <- here"]'
+        return this.RedisManager.updateDocument(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          this.ops,
+          this.ranges,
+          this.updateMeta,
+          this.callback
+        )
+      })
+
+      afterEach(function () {
+        return (this.JSON.stringify = this._stringify)
+      })
+
+      it('should log an error', function () {
+        return this.logger.error.called.should.equal(true)
+      })
+
+      return it('should call the callback with an error', function () {
+        return this.callback
+          .calledWith(new Error('null bytes found in doc lines'))
+          .should.equal(true)
+      })
+    })
+
+    describe('with ranges that are too big', function () {
+      beforeEach(function () {
+        this.RedisManager.getDocVersion
+          .withArgs(this.doc_id)
+          .yields(null, this.version - this.ops.length)
+        this.RedisManager._serializeRanges = sinon
+          .stub()
+          .yields(new Error('ranges are too large'))
+        return this.RedisManager.updateDocument(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          this.ops,
+          this.ranges,
+          this.updateMeta,
+          this.callback
+        )
+      })
+
+      it('should log an error', function () {
+        return this.logger.error.called.should.equal(true)
+      })
+
+      return it('should call the callback with the error', function () {
+        return this.callback
+          .calledWith(new Error('ranges are too large'))
+          .should.equal(true)
+      })
+    })
+
+    return describe('without user id from meta', function () {
+      beforeEach(function () {
+        this.RedisManager.getDocVersion
+          .withArgs(this.doc_id)
+          .yields(null, this.version - this.ops.length)
+        return this.RedisManager.updateDocument(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          this.ops,
+          this.ranges,
+          {},
+          this.callback
+        )
+      })
+
+      it('should set the last updater to null', function () {
+        return this.multi.del
+          .calledWith(`lastUpdatedBy:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      return it('should still set the last updated time', function () {
+        return this.multi.set
+          .calledWith(`lastUpdatedAt:${this.doc_id}`, Date.now())
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('putDocInMemory', function () {
+    beforeEach(function () {
+      this.multi.set = sinon.stub()
+      this.rclient.sadd = sinon.stub().yields()
+      this.multi.del = sinon.stub()
+      this.lines = ['one', 'two', 'three', 'これは']
+      this.version = 42
+      this.hash = crypto
+        .createHash('sha1')
+        .update(JSON.stringify(this.lines), 'utf8')
+        .digest('hex')
+      this.multi.exec = sinon.stub().callsArgWith(0, null, [this.hash])
+      this.ranges = { comments: 'mock', entries: 'mock' }
+      return (this.pathname = '/a/b/c.tex')
+    })
+
+    describe('with non-empty ranges', function () {
+      beforeEach(function (done) {
+        return this.RedisManager.putDocInMemory(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          this.ranges,
+          this.pathname,
+          this.projectHistoryId,
+          done
+        )
+      })
+
+      it('should set the lines', function () {
+        return this.multi.set
+          .calledWith(`doclines:${this.doc_id}`, JSON.stringify(this.lines))
+          .should.equal(true)
+      })
+
+      it('should set the version', function () {
+        return this.multi.set
+          .calledWith(`DocVersion:${this.doc_id}`, this.version)
+          .should.equal(true)
+      })
+
+      it('should set the hash', function () {
+        return this.multi.set
+          .calledWith(`DocHash:${this.doc_id}`, this.hash)
+          .should.equal(true)
+      })
+
+      it('should set the ranges', function () {
+        return this.multi.set
+          .calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
+          .should.equal(true)
+      })
+
+      it('should set the project_id for the doc', function () {
+        return this.multi.set
+          .calledWith(`ProjectId:${this.doc_id}`, this.project_id)
+          .should.equal(true)
+      })
+
+      it('should set the pathname for the doc', function () {
+        return this.multi.set
+          .calledWith(`Pathname:${this.doc_id}`, this.pathname)
+          .should.equal(true)
+      })
+
+      it('should set the projectHistoryId for the doc', function () {
+        return this.multi.set
+          .calledWith(`ProjectHistoryId:${this.doc_id}`, this.projectHistoryId)
+          .should.equal(true)
+      })
+
+      it('should add the doc_id to the project set', function () {
+        return this.rclient.sadd
+          .calledWith(`DocsIn:${this.project_id}`, this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('should not log any errors', function () {
+        return this.logger.error.calledWith().should.equal(false)
+      })
+    })
+
+    describe('with empty ranges', function () {
+      beforeEach(function (done) {
+        return this.RedisManager.putDocInMemory(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          {},
+          this.pathname,
+          this.projectHistoryId,
+          done
+        )
+      })
+
+      it('should delete the ranges key', function () {
+        return this.multi.del
+          .calledWith(`Ranges:${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      return it('should not set the ranges', function () {
+        return this.multi.set
+          .calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
+          .should.equal(false)
+      })
+    })
+
+    describe('with null bytes in the serialized doc lines', function () {
+      beforeEach(function () {
+        this._stringify = JSON.stringify
+        this.JSON.stringify = () => '["bad bytes! \u0000 <- here"]'
+        return this.RedisManager.putDocInMemory(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          this.ranges,
+          this.pathname,
+          this.projectHistoryId,
+          this.callback
+        )
+      })
+
+      afterEach(function () {
+        return (this.JSON.stringify = this._stringify)
+      })
+
+      it('should log an error', function () {
+        return this.logger.error.called.should.equal(true)
+      })
+
+      return it('should call the callback with an error', function () {
+        return this.callback
+          .calledWith(new Error('null bytes found in doc lines'))
+          .should.equal(true)
+      })
+    })
+
+    return describe('with ranges that are too big', function () {
+      beforeEach(function () {
+        this.RedisManager._serializeRanges = sinon
+          .stub()
+          .yields(new Error('ranges are too large'))
+        return this.RedisManager.putDocInMemory(
+          this.project_id,
+          this.doc_id,
+          this.lines,
+          this.version,
+          this.ranges,
+          this.pathname,
+          this.projectHistoryId,
+          this.callback
+        )
+      })
+
+      it('should log an error', function () {
+        return this.logger.error.called.should.equal(true)
+      })
+
+      return it('should call the callback with the error', function () {
+        return this.callback
+          .calledWith(new Error('ranges are too large'))
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('removeDocFromMemory', function () {
+    beforeEach(function (done) {
+      this.multi.strlen = sinon.stub()
+      this.multi.del = sinon.stub()
+      this.multi.srem = sinon.stub()
+      this.multi.exec.yields()
+      return this.RedisManager.removeDocFromMemory(
+        this.project_id,
+        this.doc_id,
+        done
+      )
+    })
+
+    it('should check the length of the current doclines', function () {
+      return this.multi.strlen
+        .calledWith(`doclines:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    it('should delete the lines', function () {
+      return this.multi.del
+        .calledWith(`doclines:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    it('should delete the version', function () {
+      return this.multi.del
+        .calledWith(`DocVersion:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    it('should delete the hash', function () {
+      return this.multi.del
+        .calledWith(`DocHash:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    it('should delete the unflushed time', function () {
+      return this.multi.del
+        .calledWith(`UnflushedTime:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    it('should delete the project_id for the doc', function () {
+      return this.multi.del
+        .calledWith(`ProjectId:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    it('should remove the doc_id from the project set', function () {
+      return this.multi.srem
+        .calledWith(`DocsIn:${this.project_id}`, this.doc_id)
+        .should.equal(true)
+    })
+
+    it('should delete the pathname for the doc', function () {
+      return this.multi.del
+        .calledWith(`Pathname:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    it('should delete the pathname for the doc', function () {
+      return this.multi.del
+        .calledWith(`ProjectHistoryId:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    it('should delete lastUpdatedAt', function () {
+      return this.multi.del
+        .calledWith(`lastUpdatedAt:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    return it('should delete lastUpdatedBy', function () {
+      return this.multi.del
+        .calledWith(`lastUpdatedBy:${this.doc_id}`)
+        .should.equal(true)
+    })
+  })
+
+  describe('clearProjectState', function () {
+    beforeEach(function (done) {
+      this.rclient.del = sinon.stub().callsArg(1)
+      return this.RedisManager.clearProjectState(this.project_id, done)
+    })
+
+    return it('should delete the project state', function () {
+      return this.rclient.del
+        .calledWith(`ProjectState:${this.project_id}`)
+        .should.equal(true)
+    })
+  })
+
+  return describe('renameDoc', function () {
+    beforeEach(function () {
+      this.rclient.rpush = sinon.stub().yields()
+      this.rclient.set = sinon.stub().yields()
+      return (this.update = {
+        id: this.doc_id,
+        pathname: (this.pathname = 'pathname'),
+        newPathname: (this.newPathname = 'new-pathname')
+      })
+    })
+
+    describe('the document is cached in redis', function () {
+      beforeEach(function () {
+        this.RedisManager.getDoc = sinon
+          .stub()
+          .callsArgWith(2, null, 'lines', 'version')
+        this.ProjectHistoryRedisManager.queueRenameEntity = sinon
+          .stub()
+          .yields()
+        return this.RedisManager.renameDoc(
+          this.project_id,
+          this.doc_id,
+          this.userId,
+          this.update,
+          this.projectHistoryId,
+          this.callback
+        )
+      })
+
+      it('update the cached pathname', function () {
+        return this.rclient.set
+          .calledWith(`Pathname:${this.doc_id}`, this.newPathname)
+          .should.equal(true)
+      })
+
+      return it('should queue an update', function () {
+        return this.ProjectHistoryRedisManager.queueRenameEntity
+          .calledWithExactly(
+            this.project_id,
+            this.projectHistoryId,
+            'doc',
+            this.doc_id,
+            this.userId,
+            this.update,
+            this.callback
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('the document is not cached in redis', function () {
+      beforeEach(function () {
+        this.RedisManager.getDoc = sinon
+          .stub()
+          .callsArgWith(2, null, null, null)
+        this.ProjectHistoryRedisManager.queueRenameEntity = sinon
+          .stub()
+          .yields()
+        return this.RedisManager.renameDoc(
+          this.project_id,
+          this.doc_id,
+          this.userId,
+          this.update,
+          this.projectHistoryId,
+          this.callback
+        )
+      })
+
+      it('does not update the cached pathname', function () {
+        return this.rclient.set.called.should.equal(false)
+      })
+
+      return it('should queue an update', function () {
+        return this.ProjectHistoryRedisManager.queueRenameEntity
+          .calledWithExactly(
+            this.project_id,
+            this.projectHistoryId,
+            'doc',
+            this.doc_id,
+            this.userId,
+            this.update,
+            this.callback
+          )
+          .should.equal(true)
+      })
+    })
+
+    return describe('getDocVersion', function () {
+      beforeEach(function () {
+        return (this.version = 12345)
+      })
+
+      describe('when the document does not have a project history type set', function () {
+        beforeEach(function () {
+          this.rclient.mget = sinon
+            .stub()
+            .withArgs(
+              `DocVersion:${this.doc_id}`,
+              `ProjectHistoryType:${this.doc_id}`
+            )
+            .callsArgWith(2, null, [`${this.version}`])
+          return this.RedisManager.getDocVersion(this.doc_id, this.callback)
+        })
+
+        return it('should return the document version and an undefined history type', function () {
+          return this.callback
+            .calledWithExactly(null, this.version, undefined)
+            .should.equal(true)
+        })
+      })
+
+      return describe('when the document has a project history type set', function () {
+        beforeEach(function () {
+          this.rclient.mget = sinon
+            .stub()
+            .withArgs(
+              `DocVersion:${this.doc_id}`,
+              `ProjectHistoryType:${this.doc_id}`
+            )
+            .callsArgWith(2, null, [`${this.version}`, 'project-history'])
+          return this.RedisManager.getDocVersion(this.doc_id, this.callback)
+        })
+
+        return it('should return the document version and history type', function () {
+          return this.callback
+            .calledWithExactly(null, this.version, 'project-history')
+            .should.equal(true)
+        })
+      })
+    })
+  })
+})

+ 420 - 346
services/document-updater/test/unit/js/ShareJS/TextTransformTests.js

@@ -13,353 +13,427 @@
  * 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");
-require("chai").should();
-const RangesTracker = require("../../../../app/js/RangesTracker");
-
-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});
+const text = require('../../../../app/js/sharejs/types/text')
+require('chai').should()
+const RangesTracker = require('../../../../app/js/RangesTracker')
+
+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
+          }
         }
-        for (p = 0, end1 = SNAPSHOT.length-1, asc1 = end1 >= 0; asc1 ? p <= end1 : p >= end1; asc1 ? p++ : p--) {
-            var 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});
-            }
+        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--
+      ) {
+        var 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--) {
-            var 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});
-            }
+      }
+      for (
+        p = 0, end3 = SNAPSHOT.length - 1, asc3 = end3 >= 0;
+        asc3 ? p <= end3 : p >= end3;
+        asc3 ? p++ : p--
+      ) {
+        var 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 (var op1 of Array.from(OPS)) {
+          result.push(
+            (() => {
+              const result1 = []
+              for (const op2 of Array.from(OPS)) {
+                const op1_t = transform(op1, op2, 'left')
+                const op2_t = transform(op2, op1, 'right')
+
+                const rt12 = new RangesTracker()
+                const snapshot12 = applySnapshot(
+                  applySnapshot(SNAPSHOT, [op1]),
+                  op2_t
+                )
+                applyRanges(rt12, [op1])
+                applyRanges(rt12, op2_t)
+
+                const rt21 = new RangesTracker()
+                const snapshot21 = applySnapshot(
+                  applySnapshot(SNAPSHOT, [op2]),
+                  op1_t
+                )
+                applyRanges(rt21, [op2])
+                applyRanges(rt21, op1_t)
+
+                if (snapshot12 !== snapshot21) {
+                  console.error(
+                    { op1, op2, op1_t, op2_t, snapshot12, snapshot21 },
+                    'Ops are not consistent'
+                  )
+                  throw new Error('OT is inconsistent')
+                }
 
-        return (() => {
-            const result = [];
-            for (var op1 of Array.from(OPS)) {
-                result.push((() => {
-                    const result1 = [];
-                    for (const op2 of Array.from(OPS)) {
-                        const op1_t = transform(op1, op2, "left");
-                        const op2_t = transform(op2, op1, "right");
-                
-                        const rt12 = new RangesTracker();
-                        const snapshot12 = applySnapshot(applySnapshot(SNAPSHOT, [op1]), op2_t);
-                        applyRanges(rt12, [op1]);
-                        applyRanges(rt12, op2_t);
-                
-                        const rt21 = new RangesTracker();
-                        const snapshot21 = applySnapshot(applySnapshot(SNAPSHOT, [op2]), op1_t);
-                        applyRanges(rt21, [op2]);
-                        applyRanges(rt21, op1_t);
-                
-                        if (snapshot12 !== snapshot21) {
-                            console.error({op1, op2, op1_t, op2_t, 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, op1_t, op2_t, 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;
-        })();
-    }); });
-});
+                if (!commentsEqual(rt12.comments, rt21.comments)) {
+                  console.log(rt12.comments)
+                  console.log(rt21.comments)
+                  console.error(
+                    {
+                      op1,
+                      op2,
+                      op1_t,
+                      op2_t,
+                      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
+      })()
+    })
+  })
+})

+ 139 - 123
services/document-updater/test/unit/js/ShareJsDB/ShareJsDBTests.js

@@ -10,126 +10,142 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const {
-    expect
-} = 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 = {})
-		}
-	});
-
-		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(new Errors.NotFoundError("not found")).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);
-			});
-
-			return it("should return an empty array", function() {
-				return this.callback.calledWith(null, []).should.equal(true);
-			});
-		});
-		
-		describe("with a non empty range", function() {
-			beforeEach(function() {
-				this.start = 35;
-				this.end = 42;
-				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.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);
-        });
-    }); });
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = 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 = {})
+      }
+    })
+
+    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(new Errors.NotFoundError('not found'))
+          .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)
+      })
+
+      return it('should return an empty array', function () {
+        return this.callback.calledWith(null, []).should.equal(true)
+      })
+    })
+
+    describe('with a non empty range', function () {
+      beforeEach(function () {
+        this.start = 35
+        this.end = 42
+        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.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)
+      })
+    })
+  })
+})

+ 227 - 176
services/document-updater/test/unit/js/ShareJsUpdateManager/ShareJsUpdateManagerTests.js

@@ -9,179 +9,230 @@
  * 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 chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/ShareJsUpdateManager.js";
-const SandboxedModule = require('sandboxed-module');
-const crypto = require('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 }),
-				"redis-sharelatex" : { createClient: () => { return this.rclient = {auth() {}}; }
-			},
-				"logger-sharelatex": (this.logger = { log: sinon.stub() }),
-				"./RealTimeRedisManager": (this.RealTimeRedisManager = {}),
-				"./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(this.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);
-			});
-		});
-	});
-});
-
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/ShareJsUpdateManager.js'
+const SandboxedModule = require('sandboxed-module')
+const crypto = require('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 }),
+        'redis-sharelatex': {
+          createClient: () => {
+            return (this.rclient = { auth() {} })
+          }
+        },
+        'logger-sharelatex': (this.logger = { log: sinon.stub() }),
+        './RealTimeRedisManager': (this.RealTimeRedisManager = {}),
+        './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(this.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)
+      })
+    })
+  })
+})

+ 678 - 473
services/document-updater/test/unit/js/UpdateManager/UpdateManagerTests.js

@@ -11,477 +11,682 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const modulePath = "../../../../app/js/UpdateManager.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe("UpdateManager", function() {
-	beforeEach(function() {
-		let Profiler, Timer;
-		this.project_id = "project-id-123";
-		this.projectHistoryId = "history-id-123";
-		this.doc_id = "document-id-123";
-		this.callback = sinon.stub();
-		return this.UpdateManager = SandboxedModule.require(modulePath, { requires: {
-			"./LockManager" : (this.LockManager = {}),
-			"./RedisManager" : (this.RedisManager = {}),
-			"./RealTimeRedisManager" : (this.RealTimeRedisManager = {}),
-			"./ShareJsUpdateManager" : (this.ShareJsUpdateManager = {}),
-			"./HistoryManager" : (this.HistoryManager = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub() }),
-			"./Metrics": (this.Metrics = {
-				Timer: (Timer = (function() {
-					Timer = class Timer {
-						static initClass() {
-							this.prototype.done = sinon.stub();
-						}
-					};
-					Timer.initClass();
-					return Timer;
-				})())
-			}),
-			"settings-sharelatex": (this.Settings = {}),
-			"./DocumentManager": (this.DocumentManager = {}),
-			"./RangesManager": (this.RangesManager = {}),
-			"./SnapshotManager": (this.SnapshotManager = {}),
-			"./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;
-			})())
-		}
-	}
-		);
-	});
-
-	describe("processOutstandingUpdates", function() {
-		beforeEach(function() {
-			this.UpdateManager.fetchAndApplyUpdates = sinon.stub().callsArg(2);
-			return this.UpdateManager.processOutstandingUpdates(this.project_id, this.doc_id, this.callback);
-		});
-
-		it("should apply the updates", function() {
-			return this.UpdateManager.fetchAndApplyUpdates.calledWith(this.project_id, this.doc_id).should.equal(true);
-		});
-
-		it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-
-		return it("should time the execution", function() {
-			return this.Metrics.Timer.prototype.done.called.should.equal(true);
-		});
-	});
-
-	describe("processOutstandingUpdatesWithLock", function() {
-		describe("when the lock is free", function() {
-			beforeEach(function() {
-				this.LockManager.tryLock = sinon.stub().callsArgWith(1, null, true, (this.lockValue = "mock-lock-value"));
-				this.LockManager.releaseLock = sinon.stub().callsArg(2);
-				this.UpdateManager.continueProcessingUpdatesWithLock = sinon.stub().callsArg(2);
-				return this.UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2);
-			});
-
-			describe("successfully", function() {
-				beforeEach(function() {
-					return this.UpdateManager.processOutstandingUpdatesWithLock(this.project_id, this.doc_id, this.callback);
-				});
-
-				it("should acquire the lock", function() {
-					return this.LockManager.tryLock.calledWith(this.doc_id).should.equal(true);
-				});
-
-				it("should free the lock", function() {
-					return this.LockManager.releaseLock.calledWith(this.doc_id, this.lockValue).should.equal(true);
-				});
-
-				it("should process the outstanding updates", function() {
-					return this.UpdateManager.processOutstandingUpdates.calledWith(this.project_id, this.doc_id).should.equal(true);
-				});
-
-				it("should do everything with the lock acquired", function() {
-					this.UpdateManager.processOutstandingUpdates.calledAfter(this.LockManager.tryLock).should.equal(true);
-					return this.UpdateManager.processOutstandingUpdates.calledBefore(this.LockManager.releaseLock).should.equal(true);
-				});
-
-				it("should continue processing new updates that may have come in", function() {
-					return this.UpdateManager.continueProcessingUpdatesWithLock.calledWith(this.project_id, this.doc_id).should.equal(true);
-				});
-
-				return it("should return the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-
-			return describe("when processOutstandingUpdates returns an error", function() {
-				beforeEach(function() {
-					this.UpdateManager.processOutstandingUpdates = sinon.stub().callsArgWith(2, (this.error = new Error("Something went wrong")));
-					return this.UpdateManager.processOutstandingUpdatesWithLock(this.project_id, this.doc_id, this.callback);
-				});
-
-				it("should free the lock", function() {
-					return this.LockManager.releaseLock.calledWith(this.doc_id, this.lockValue).should.equal(true);
-				});
-
-				return it("should return the error in the callback", function() {
-					return this.callback.calledWith(this.error).should.equal(true);
-				});
-			});
-		});
-
-		return describe("when the lock is taken", function() {
-			beforeEach(function() {
-				this.LockManager.tryLock = sinon.stub().callsArgWith(1, null, false);
-				this.UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2);
-				return this.UpdateManager.processOutstandingUpdatesWithLock(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should return the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-
-			return it("should not process the updates", function() {
-				return this.UpdateManager.processOutstandingUpdates.called.should.equal(false);
-			});
-		});
-	});
-
-	describe("continueProcessingUpdatesWithLock", function() {
-		describe("when there are outstanding updates", function() {
-			beforeEach(function() {
-				this.RealTimeRedisManager.getUpdatesLength = sinon.stub().callsArgWith(1, null, 3);
-				this.UpdateManager.processOutstandingUpdatesWithLock = sinon.stub().callsArg(2);
-				return this.UpdateManager.continueProcessingUpdatesWithLock(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should process the outstanding updates", function() {
-				return this.UpdateManager.processOutstandingUpdatesWithLock.calledWith(this.project_id, this.doc_id).should.equal(true);
-			});
-
-			return it("should return the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		return describe("when there are no outstanding updates", function() {
-			beforeEach(function() {
-				this.RealTimeRedisManager.getUpdatesLength = sinon.stub().callsArgWith(1, null, 0);
-				this.UpdateManager.processOutstandingUpdatesWithLock = sinon.stub().callsArg(2);
-				return this.UpdateManager.continueProcessingUpdatesWithLock(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should not try to process the outstanding updates", function() {
-				return this.UpdateManager.processOutstandingUpdatesWithLock.called.should.equal(false);
-			});
-
-			return it("should return the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-	});
-
-	describe("fetchAndApplyUpdates", function() {
-		describe("with updates", function() {
-			beforeEach(function() {
-				this.updates = [{p: 1, t: "foo"}];
-				this.updatedDocLines = ["updated", "lines"];
-				this.version = 34;
-				this.RealTimeRedisManager.getPendingUpdatesForDoc = sinon.stub().callsArgWith(1, null, this.updates);
-				this.UpdateManager.applyUpdate = sinon.stub().callsArgWith(3, null, this.updatedDocLines, this.version);
-				return this.UpdateManager.fetchAndApplyUpdates(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should get the pending updates", function() {
-				return this.RealTimeRedisManager.getPendingUpdatesForDoc.calledWith(this.doc_id).should.equal(true);
-			});
-
-			it("should apply the updates", function() {
-				return Array.from(this.updates).map((update) =>
-					this.UpdateManager.applyUpdate
-						.calledWith(this.project_id, this.doc_id, update)
-						.should.equal(true));
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		return describe("when there are no updates", function() {
-			beforeEach(function() {
-				this.updates = [];
-				this.RealTimeRedisManager.getPendingUpdatesForDoc = sinon.stub().callsArgWith(1, null, this.updates);
-				this.UpdateManager.applyUpdate = sinon.stub();
-				this.RedisManager.setDocument = sinon.stub();
-				return this.UpdateManager.fetchAndApplyUpdates(this.project_id, this.doc_id, this.callback);
-			});
-
-			it("should not call applyUpdate", function() {
-				return this.UpdateManager.applyUpdate.called.should.equal(false);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-	});
-
-	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.doc_ops_length = sinon.stub();
-			this.project_ops_length = sinon.stub();
-			this.pathname = '/a/b/c.tex';
-			this.DocumentManager.getDoc = sinon.stub().yields(null, this.lines, this.version, this.ranges, this.pathname, this.projectHistoryId);
-			this.RangesManager.applyUpdate = sinon.stub().yields(null, this.updated_ranges, false);
-			this.ShareJsUpdateManager.applyUpdate = sinon.stub().yields(null, this.updatedDocLines, this.version, this.appliedOps);
-			this.RedisManager.updateDocument = sinon.stub().yields(null, this.doc_ops_length, this.project_ops_length);
-			this.RealTimeRedisManager.sendData = sinon.stub();
-			this.UpdateManager._addProjectHistoryMetadataToOps = sinon.stub();
-			return this.HistoryManager.recordAndFlushHistoryOps = sinon.stub().callsArg(5);
-		});
-
-		describe("normally", function() {
-			beforeEach(function() {
-				return this.UpdateManager.applyUpdate(this.project_id, this.doc_id, this.update, this.callback);
-			});
-
-			it("should apply the updates via ShareJS", function() {
-				return this.ShareJsUpdateManager.applyUpdate
-					.calledWith(this.project_id, this.doc_id, this.update, this.lines, this.version)
-					.should.equal(true);
-			});
-
-			it("should update the ranges", function() {
-				return 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() {
-				return this.RedisManager.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() {
-				return this.UpdateManager._addProjectHistoryMetadataToOps
-					.calledWith(this.appliedOps, this.pathname, this.projectHistoryId, this.lines)
-					.should.equal(true);
-			});
-
-			it("should push the applied ops into the history queue", function() {
-				return this.HistoryManager.recordAndFlushHistoryOps
-					.calledWith(this.project_id, this.doc_id, this.appliedOps, this.doc_ops_length, this.project_ops_length)
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		describe("with UTF-16 surrogate pairs in the update", function() {
-			beforeEach(function() {
-				this.update = {op: [{p: 42, i: "\uD835\uDC00"}]};
-				return this.UpdateManager.applyUpdate(this.project_id, this.doc_id, this.update, this.callback);
-			});
-
-			return it("should apply the update but with surrogate pairs removed", function() {
-				this.ShareJsUpdateManager.applyUpdate
-					.calledWith(this.project_id, this.doc_id, this.update)
-					.should.equal(true);
-
-				// \uFFFD is 'replacement character'
-				return this.update.op[0].i.should.equal("\uFFFD\uFFFD");
-			});
-		});
-
-		describe("with an error", function() {
-			beforeEach(function() {
-				this.error = new Error("something went wrong");
-				this.ShareJsUpdateManager.applyUpdate = sinon.stub().yields(this.error);
-				return this.UpdateManager.applyUpdate(this.project_id, this.doc_id, this.update, this.callback);
-			});
-
-			it("should call RealTimeRedisManager.sendData with the error", function() {
-				return this.RealTimeRedisManager.sendData
-					.calledWith({
-						project_id: this.project_id,
-						doc_id: this.doc_id,
-						error: this.error.message
-					})
-					.should.equal(true);
-			});
-
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		return describe("when ranges get collapsed", function() {
-			beforeEach(function() {
-				this.RangesManager.applyUpdate = sinon.stub().yields(null, this.updated_ranges, true);
-				this.SnapshotManager.recordSnapshot = sinon.stub().yields();
-				return this.UpdateManager.applyUpdate(this.project_id, this.doc_id, this.update, this.callback);
-			});
-
-			return it("should call SnapshotManager.recordSnapshot", function() {
-				return this.SnapshotManager.recordSnapshot
-					.calledWith(
-						this.project_id,
-						this.doc_id,
-						this.version,
-						this.pathname,
-						this.lines,
-						this.ranges
-					)
-					.should.equal(true);
-			});
-		});
-	});
-
-
-	describe("_addProjectHistoryMetadataToOps", function() { return it("should add projectHistoryId, pathname and doc_length metadata to the ops", function() {
-        const lines = [
-            'some',
-            'test',
-            'data'
-        ];
-        const appliedOps = [
-            { v: 42, op: [{i: "foo", p: 4}, { i: "bar", p: 6 }] },
-            { v: 45, op: [{d: "qux", p: 4}, { i: "bazbaz", p: 14 }] },
-            { v: 49, op: [{i: "penguin", p: 18}] }
-        ];
-        this.UpdateManager._addProjectHistoryMetadataToOps(appliedOps, this.pathname, this.projectHistoryId, lines);
-        return appliedOps.should.deep.equal([{
-            projectHistoryId: this.projectHistoryId,
-            v: 42,
-            op: [{i: "foo", p: 4}, { i: "bar", p: 6 }],
-            meta: {
-                pathname: this.pathname,
-                doc_length: 14
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const modulePath = '../../../../app/js/UpdateManager.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('UpdateManager', function () {
+  beforeEach(function () {
+    let Profiler, Timer
+    this.project_id = 'project-id-123'
+    this.projectHistoryId = 'history-id-123'
+    this.doc_id = 'document-id-123'
+    this.callback = sinon.stub()
+    return (this.UpdateManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './LockManager': (this.LockManager = {}),
+        './RedisManager': (this.RedisManager = {}),
+        './RealTimeRedisManager': (this.RealTimeRedisManager = {}),
+        './ShareJsUpdateManager': (this.ShareJsUpdateManager = {}),
+        './HistoryManager': (this.HistoryManager = {}),
+        'logger-sharelatex': (this.logger = { log: sinon.stub() }),
+        './Metrics': (this.Metrics = {
+          Timer: (Timer = (function () {
+            Timer = class Timer {
+              static initClass() {
+                this.prototype.done = sinon.stub()
+              }
             }
-        }, {
-            projectHistoryId: this.projectHistoryId,
-            v: 45,
-            op: [{d: "qux", p: 4}, { i: "bazbaz", p: 14 }],
-            meta: {
-                pathname: this.pathname,
-                doc_length: 20
-            } // 14 + 'foo' + 'bar'
-        }, {
-            projectHistoryId: this.projectHistoryId,
-            v: 49,
-            op: [{i: "penguin", p: 18}],
-            meta: {
-                pathname: this.pathname,
-                doc_length: 23
-            } // 14 - 'qux' + 'bazbaz'
-        }]);
-}); });
-
-	return describe("lockUpdatesAndDo", function() {
-		beforeEach(function() {
-			this.method = sinon.stub().callsArgWith(3, null, this.response_arg1);
-			this.callback = sinon.stub();
-			this.arg1 = "argument 1";
-			this.response_arg1 = "response argument 1";
-			this.lockValue = "mock-lock-value";
-			this.LockManager.getLock = sinon.stub().callsArgWith(1, null, this.lockValue);
-			return this.LockManager.releaseLock = sinon.stub().callsArg(2);
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.UpdateManager.continueProcessingUpdatesWithLock = sinon.stub();
-				this.UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2);
-				return this.UpdateManager.lockUpdatesAndDo(this.method, this.project_id, this.doc_id, this.arg1, this.callback);
-			});
-
-			it("should lock the doc", function() {
-				return this.LockManager.getLock
-					.calledWith(this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should process any outstanding updates", function() {
-				return this.UpdateManager.processOutstandingUpdates
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should call the method", function() {
-				return this.method
-					.calledWith(this.project_id, this.doc_id, this.arg1)
-					.should.equal(true);
-			});
-
-			it("should return the method response to the callback", function() {
-				return this.callback
-					.calledWith(null, this.response_arg1)
-					.should.equal(true);
-			});
-
-			it("should release the lock", function() {
-				return this.LockManager.releaseLock
-					.calledWith(this.doc_id, this.lockValue)
-					.should.equal(true);
-			});
-
-			return it("should continue processing updates", function() {
-				return this.UpdateManager.continueProcessingUpdatesWithLock
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-		});
-
-		describe("when processOutstandingUpdates returns an error", function() {
-			beforeEach(function() {
-				this.UpdateManager.processOutstandingUpdates = sinon.stub().callsArgWith(2, (this.error = new Error("Something went wrong")));
-				return this.UpdateManager.lockUpdatesAndDo(this.method, this.project_id, this.doc_id, this.arg1, this.callback);
-			});
-
-			it("should free the lock", function() {
-				return this.LockManager.releaseLock.calledWith(this.doc_id, this.lockValue).should.equal(true);
-			});
-
-			return it("should return the error in the callback", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		return describe("when the method returns an error", function() {
-			beforeEach(function() {
-				this.UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2);
-				this.method = sinon.stub().callsArgWith(3, (this.error = new Error("something went wrong")), this.response_arg1);
-				return this.UpdateManager.lockUpdatesAndDo(this.method, this.project_id, this.doc_id, this.arg1, this.callback);
-			});
-
-			it("should free the lock", function() {
-				return this.LockManager.releaseLock.calledWith(this.doc_id, this.lockValue).should.equal(true);
-			});
-
-			return it("should return the error in the callback", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-	});
-});
-
-
-
+            Timer.initClass()
+            return Timer
+          })())
+        }),
+        'settings-sharelatex': (this.Settings = {}),
+        './DocumentManager': (this.DocumentManager = {}),
+        './RangesManager': (this.RangesManager = {}),
+        './SnapshotManager': (this.SnapshotManager = {}),
+        './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
+        })())
+      }
+    }))
+  })
+
+  describe('processOutstandingUpdates', function () {
+    beforeEach(function () {
+      this.UpdateManager.fetchAndApplyUpdates = sinon.stub().callsArg(2)
+      return this.UpdateManager.processOutstandingUpdates(
+        this.project_id,
+        this.doc_id,
+        this.callback
+      )
+    })
+
+    it('should apply the updates', function () {
+      return this.UpdateManager.fetchAndApplyUpdates
+        .calledWith(this.project_id, this.doc_id)
+        .should.equal(true)
+    })
+
+    it('should call the callback', function () {
+      return this.callback.called.should.equal(true)
+    })
+
+    return it('should time the execution', function () {
+      return this.Metrics.Timer.prototype.done.called.should.equal(true)
+    })
+  })
+
+  describe('processOutstandingUpdatesWithLock', function () {
+    describe('when the lock is free', function () {
+      beforeEach(function () {
+        this.LockManager.tryLock = sinon
+          .stub()
+          .callsArgWith(1, null, true, (this.lockValue = 'mock-lock-value'))
+        this.LockManager.releaseLock = sinon.stub().callsArg(2)
+        this.UpdateManager.continueProcessingUpdatesWithLock = sinon
+          .stub()
+          .callsArg(2)
+        return (this.UpdateManager.processOutstandingUpdates = sinon
+          .stub()
+          .callsArg(2))
+      })
+
+      describe('successfully', function () {
+        beforeEach(function () {
+          return this.UpdateManager.processOutstandingUpdatesWithLock(
+            this.project_id,
+            this.doc_id,
+            this.callback
+          )
+        })
+
+        it('should acquire the lock', function () {
+          return this.LockManager.tryLock
+            .calledWith(this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should free the lock', function () {
+          return this.LockManager.releaseLock
+            .calledWith(this.doc_id, this.lockValue)
+            .should.equal(true)
+        })
+
+        it('should process the outstanding updates', function () {
+          return this.UpdateManager.processOutstandingUpdates
+            .calledWith(this.project_id, this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should do everything with the lock acquired', function () {
+          this.UpdateManager.processOutstandingUpdates
+            .calledAfter(this.LockManager.tryLock)
+            .should.equal(true)
+          return this.UpdateManager.processOutstandingUpdates
+            .calledBefore(this.LockManager.releaseLock)
+            .should.equal(true)
+        })
+
+        it('should continue processing new updates that may have come in', function () {
+          return this.UpdateManager.continueProcessingUpdatesWithLock
+            .calledWith(this.project_id, this.doc_id)
+            .should.equal(true)
+        })
+
+        return it('should return the callback', function () {
+          return this.callback.called.should.equal(true)
+        })
+      })
+
+      return describe('when processOutstandingUpdates returns an error', function () {
+        beforeEach(function () {
+          this.UpdateManager.processOutstandingUpdates = sinon
+            .stub()
+            .callsArgWith(2, (this.error = new Error('Something went wrong')))
+          return this.UpdateManager.processOutstandingUpdatesWithLock(
+            this.project_id,
+            this.doc_id,
+            this.callback
+          )
+        })
+
+        it('should free the lock', function () {
+          return this.LockManager.releaseLock
+            .calledWith(this.doc_id, this.lockValue)
+            .should.equal(true)
+        })
+
+        return it('should return the error in the callback', function () {
+          return this.callback.calledWith(this.error).should.equal(true)
+        })
+      })
+    })
+
+    return describe('when the lock is taken', function () {
+      beforeEach(function () {
+        this.LockManager.tryLock = sinon.stub().callsArgWith(1, null, false)
+        this.UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2)
+        return this.UpdateManager.processOutstandingUpdatesWithLock(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should return the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+
+      return it('should not process the updates', function () {
+        return this.UpdateManager.processOutstandingUpdates.called.should.equal(
+          false
+        )
+      })
+    })
+  })
+
+  describe('continueProcessingUpdatesWithLock', function () {
+    describe('when there are outstanding updates', function () {
+      beforeEach(function () {
+        this.RealTimeRedisManager.getUpdatesLength = sinon
+          .stub()
+          .callsArgWith(1, null, 3)
+        this.UpdateManager.processOutstandingUpdatesWithLock = sinon
+          .stub()
+          .callsArg(2)
+        return this.UpdateManager.continueProcessingUpdatesWithLock(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should process the outstanding updates', function () {
+        return this.UpdateManager.processOutstandingUpdatesWithLock
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('should return the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    return describe('when there are no outstanding updates', function () {
+      beforeEach(function () {
+        this.RealTimeRedisManager.getUpdatesLength = sinon
+          .stub()
+          .callsArgWith(1, null, 0)
+        this.UpdateManager.processOutstandingUpdatesWithLock = sinon
+          .stub()
+          .callsArg(2)
+        return this.UpdateManager.continueProcessingUpdatesWithLock(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should not try to process the outstanding updates', function () {
+        return this.UpdateManager.processOutstandingUpdatesWithLock.called.should.equal(
+          false
+        )
+      })
+
+      return it('should return the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+  })
+
+  describe('fetchAndApplyUpdates', function () {
+    describe('with updates', function () {
+      beforeEach(function () {
+        this.updates = [{ p: 1, t: 'foo' }]
+        this.updatedDocLines = ['updated', 'lines']
+        this.version = 34
+        this.RealTimeRedisManager.getPendingUpdatesForDoc = sinon
+          .stub()
+          .callsArgWith(1, null, this.updates)
+        this.UpdateManager.applyUpdate = sinon
+          .stub()
+          .callsArgWith(3, null, this.updatedDocLines, this.version)
+        return this.UpdateManager.fetchAndApplyUpdates(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the pending updates', function () {
+        return this.RealTimeRedisManager.getPendingUpdatesForDoc
+          .calledWith(this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should apply the updates', function () {
+        return Array.from(this.updates).map((update) =>
+          this.UpdateManager.applyUpdate
+            .calledWith(this.project_id, this.doc_id, update)
+            .should.equal(true)
+        )
+      })
+
+      return it('should call the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    return describe('when there are no updates', function () {
+      beforeEach(function () {
+        this.updates = []
+        this.RealTimeRedisManager.getPendingUpdatesForDoc = sinon
+          .stub()
+          .callsArgWith(1, null, this.updates)
+        this.UpdateManager.applyUpdate = sinon.stub()
+        this.RedisManager.setDocument = sinon.stub()
+        return this.UpdateManager.fetchAndApplyUpdates(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should not call applyUpdate', function () {
+        return this.UpdateManager.applyUpdate.called.should.equal(false)
+      })
+
+      return it('should call the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+  })
+
+  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.doc_ops_length = sinon.stub()
+      this.project_ops_length = sinon.stub()
+      this.pathname = '/a/b/c.tex'
+      this.DocumentManager.getDoc = sinon
+        .stub()
+        .yields(
+          null,
+          this.lines,
+          this.version,
+          this.ranges,
+          this.pathname,
+          this.projectHistoryId
+        )
+      this.RangesManager.applyUpdate = sinon
+        .stub()
+        .yields(null, this.updated_ranges, false)
+      this.ShareJsUpdateManager.applyUpdate = sinon
+        .stub()
+        .yields(null, this.updatedDocLines, this.version, this.appliedOps)
+      this.RedisManager.updateDocument = sinon
+        .stub()
+        .yields(null, this.doc_ops_length, this.project_ops_length)
+      this.RealTimeRedisManager.sendData = sinon.stub()
+      this.UpdateManager._addProjectHistoryMetadataToOps = sinon.stub()
+      return (this.HistoryManager.recordAndFlushHistoryOps = sinon
+        .stub()
+        .callsArg(5))
+    })
+
+    describe('normally', function () {
+      beforeEach(function () {
+        return this.UpdateManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.update,
+          this.callback
+        )
+      })
+
+      it('should apply the updates via ShareJS', function () {
+        return this.ShareJsUpdateManager.applyUpdate
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.update,
+            this.lines,
+            this.version
+          )
+          .should.equal(true)
+      })
+
+      it('should update the ranges', function () {
+        return 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 () {
+        return this.RedisManager.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 () {
+        return this.UpdateManager._addProjectHistoryMetadataToOps
+          .calledWith(
+            this.appliedOps,
+            this.pathname,
+            this.projectHistoryId,
+            this.lines
+          )
+          .should.equal(true)
+      })
+
+      it('should push the applied ops into the history queue', function () {
+        return this.HistoryManager.recordAndFlushHistoryOps
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.appliedOps,
+            this.doc_ops_length,
+            this.project_ops_length
+          )
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    describe('with UTF-16 surrogate pairs in the update', function () {
+      beforeEach(function () {
+        this.update = { op: [{ p: 42, i: '\uD835\uDC00' }] }
+        return this.UpdateManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.update,
+          this.callback
+        )
+      })
+
+      return it('should apply the update but with surrogate pairs removed', function () {
+        this.ShareJsUpdateManager.applyUpdate
+          .calledWith(this.project_id, this.doc_id, this.update)
+          .should.equal(true)
+
+        // \uFFFD is 'replacement character'
+        return this.update.op[0].i.should.equal('\uFFFD\uFFFD')
+      })
+    })
+
+    describe('with an error', function () {
+      beforeEach(function () {
+        this.error = new Error('something went wrong')
+        this.ShareJsUpdateManager.applyUpdate = sinon.stub().yields(this.error)
+        return this.UpdateManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.update,
+          this.callback
+        )
+      })
+
+      it('should call RealTimeRedisManager.sendData with the error', function () {
+        return this.RealTimeRedisManager.sendData
+          .calledWith({
+            project_id: this.project_id,
+            doc_id: this.doc_id,
+            error: this.error.message
+          })
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the error', function () {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    return describe('when ranges get collapsed', function () {
+      beforeEach(function () {
+        this.RangesManager.applyUpdate = sinon
+          .stub()
+          .yields(null, this.updated_ranges, true)
+        this.SnapshotManager.recordSnapshot = sinon.stub().yields()
+        return this.UpdateManager.applyUpdate(
+          this.project_id,
+          this.doc_id,
+          this.update,
+          this.callback
+        )
+      })
+
+      return it('should call SnapshotManager.recordSnapshot', function () {
+        return this.SnapshotManager.recordSnapshot
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.version,
+            this.pathname,
+            this.lines,
+            this.ranges
+          )
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('_addProjectHistoryMetadataToOps', function () {
+    return it('should add projectHistoryId, pathname and doc_length metadata to the ops', function () {
+      const lines = ['some', 'test', 'data']
+      const appliedOps = [
+        {
+          v: 42,
+          op: [
+            { i: 'foo', p: 4 },
+            { i: 'bar', p: 6 }
+          ]
+        },
+        {
+          v: 45,
+          op: [
+            { d: 'qux', p: 4 },
+            { i: 'bazbaz', p: 14 }
+          ]
+        },
+        { v: 49, op: [{ i: 'penguin', p: 18 }] }
+      ]
+      this.UpdateManager._addProjectHistoryMetadataToOps(
+        appliedOps,
+        this.pathname,
+        this.projectHistoryId,
+        lines
+      )
+      return appliedOps.should.deep.equal([
+        {
+          projectHistoryId: this.projectHistoryId,
+          v: 42,
+          op: [
+            { 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 }
+          ],
+          meta: {
+            pathname: this.pathname,
+            doc_length: 20
+          } // 14 + 'foo' + 'bar'
+        },
+        {
+          projectHistoryId: this.projectHistoryId,
+          v: 49,
+          op: [{ i: 'penguin', p: 18 }],
+          meta: {
+            pathname: this.pathname,
+            doc_length: 23
+          } // 14 - 'qux' + 'bazbaz'
+        }
+      ])
+    })
+  })
+
+  return describe('lockUpdatesAndDo', function () {
+    beforeEach(function () {
+      this.method = sinon.stub().callsArgWith(3, null, this.response_arg1)
+      this.callback = sinon.stub()
+      this.arg1 = 'argument 1'
+      this.response_arg1 = 'response argument 1'
+      this.lockValue = 'mock-lock-value'
+      this.LockManager.getLock = sinon
+        .stub()
+        .callsArgWith(1, null, this.lockValue)
+      return (this.LockManager.releaseLock = sinon.stub().callsArg(2))
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.UpdateManager.continueProcessingUpdatesWithLock = sinon.stub()
+        this.UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2)
+        return this.UpdateManager.lockUpdatesAndDo(
+          this.method,
+          this.project_id,
+          this.doc_id,
+          this.arg1,
+          this.callback
+        )
+      })
+
+      it('should lock the doc', function () {
+        return this.LockManager.getLock
+          .calledWith(this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should process any outstanding updates', function () {
+        return this.UpdateManager.processOutstandingUpdates
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should call the method', function () {
+        return this.method
+          .calledWith(this.project_id, this.doc_id, this.arg1)
+          .should.equal(true)
+      })
+
+      it('should return the method response to the callback', function () {
+        return this.callback
+          .calledWith(null, this.response_arg1)
+          .should.equal(true)
+      })
+
+      it('should release the lock', function () {
+        return this.LockManager.releaseLock
+          .calledWith(this.doc_id, this.lockValue)
+          .should.equal(true)
+      })
+
+      return it('should continue processing updates', function () {
+        return this.UpdateManager.continueProcessingUpdatesWithLock
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+    })
+
+    describe('when processOutstandingUpdates returns an error', function () {
+      beforeEach(function () {
+        this.UpdateManager.processOutstandingUpdates = sinon
+          .stub()
+          .callsArgWith(2, (this.error = new Error('Something went wrong')))
+        return this.UpdateManager.lockUpdatesAndDo(
+          this.method,
+          this.project_id,
+          this.doc_id,
+          this.arg1,
+          this.callback
+        )
+      })
+
+      it('should free the lock', function () {
+        return this.LockManager.releaseLock
+          .calledWith(this.doc_id, this.lockValue)
+          .should.equal(true)
+      })
+
+      return it('should return the error in the callback', function () {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    return describe('when the method returns an error', function () {
+      beforeEach(function () {
+        this.UpdateManager.processOutstandingUpdates = sinon.stub().callsArg(2)
+        this.method = sinon
+          .stub()
+          .callsArgWith(
+            3,
+            (this.error = new Error('something went wrong')),
+            this.response_arg1
+          )
+        return this.UpdateManager.lockUpdatesAndDo(
+          this.method,
+          this.project_id,
+          this.doc_id,
+          this.arg1,
+          this.callback
+        )
+      })
+
+      it('should free the lock', function () {
+        return this.LockManager.releaseLock
+          .calledWith(this.doc_id, this.lockValue)
+          .should.equal(true)
+      })
+
+      return it('should return the error in the callback', function () {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+  })
+})