فهرست منبع

prettier: convert test/acceptance decaffeinated files to Prettier format

Tim Alby 6 سال پیش
والد
کامیت
d15738cb98

+ 782 - 492
services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js

@@ -11,495 +11,785 @@
  * 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");
-chai.should();
-const {
-    expect
-} = chai;
-const async = require("async");
-const Settings = require('settings-sharelatex');
-const rclient_history = require("redis-sharelatex").createClient(Settings.redis.history); // note: this is track changes, not project-history
-const rclient_project_history = require("redis-sharelatex").createClient(Settings.redis.project_history);
-const rclient_du = require("redis-sharelatex").createClient(Settings.redis.documentupdater);
-const Keys = Settings.redis.documentupdater.key_schema;
-const HistoryKeys = Settings.redis.history.key_schema;
-const ProjectHistoryKeys = Settings.redis.project_history.key_schema;
-
-const MockTrackChangesApi = require("./helpers/MockTrackChangesApi");
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
-
-describe("Applying updates to a doc", function() {
-	before(function(done) {
-		this.lines = ["one", "two", "three"];
-		this.version = 42;
-		this.update = {
-			doc: this.doc_id,
-			op: [{
-				i: "one and a half\n",
-				p: 4
-			}],
-			v: this.version
-		};
-		this.result = ["one", "one and a half", "two", "three"];
-		return DocUpdaterApp.ensureRunning(done);
-	});
-
-	describe("when the document is not loaded", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			sinon.spy(MockWebApi, "getDocument");
-			this.startTime = Date.now();
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, this.update, (error) => {
-				if (error != null) { throw error; }
-				return setTimeout(done, 200);
-			});
-			return null;
-		});
-
-		after(function() { return MockWebApi.getDocument.restore(); });
-
-		it("should load the document from the web API", function() {
-			return MockWebApi.getDocument
-				.calledWith(this.project_id, this.doc_id)
-				.should.equal(true);
-		});
-
-		it("should update the doc", function(done) {
-			DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				doc.lines.should.deep.equal(this.result);
-				return done();
-			});
-			return null;
-		});
-
-		it("should push the applied updates to the track changes api", function(done) {
-			rclient_history.lrange(HistoryKeys.uncompressedHistoryOps({doc_id: this.doc_id}), 0, -1, (error, updates) => {
-				if (error != null) { throw error; }
-				JSON.parse(updates[0]).op.should.deep.equal(this.update.op);
-				return rclient_history.sismember(HistoryKeys.docsWithHistoryOps({project_id: this.project_id}), this.doc_id, (error, result) => {
-					if (error != null) { throw error; }
-					result.should.equal(1);
-					return done();
-				});
-			});
-			return null;
-		});
-
-		it("should push the applied updates to the project history changes api", function(done) {
-			rclient_project_history.lrange(ProjectHistoryKeys.projectHistoryOps({project_id: this.project_id}), 0, -1, (error, updates) => {
-				if (error != null) { throw error; }
-				JSON.parse(updates[0]).op.should.deep.equal(this.update.op);
-				return done();
-			});
-			return null;
-		});
-
-		it("should set the first op timestamp", function(done) {
-			rclient_project_history.get(ProjectHistoryKeys.projectHistoryFirstOpTimestamp({project_id: this.project_id}), (error, result) => {
-				if (error != null) { throw error; }
-				result.should.be.within(this.startTime, Date.now());
-				this.firstOpTimestamp = result;
-				return done();
-			});
-			return null;
-		});
-
-		return describe("when sending another update", function() {
-			before(function(done) {
-				this.timeout = 10000;
-				this.second_update = Object.create(this.update);
-				this.second_update.v = this.version + 1;
-				DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, this.second_update, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 200);
-				});
-				return null;
-			});
-
-			return it("should not change the first op timestamp", function(done) {
-				rclient_project_history.get(ProjectHistoryKeys.projectHistoryFirstOpTimestamp({project_id: this.project_id}), (error, result) => {
-					if (error != null) { throw error; }
-					result.should.equal(this.firstOpTimestamp);
-					return done();
-				});
-				return null;
-			});
-		});
-	});
-
-	describe("when the document is loaded", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-				if (error != null) { throw error; }
-				sinon.spy(MockWebApi, "getDocument");
-				return DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, this.update, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 200);
-				});
-			});
-			return null;
-		});
-
-		after(function() { return MockWebApi.getDocument.restore(); });
-
-		it("should not need to call the web api", function() { return MockWebApi.getDocument.called.should.equal(false); });
-
-		it("should update the doc", function(done) {
-			DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				doc.lines.should.deep.equal(this.result);
-				return done();
-			});
-			return null;
-		});
-
-		it("should push the applied updates to the track changes api", function(done) {
-			rclient_history.lrange(HistoryKeys.uncompressedHistoryOps({doc_id: this.doc_id}), 0, -1, (error, updates) => {
-				JSON.parse(updates[0]).op.should.deep.equal(this.update.op);
-				return rclient_history.sismember(HistoryKeys.docsWithHistoryOps({project_id: this.project_id}), this.doc_id, (error, result) => {
-					result.should.equal(1);
-					return done();
-				});
-			});
-			return null;
-		});
-
-		return it("should push the applied updates to the project history changes api", function(done) {
-			rclient_project_history.lrange(ProjectHistoryKeys.projectHistoryOps({project_id: this.project_id}), 0, -1, (error, updates) => {
-				JSON.parse(updates[0]).op.should.deep.equal(this.update.op);
-				return done();
-			});
-			return null;
-		});
-	});
-
-	describe("when the document is loaded and is using project-history only", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version, projectHistoryType: 'project-history'});
-			DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-				if (error != null) { throw error; }
-				sinon.spy(MockWebApi, "getDocument");
-				return DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, this.update, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 200);
-				});
-			});
-			return null;
-		});
-
-		after(function() { return MockWebApi.getDocument.restore(); });
-
-		it("should update the doc", function(done) {
-			DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				doc.lines.should.deep.equal(this.result);
-				return done();
-			});
-			return null;
-		});
-
-		it("should not push any applied updates to the track changes api", function(done) {
-			rclient_history.lrange(HistoryKeys.uncompressedHistoryOps({doc_id: this.doc_id}), 0, -1, (error, updates) => {
-				updates.length.should.equal(0);
-				return done();
-			});
-			return null;
-		});
-
-		return it("should push the applied updates to the project history changes api", function(done) {
-			rclient_project_history.lrange(ProjectHistoryKeys.projectHistoryOps({project_id: this.project_id}), 0, -1, (error, updates) => {
-				JSON.parse(updates[0]).op.should.deep.equal(this.update.op);
-				return done();
-			});
-			return null;
-		});
-	});
-
-	describe("when the document has been deleted", function() {
-		describe("when the ops come in a single linear order", function() {
-			before(function(done) {
-				[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-				const lines = ["", "", ""];
-				MockWebApi.insertDoc(this.project_id, this.doc_id, {lines, version: 0});
-				this.updates = [
-					{ doc_id: this.doc_id, v: 0,  op: [{i: "h", p: 0} ] },
-					{ doc_id: this.doc_id, v: 1,  op: [{i: "e", p: 1} ] },
-					{ doc_id: this.doc_id, v: 2,  op: [{i: "l", p: 2} ] },
-					{ doc_id: this.doc_id, v: 3,  op: [{i: "l", p: 3} ] },
-					{ doc_id: this.doc_id, v: 4,  op: [{i: "o", p: 4} ] },
-					{ doc_id: this.doc_id, v: 5,  op: [{i: " ", p: 5} ] },
-					{ doc_id: this.doc_id, v: 6,  op: [{i: "w", p: 6} ] },
-					{ doc_id: this.doc_id, v: 7,  op: [{i: "o", p: 7} ] },
-					{ doc_id: this.doc_id, v: 8,  op: [{i: "r", p: 8} ] },
-					{ doc_id: this.doc_id, v: 9,  op: [{i: "l", p: 9} ] },
-					{ doc_id: this.doc_id, v: 10, op: [{i: "d", p: 10}] }
-				];
-				this.my_result = ["hello world", "", ""];
-				return done();
-			});
-
-			it("should be able to continue applying updates when the project has been deleted", function(done) {
-				let update;
-				const actions = [];
-				for (update of Array.from(this.updates.slice(0,6))) {
-					(update => {
-						return actions.push(callback => DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, update, callback));
-					})(update);
-				}
-				actions.push(callback => DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback));
-				for (update of Array.from(this.updates.slice(6))) {
-					(update => {
-						return actions.push(callback => DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, update, callback));
-					})(update);
-				}
-
-				async.series(actions, error => {
-					if (error != null) { throw error; }
-					return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-						doc.lines.should.deep.equal(this.my_result);
-						return done();
-					});
-				});
-				return null;
-			});
-
-			it("should push the applied updates to the track changes api", function(done) {
-				rclient_history.lrange(HistoryKeys.uncompressedHistoryOps({doc_id: this.doc_id}), 0, -1, (error, updates) => {
-					updates = (Array.from(updates).map((u) => JSON.parse(u)));
-					for (let i = 0; i < this.updates.length; i++) {
-						const appliedUpdate = this.updates[i];
-						appliedUpdate.op.should.deep.equal(updates[i].op);
-					}
-
-					return rclient_history.sismember(HistoryKeys.docsWithHistoryOps({project_id: this.project_id}), this.doc_id, (error, result) => {
-						result.should.equal(1);
-						return done();
-					});
-				});
-				return null;
-			});
-
-			return it("should store the doc ops in the correct order", function(done) {
-				rclient_du.lrange(Keys.docOps({doc_id: this.doc_id}), 0, -1, (error, updates) => {
-					updates = (Array.from(updates).map((u) => JSON.parse(u)));
-					for (let i = 0; i < this.updates.length; i++) {
-						const appliedUpdate = this.updates[i];
-						appliedUpdate.op.should.deep.equal(updates[i].op);
-					}
-					return done();
-				});
-				return null;
-			});
-		});
-
-		return describe("when older ops come in after the delete", function() {
-			before(function(done) {
-				[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-				const lines = ["", "", ""];
-				MockWebApi.insertDoc(this.project_id, this.doc_id, {lines, version: 0});
-				this.updates = [
-					{ doc_id: this.doc_id, v: 0, op: [{i: "h", p: 0} ] },
-					{ doc_id: this.doc_id, v: 1, op: [{i: "e", p: 1} ] },
-					{ doc_id: this.doc_id, v: 2, op: [{i: "l", p: 2} ] },
-					{ doc_id: this.doc_id, v: 3, op: [{i: "l", p: 3} ] },
-					{ doc_id: this.doc_id, v: 4, op: [{i: "o", p: 4} ] },
-					{ doc_id: this.doc_id, v: 0, op: [{i: "world", p: 1} ] }
-				];
-				this.my_result = ["hello", "world", ""];
-				return done();
-			});
-
-			return it("should be able to continue applying updates when the project has been deleted", function(done) {
-				let update;
-				const actions = [];
-				for (update of Array.from(this.updates.slice(0,5))) {
-					(update => {
-						return actions.push(callback => DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, update, callback));
-					})(update);
-				}
-				actions.push(callback => DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback));
-				for (update of Array.from(this.updates.slice(5))) {
-					(update => {
-						return actions.push(callback => DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, update, callback));
-					})(update);
-				}
-
-				async.series(actions, error => {
-					if (error != null) { throw error; }
-					return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-						doc.lines.should.deep.equal(this.my_result);
-						return done();
-					});
-				});
-				return null;
-			});
-		});
-	});
-
-	describe("with a broken update", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			this.broken_update = { doc_id: this.doc_id, v: this.version, op: [{d: "not the correct content", p: 0} ] };
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-
-			DocUpdaterClient.subscribeToAppliedOps(this.messageCallback = sinon.stub());
-
-			DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, this.broken_update, (error) => {
-				if (error != null) { throw error; }
-				return setTimeout(done, 200);
-			});
-			return null;
-		});
-
-		it("should not update the doc", function(done) {
-			DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				doc.lines.should.deep.equal(this.lines);
-				return done();
-			});
-			return null;
-		});
-
-		return it("should send a message with an error", function() {
-			this.messageCallback.called.should.equal(true);
-			const [channel, message] = Array.from(this.messageCallback.args[0]);
-			channel.should.equal("applied-ops");
-			return JSON.parse(message).should.deep.include({
-				project_id: this.project_id,
-				doc_id: this.doc_id,
-				error:'Delete component does not match'
-			});
-	});
-});
-
-	describe("with enough updates to flush to the track changes api", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			const updates = [];
-			for (let v = 0; v <= 199; v++) { // Should flush after 100 ops
-				updates.push({
-					doc_id: this.doc_id,
-					op: [{i: v.toString(), p: 0}],
-					v
-				});
-			}
-
-			sinon.spy(MockTrackChangesApi, "flushDoc");
-
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: 0});
-
-			// Send updates in chunks to causes multiple flushes
-			const actions = [];
-			for (let i = 0; i <= 19; i++) {
-				(i => {
-					return actions.push(cb => {
-						return DocUpdaterClient.sendUpdates(this.project_id, this.doc_id, updates.slice(i*10, (i+1)*10), cb);
-					});
-				})(i);
-			}
-			async.series(actions, error => {
-				if (error != null) { throw error; }
-				return setTimeout(done, 2000);
-			});
-			return null;
-		});
-
-		after(function() { return MockTrackChangesApi.flushDoc.restore(); });
-
-		return it("should flush the doc twice", function() { return MockTrackChangesApi.flushDoc.calledTwice.should.equal(true); });
-	});
-
-	describe("when there is no version in Mongo", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {
-				lines: this.lines
-			});
-
-			const update = {
-				doc: this.doc_id,
-				op: this.update.op,
-				v: 0
-			};
-			DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, update, (error) => {
-				if (error != null) { throw error; }
-				return setTimeout(done, 200);
-			});
-			return null;
-		});
-
-		return it("should update the doc (using version = 0)", function(done) {
-			DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				doc.lines.should.deep.equal(this.result);
-				return done();
-			});
-			return null;
-		});
-	});
-
-	return describe("when the sending duplicate ops", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-
-			DocUpdaterClient.subscribeToAppliedOps(this.messageCallback = sinon.stub());
-
-			// One user delete 'one', the next turns it into 'once'. The second becomes a NOP.
-			DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, {
-				doc: this.doc_id,
-				op: [{
-					i: "one and a half\n",
-					p: 4
-				}],
-				v: this.version,
-				meta: {
-					source: "ikHceq3yfAdQYzBo4-xZ"
-				}
-			}, error => {
-				if (error != null) { throw error; }
-				return setTimeout(() => {
-					return DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, {
-						doc: this.doc_id,
-						op: [{
-							i: "one and a half\n",
-							p: 4
-						}],
-						v: this.version,
-						dupIfSource: ["ikHceq3yfAdQYzBo4-xZ"],
-						meta: {
-							source: "ikHceq3yfAdQYzBo4-xZ"
-						}
-					}, error => {
-						if (error != null) { throw error; }
-						return setTimeout(done, 200);
-					});
-				}
-				, 200);
-			});
-			return null;
-		});
-
-		it("should update the doc", function(done) {
-			DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				doc.lines.should.deep.equal(this.result);
-				return done();
-			});
-			return null;
-		});
-
-		return it("should return a message about duplicate ops", function() {
-			this.messageCallback.calledTwice.should.equal(true);
-			this.messageCallback.args[0][0].should.equal("applied-ops");
-			expect(JSON.parse(this.messageCallback.args[0][1]).op.dup).to.be.undefined;
-			this.messageCallback.args[1][0].should.equal("applied-ops");
-			return expect(JSON.parse(this.messageCallback.args[1][1]).op.dup).to.equal(true);
-		});
-	});
-});
-
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+const { expect } = chai
+const async = require('async')
+const Settings = require('settings-sharelatex')
+const rclient_history = require('redis-sharelatex').createClient(
+  Settings.redis.history
+) // note: this is track changes, not project-history
+const rclient_project_history = require('redis-sharelatex').createClient(
+  Settings.redis.project_history
+)
+const rclient_du = require('redis-sharelatex').createClient(
+  Settings.redis.documentupdater
+)
+const Keys = Settings.redis.documentupdater.key_schema
+const HistoryKeys = Settings.redis.history.key_schema
+const ProjectHistoryKeys = Settings.redis.project_history.key_schema
+
+const MockTrackChangesApi = require('./helpers/MockTrackChangesApi')
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
+
+describe('Applying updates to a doc', function () {
+  before(function (done) {
+    this.lines = ['one', 'two', 'three']
+    this.version = 42
+    this.update = {
+      doc: this.doc_id,
+      op: [
+        {
+          i: 'one and a half\n',
+          p: 4
+        }
+      ],
+      v: this.version
+    }
+    this.result = ['one', 'one and a half', 'two', 'three']
+    return DocUpdaterApp.ensureRunning(done)
+  })
+
+  describe('when the document is not loaded', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      sinon.spy(MockWebApi, 'getDocument')
+      this.startTime = Date.now()
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      DocUpdaterClient.sendUpdate(
+        this.project_id,
+        this.doc_id,
+        this.update,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(done, 200)
+        }
+      )
+      return null
+    })
+
+    after(function () {
+      return MockWebApi.getDocument.restore()
+    })
+
+    it('should load the document from the web API', function () {
+      return MockWebApi.getDocument
+        .calledWith(this.project_id, this.doc_id)
+        .should.equal(true)
+    })
+
+    it('should update the doc', function (done) {
+      DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          doc.lines.should.deep.equal(this.result)
+          return done()
+        }
+      )
+      return null
+    })
+
+    it('should push the applied updates to the track changes api', function (done) {
+      rclient_history.lrange(
+        HistoryKeys.uncompressedHistoryOps({ doc_id: this.doc_id }),
+        0,
+        -1,
+        (error, updates) => {
+          if (error != null) {
+            throw error
+          }
+          JSON.parse(updates[0]).op.should.deep.equal(this.update.op)
+          return rclient_history.sismember(
+            HistoryKeys.docsWithHistoryOps({ project_id: this.project_id }),
+            this.doc_id,
+            (error, result) => {
+              if (error != null) {
+                throw error
+              }
+              result.should.equal(1)
+              return done()
+            }
+          )
+        }
+      )
+      return null
+    })
+
+    it('should push the applied updates to the project history changes api', function (done) {
+      rclient_project_history.lrange(
+        ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
+        0,
+        -1,
+        (error, updates) => {
+          if (error != null) {
+            throw error
+          }
+          JSON.parse(updates[0]).op.should.deep.equal(this.update.op)
+          return done()
+        }
+      )
+      return null
+    })
+
+    it('should set the first op timestamp', function (done) {
+      rclient_project_history.get(
+        ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
+          project_id: this.project_id
+        }),
+        (error, result) => {
+          if (error != null) {
+            throw error
+          }
+          result.should.be.within(this.startTime, Date.now())
+          this.firstOpTimestamp = result
+          return done()
+        }
+      )
+      return null
+    })
+
+    return describe('when sending another update', function () {
+      before(function (done) {
+        this.timeout = 10000
+        this.second_update = Object.create(this.update)
+        this.second_update.v = this.version + 1
+        DocUpdaterClient.sendUpdate(
+          this.project_id,
+          this.doc_id,
+          this.second_update,
+          (error) => {
+            if (error != null) {
+              throw error
+            }
+            return setTimeout(done, 200)
+          }
+        )
+        return null
+      })
+
+      return it('should not change the first op timestamp', function (done) {
+        rclient_project_history.get(
+          ProjectHistoryKeys.projectHistoryFirstOpTimestamp({
+            project_id: this.project_id
+          }),
+          (error, result) => {
+            if (error != null) {
+              throw error
+            }
+            result.should.equal(this.firstOpTimestamp)
+            return done()
+          }
+        )
+        return null
+      })
+    })
+  })
+
+  describe('when the document is loaded', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, (error) => {
+        if (error != null) {
+          throw error
+        }
+        sinon.spy(MockWebApi, 'getDocument')
+        return DocUpdaterClient.sendUpdate(
+          this.project_id,
+          this.doc_id,
+          this.update,
+          (error) => {
+            if (error != null) {
+              throw error
+            }
+            return setTimeout(done, 200)
+          }
+        )
+      })
+      return null
+    })
+
+    after(function () {
+      return MockWebApi.getDocument.restore()
+    })
+
+    it('should not need to call the web api', function () {
+      return MockWebApi.getDocument.called.should.equal(false)
+    })
+
+    it('should update the doc', function (done) {
+      DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          doc.lines.should.deep.equal(this.result)
+          return done()
+        }
+      )
+      return null
+    })
+
+    it('should push the applied updates to the track changes api', function (done) {
+      rclient_history.lrange(
+        HistoryKeys.uncompressedHistoryOps({ doc_id: this.doc_id }),
+        0,
+        -1,
+        (error, updates) => {
+          JSON.parse(updates[0]).op.should.deep.equal(this.update.op)
+          return rclient_history.sismember(
+            HistoryKeys.docsWithHistoryOps({ project_id: this.project_id }),
+            this.doc_id,
+            (error, result) => {
+              result.should.equal(1)
+              return done()
+            }
+          )
+        }
+      )
+      return null
+    })
+
+    return it('should push the applied updates to the project history changes api', function (done) {
+      rclient_project_history.lrange(
+        ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
+        0,
+        -1,
+        (error, updates) => {
+          JSON.parse(updates[0]).op.should.deep.equal(this.update.op)
+          return done()
+        }
+      )
+      return null
+    })
+  })
+
+  describe('when the document is loaded and is using project-history only', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version,
+        projectHistoryType: 'project-history'
+      })
+      DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, (error) => {
+        if (error != null) {
+          throw error
+        }
+        sinon.spy(MockWebApi, 'getDocument')
+        return DocUpdaterClient.sendUpdate(
+          this.project_id,
+          this.doc_id,
+          this.update,
+          (error) => {
+            if (error != null) {
+              throw error
+            }
+            return setTimeout(done, 200)
+          }
+        )
+      })
+      return null
+    })
+
+    after(function () {
+      return MockWebApi.getDocument.restore()
+    })
+
+    it('should update the doc', function (done) {
+      DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          doc.lines.should.deep.equal(this.result)
+          return done()
+        }
+      )
+      return null
+    })
+
+    it('should not push any applied updates to the track changes api', function (done) {
+      rclient_history.lrange(
+        HistoryKeys.uncompressedHistoryOps({ doc_id: this.doc_id }),
+        0,
+        -1,
+        (error, updates) => {
+          updates.length.should.equal(0)
+          return done()
+        }
+      )
+      return null
+    })
+
+    return it('should push the applied updates to the project history changes api', function (done) {
+      rclient_project_history.lrange(
+        ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
+        0,
+        -1,
+        (error, updates) => {
+          JSON.parse(updates[0]).op.should.deep.equal(this.update.op)
+          return done()
+        }
+      )
+      return null
+    })
+  })
+
+  describe('when the document has been deleted', function () {
+    describe('when the ops come in a single linear order', function () {
+      before(function (done) {
+        ;[this.project_id, this.doc_id] = Array.from([
+          DocUpdaterClient.randomId(),
+          DocUpdaterClient.randomId()
+        ])
+        const lines = ['', '', '']
+        MockWebApi.insertDoc(this.project_id, this.doc_id, {
+          lines,
+          version: 0
+        })
+        this.updates = [
+          { doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
+          { doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
+          { doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
+          { doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
+          { doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
+          { doc_id: this.doc_id, v: 5, op: [{ i: ' ', p: 5 }] },
+          { doc_id: this.doc_id, v: 6, op: [{ i: 'w', p: 6 }] },
+          { doc_id: this.doc_id, v: 7, op: [{ i: 'o', p: 7 }] },
+          { doc_id: this.doc_id, v: 8, op: [{ i: 'r', p: 8 }] },
+          { doc_id: this.doc_id, v: 9, op: [{ i: 'l', p: 9 }] },
+          { doc_id: this.doc_id, v: 10, op: [{ i: 'd', p: 10 }] }
+        ]
+        this.my_result = ['hello world', '', '']
+        return done()
+      })
+
+      it('should be able to continue applying updates when the project has been deleted', function (done) {
+        let update
+        const actions = []
+        for (update of Array.from(this.updates.slice(0, 6))) {
+          ;((update) => {
+            return actions.push((callback) =>
+              DocUpdaterClient.sendUpdate(
+                this.project_id,
+                this.doc_id,
+                update,
+                callback
+              )
+            )
+          })(update)
+        }
+        actions.push((callback) =>
+          DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback)
+        )
+        for (update of Array.from(this.updates.slice(6))) {
+          ;((update) => {
+            return actions.push((callback) =>
+              DocUpdaterClient.sendUpdate(
+                this.project_id,
+                this.doc_id,
+                update,
+                callback
+              )
+            )
+          })(update)
+        }
+
+        async.series(actions, (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.getDoc(
+            this.project_id,
+            this.doc_id,
+            (error, res, doc) => {
+              doc.lines.should.deep.equal(this.my_result)
+              return done()
+            }
+          )
+        })
+        return null
+      })
+
+      it('should push the applied updates to the track changes api', function (done) {
+        rclient_history.lrange(
+          HistoryKeys.uncompressedHistoryOps({ doc_id: this.doc_id }),
+          0,
+          -1,
+          (error, updates) => {
+            updates = Array.from(updates).map((u) => JSON.parse(u))
+            for (let i = 0; i < this.updates.length; i++) {
+              const appliedUpdate = this.updates[i]
+              appliedUpdate.op.should.deep.equal(updates[i].op)
+            }
+
+            return rclient_history.sismember(
+              HistoryKeys.docsWithHistoryOps({ project_id: this.project_id }),
+              this.doc_id,
+              (error, result) => {
+                result.should.equal(1)
+                return done()
+              }
+            )
+          }
+        )
+        return null
+      })
+
+      return it('should store the doc ops in the correct order', function (done) {
+        rclient_du.lrange(
+          Keys.docOps({ doc_id: this.doc_id }),
+          0,
+          -1,
+          (error, updates) => {
+            updates = Array.from(updates).map((u) => JSON.parse(u))
+            for (let i = 0; i < this.updates.length; i++) {
+              const appliedUpdate = this.updates[i]
+              appliedUpdate.op.should.deep.equal(updates[i].op)
+            }
+            return done()
+          }
+        )
+        return null
+      })
+    })
+
+    return describe('when older ops come in after the delete', function () {
+      before(function (done) {
+        ;[this.project_id, this.doc_id] = Array.from([
+          DocUpdaterClient.randomId(),
+          DocUpdaterClient.randomId()
+        ])
+        const lines = ['', '', '']
+        MockWebApi.insertDoc(this.project_id, this.doc_id, {
+          lines,
+          version: 0
+        })
+        this.updates = [
+          { doc_id: this.doc_id, v: 0, op: [{ i: 'h', p: 0 }] },
+          { doc_id: this.doc_id, v: 1, op: [{ i: 'e', p: 1 }] },
+          { doc_id: this.doc_id, v: 2, op: [{ i: 'l', p: 2 }] },
+          { doc_id: this.doc_id, v: 3, op: [{ i: 'l', p: 3 }] },
+          { doc_id: this.doc_id, v: 4, op: [{ i: 'o', p: 4 }] },
+          { doc_id: this.doc_id, v: 0, op: [{ i: 'world', p: 1 }] }
+        ]
+        this.my_result = ['hello', 'world', '']
+        return done()
+      })
+
+      return it('should be able to continue applying updates when the project has been deleted', function (done) {
+        let update
+        const actions = []
+        for (update of Array.from(this.updates.slice(0, 5))) {
+          ;((update) => {
+            return actions.push((callback) =>
+              DocUpdaterClient.sendUpdate(
+                this.project_id,
+                this.doc_id,
+                update,
+                callback
+              )
+            )
+          })(update)
+        }
+        actions.push((callback) =>
+          DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback)
+        )
+        for (update of Array.from(this.updates.slice(5))) {
+          ;((update) => {
+            return actions.push((callback) =>
+              DocUpdaterClient.sendUpdate(
+                this.project_id,
+                this.doc_id,
+                update,
+                callback
+              )
+            )
+          })(update)
+        }
+
+        async.series(actions, (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.getDoc(
+            this.project_id,
+            this.doc_id,
+            (error, res, doc) => {
+              doc.lines.should.deep.equal(this.my_result)
+              return done()
+            }
+          )
+        })
+        return null
+      })
+    })
+  })
+
+  describe('with a broken update', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      this.broken_update = {
+        doc_id: this.doc_id,
+        v: this.version,
+        op: [{ d: 'not the correct content', p: 0 }]
+      }
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+
+      DocUpdaterClient.subscribeToAppliedOps(
+        (this.messageCallback = sinon.stub())
+      )
+
+      DocUpdaterClient.sendUpdate(
+        this.project_id,
+        this.doc_id,
+        this.broken_update,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(done, 200)
+        }
+      )
+      return null
+    })
+
+    it('should not update the doc', function (done) {
+      DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          doc.lines.should.deep.equal(this.lines)
+          return done()
+        }
+      )
+      return null
+    })
+
+    return it('should send a message with an error', function () {
+      this.messageCallback.called.should.equal(true)
+      const [channel, message] = Array.from(this.messageCallback.args[0])
+      channel.should.equal('applied-ops')
+      return JSON.parse(message).should.deep.include({
+        project_id: this.project_id,
+        doc_id: this.doc_id,
+        error: 'Delete component does not match'
+      })
+    })
+  })
+
+  describe('with enough updates to flush to the track changes api', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      const updates = []
+      for (let v = 0; v <= 199; v++) {
+        // Should flush after 100 ops
+        updates.push({
+          doc_id: this.doc_id,
+          op: [{ i: v.toString(), p: 0 }],
+          v
+        })
+      }
+
+      sinon.spy(MockTrackChangesApi, 'flushDoc')
+
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: 0
+      })
+
+      // Send updates in chunks to causes multiple flushes
+      const actions = []
+      for (let i = 0; i <= 19; i++) {
+        ;((i) => {
+          return actions.push((cb) => {
+            return DocUpdaterClient.sendUpdates(
+              this.project_id,
+              this.doc_id,
+              updates.slice(i * 10, (i + 1) * 10),
+              cb
+            )
+          })
+        })(i)
+      }
+      async.series(actions, (error) => {
+        if (error != null) {
+          throw error
+        }
+        return setTimeout(done, 2000)
+      })
+      return null
+    })
+
+    after(function () {
+      return MockTrackChangesApi.flushDoc.restore()
+    })
+
+    return it('should flush the doc twice', function () {
+      return MockTrackChangesApi.flushDoc.calledTwice.should.equal(true)
+    })
+  })
+
+  describe('when there is no version in Mongo', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines
+      })
+
+      const update = {
+        doc: this.doc_id,
+        op: this.update.op,
+        v: 0
+      }
+      DocUpdaterClient.sendUpdate(
+        this.project_id,
+        this.doc_id,
+        update,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(done, 200)
+        }
+      )
+      return null
+    })
+
+    return it('should update the doc (using version = 0)', function (done) {
+      DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          doc.lines.should.deep.equal(this.result)
+          return done()
+        }
+      )
+      return null
+    })
+  })
+
+  return describe('when the sending duplicate ops', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+
+      DocUpdaterClient.subscribeToAppliedOps(
+        (this.messageCallback = sinon.stub())
+      )
+
+      // One user delete 'one', the next turns it into 'once'. The second becomes a NOP.
+      DocUpdaterClient.sendUpdate(
+        this.project_id,
+        this.doc_id,
+        {
+          doc: this.doc_id,
+          op: [
+            {
+              i: 'one and a half\n',
+              p: 4
+            }
+          ],
+          v: this.version,
+          meta: {
+            source: 'ikHceq3yfAdQYzBo4-xZ'
+          }
+        },
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(() => {
+            return DocUpdaterClient.sendUpdate(
+              this.project_id,
+              this.doc_id,
+              {
+                doc: this.doc_id,
+                op: [
+                  {
+                    i: 'one and a half\n',
+                    p: 4
+                  }
+                ],
+                v: this.version,
+                dupIfSource: ['ikHceq3yfAdQYzBo4-xZ'],
+                meta: {
+                  source: 'ikHceq3yfAdQYzBo4-xZ'
+                }
+              },
+              (error) => {
+                if (error != null) {
+                  throw error
+                }
+                return setTimeout(done, 200)
+              }
+            )
+          }, 200)
+        }
+      )
+      return null
+    })
+
+    it('should update the doc', function (done) {
+      DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          doc.lines.should.deep.equal(this.result)
+          return done()
+        }
+      )
+      return null
+    })
+
+    return it('should return a message about duplicate ops', function () {
+      this.messageCallback.calledTwice.should.equal(true)
+      this.messageCallback.args[0][0].should.equal('applied-ops')
+      expect(JSON.parse(this.messageCallback.args[0][1]).op.dup).to.be.undefined
+      this.messageCallback.args[1][0].should.equal('applied-ops')
+      return expect(
+        JSON.parse(this.messageCallback.args[1][1]).op.dup
+      ).to.equal(true)
+    })
+  })
+})

+ 516 - 357
services/document-updater/test/acceptance/js/ApplyingUpdatesToProjectStructureTests.js

@@ -11,360 +11,519 @@
  * 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");
-chai.should();
-const Settings = require('settings-sharelatex');
-const rclient_project_history = require("redis-sharelatex").createClient(Settings.redis.project_history);
-const ProjectHistoryKeys = Settings.redis.project_history.key_schema;
-
-const MockProjectHistoryApi = require("./helpers/MockProjectHistoryApi");
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
-
-describe("Applying updates to a project's structure", function() {
-	before(function() {
-		this.user_id = 'user-id-123';
-		return this.version = 1234;
-	});
-
-	describe("renaming a file", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.fileUpdate = {
-				id: DocUpdaterClient.randomId(),
-				pathname: '/file-path',
-				newPathname: '/new-file-path'
-			};
-			this.fileUpdates = [ this.fileUpdate ];
-			return DocUpdaterApp.ensureRunning(error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.sendProjectUpdate(this.project_id, this.user_id, [], this.fileUpdates, this.version, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 200);
-				});
-			});
-		});
-
-		return it("should push the applied file renames to the project history api", function(done) {
-			rclient_project_history.lrange(ProjectHistoryKeys.projectHistoryOps({project_id: this.project_id}), 0, -1, (error, updates) => {
-				if (error != null) { throw error; }
-
-				const update = JSON.parse(updates[0]);
-				update.file.should.equal(this.fileUpdate.id);
-				update.pathname.should.equal('/file-path');
-				update.new_pathname.should.equal('/new-file-path');
-				update.meta.user_id.should.equal(this.user_id);
-				update.meta.ts.should.be.a('string');
-				update.version.should.equal(`${this.version}.0`);
-
-				return done();
-			});
-			return null;
-		});
-	});
-
-	describe("renaming a document", function() {
-		before(function() {
-			this.docUpdate = {
-				id: DocUpdaterClient.randomId(),
-				pathname: '/doc-path',
-				newPathname: '/new-doc-path'
-			};
-			return this.docUpdates = [ this.docUpdate ];});
-
-		describe("when the document is not loaded", function() {
-			before(function(done) {
-				this.project_id = DocUpdaterClient.randomId();
-				DocUpdaterClient.sendProjectUpdate(this.project_id, this.user_id, this.docUpdates, [], this.version, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 200);
-				});
-				return null;
-			});
-
-			return it("should push the applied doc renames to the project history api", function(done) {
-				rclient_project_history.lrange(ProjectHistoryKeys.projectHistoryOps({project_id: this.project_id}), 0, -1, (error, updates) => {
-					if (error != null) { throw error; }
-
-					const update = JSON.parse(updates[0]);
-					update.doc.should.equal(this.docUpdate.id);
-					update.pathname.should.equal('/doc-path');
-					update.new_pathname.should.equal('/new-doc-path');
-					update.meta.user_id.should.equal(this.user_id);
-					update.meta.ts.should.be.a('string');
-					update.version.should.equal(`${this.version}.0`);
-
-					return done();
-				});
-				return null;
-			});
-		});
-
-		return describe("when the document is loaded", function() {
-			before(function(done) {
-				this.project_id = DocUpdaterClient.randomId();
-				MockWebApi.insertDoc(this.project_id, this.docUpdate.id, {});
-				DocUpdaterClient.preloadDoc(this.project_id, this.docUpdate.id, error => {
-					if (error != null) { throw error; }
-					sinon.spy(MockWebApi, "getDocument");
-					return DocUpdaterClient.sendProjectUpdate(this.project_id, this.user_id, this.docUpdates, [], this.version, (error) => {
-						if (error != null) { throw error; }
-						return setTimeout(done, 200);
-					});
-				});
-				return null;
-			});
-
-			after(function() { return MockWebApi.getDocument.restore(); });
-
-			it("should update the doc", function(done) {
-				DocUpdaterClient.getDoc(this.project_id, this.docUpdate.id, (error, res, doc) => {
-					doc.pathname.should.equal(this.docUpdate.newPathname);
-					return done();
-				});
-				return null;
-			});
-
-			return it("should push the applied doc renames to the project history api", function(done) {
-				rclient_project_history.lrange(ProjectHistoryKeys.projectHistoryOps({project_id: this.project_id}), 0, -1, (error, updates) => {
-					if (error != null) { throw error; }
-
-					const update = JSON.parse(updates[0]);
-					update.doc.should.equal(this.docUpdate.id);
-					update.pathname.should.equal('/doc-path');
-					update.new_pathname.should.equal('/new-doc-path');
-					update.meta.user_id.should.equal(this.user_id);
-					update.meta.ts.should.be.a('string');
-					update.version.should.equal(`${this.version}.0`);
-
-					return done();
-				});
-				return null;
-			});
-		});
-	});
-
-	describe("renaming multiple documents and files", function() {
-		before(function() {
-			this.docUpdate0 = {
-				id: DocUpdaterClient.randomId(),
-				pathname: '/doc-path0',
-				newPathname: '/new-doc-path0'
-			};
-			this.docUpdate1 = {
-				id: DocUpdaterClient.randomId(),
-				pathname: '/doc-path1',
-				newPathname: '/new-doc-path1'
-			};
-			this.docUpdates = [ this.docUpdate0, this.docUpdate1 ];
-			this.fileUpdate0 = {
-				id: DocUpdaterClient.randomId(),
-				pathname: '/file-path0',
-				newPathname: '/new-file-path0'
-			};
-			this.fileUpdate1 = {
-				id: DocUpdaterClient.randomId(),
-				pathname: '/file-path1',
-				newPathname: '/new-file-path1'
-			};
-			return this.fileUpdates = [ this.fileUpdate0, this.fileUpdate1 ];});
-
-		return describe("when the documents are not loaded", function() {
-			before(function(done) {
-				this.project_id = DocUpdaterClient.randomId();
-				DocUpdaterClient.sendProjectUpdate(this.project_id, this.user_id, this.docUpdates, this.fileUpdates, this.version, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 200);
-				});
-				return null;
-			});
-
-			return it("should push the applied doc renames to the project history api", function(done) {
-				rclient_project_history.lrange(ProjectHistoryKeys.projectHistoryOps({project_id: this.project_id}), 0, -1, (error, updates) => {
-					if (error != null) { throw error; }
-
-					let update = JSON.parse(updates[0]);
-					update.doc.should.equal(this.docUpdate0.id);
-					update.pathname.should.equal('/doc-path0');
-					update.new_pathname.should.equal('/new-doc-path0');
-					update.meta.user_id.should.equal(this.user_id);
-					update.meta.ts.should.be.a('string');
-					update.version.should.equal(`${this.version}.0`);
-
-					update = JSON.parse(updates[1]);
-					update.doc.should.equal(this.docUpdate1.id);
-					update.pathname.should.equal('/doc-path1');
-					update.new_pathname.should.equal('/new-doc-path1');
-					update.meta.user_id.should.equal(this.user_id);
-					update.meta.ts.should.be.a('string');
-					update.version.should.equal(`${this.version}.1`);
-
-					update = JSON.parse(updates[2]);
-					update.file.should.equal(this.fileUpdate0.id);
-					update.pathname.should.equal('/file-path0');
-					update.new_pathname.should.equal('/new-file-path0');
-					update.meta.user_id.should.equal(this.user_id);
-					update.meta.ts.should.be.a('string');
-					update.version.should.equal(`${this.version}.2`);
-
-					update = JSON.parse(updates[3]);
-					update.file.should.equal(this.fileUpdate1.id);
-					update.pathname.should.equal('/file-path1');
-					update.new_pathname.should.equal('/new-file-path1');
-					update.meta.user_id.should.equal(this.user_id);
-					update.meta.ts.should.be.a('string');
-					update.version.should.equal(`${this.version}.3`);
-
-					return done();
-				});
-				return null;
-			});
-		});
-	});
-
-
-	describe("adding a file", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.fileUpdate = {
-				id: DocUpdaterClient.randomId(),
-				pathname: '/file-path',
-				url: 'filestore.example.com'
-			};
-			this.fileUpdates = [ this.fileUpdate ];
-			DocUpdaterClient.sendProjectUpdate(this.project_id, this.user_id, [], this.fileUpdates, this.version, (error) => {
-				if (error != null) { throw error; }
-				return setTimeout(done, 200);
-			});
-			return null;
-		});
-
-		return it("should push the file addition to the project history api", function(done) {
-			rclient_project_history.lrange(ProjectHistoryKeys.projectHistoryOps({project_id: this.project_id}), 0, -1, (error, updates) => {
-				if (error != null) { throw error; }
-
-				const update = JSON.parse(updates[0]);
-				update.file.should.equal(this.fileUpdate.id);
-				update.pathname.should.equal('/file-path');
-				update.url.should.equal('filestore.example.com');
-				update.meta.user_id.should.equal(this.user_id);
-				update.meta.ts.should.be.a('string');
-				update.version.should.equal(`${this.version}.0`);
-
-				return done();
-			});
-			return null;
-		});
-	});
-
-	describe("adding a doc", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.docUpdate = {
-				id: DocUpdaterClient.randomId(),
-				pathname: '/file-path',
-				docLines: 'a\nb'
-			};
-			this.docUpdates = [ this.docUpdate ];
-			DocUpdaterClient.sendProjectUpdate(this.project_id, this.user_id, this.docUpdates, [], this.version, (error) => {
-				if (error != null) { throw error; }
-				return setTimeout(done, 200);
-			});
-			return null;
-		});
-
-		return it("should push the doc addition to the project history api", function(done) {
-			rclient_project_history.lrange(ProjectHistoryKeys.projectHistoryOps({project_id: this.project_id}), 0, -1, (error, updates) => {
-				if (error != null) { throw error; }
-
-				const update = JSON.parse(updates[0]);
-				update.doc.should.equal(this.docUpdate.id);
-				update.pathname.should.equal('/file-path');
-				update.docLines.should.equal('a\nb');
-				update.meta.user_id.should.equal(this.user_id);
-				update.meta.ts.should.be.a('string');
-				update.version.should.equal(`${this.version}.0`);
-
-				return done();
-			});
-			return null;
-		});
-	});
-
-	describe("with enough updates to flush to the history service", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.user_id = DocUpdaterClient.randomId();
-			this.version0 = 12345;
-			this.version1 = this.version0 + 1;
-			const updates = [];
-			for (let v = 0; v <= 599; v++) { // Should flush after 500 ops
-				updates.push({
-					id: DocUpdaterClient.randomId(),
-					pathname: '/file-' + v,
-					docLines: 'a\nb'
-				});
-			}
-
-			sinon.spy(MockProjectHistoryApi, "flushProject");
-
-			// Send updates in chunks to causes multiple flushes
-			const projectId = this.project_id;
-			const userId = this.project_id;
-			DocUpdaterClient.sendProjectUpdate(projectId, userId, updates.slice(0, 250), [], this.version0, function(error) {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.sendProjectUpdate(projectId, userId, updates.slice(250), [], this.version1, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 2000);
-				});
-			});
-			return null;
-		});
-
-		after(function() { return MockProjectHistoryApi.flushProject.restore(); });
-
-		return it("should flush project history", function() {
-			return MockProjectHistoryApi.flushProject.calledWith(this.project_id).should.equal(true);
-		});
-	});
-
-	return describe("with too few updates to flush to the history service", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.user_id = DocUpdaterClient.randomId();
-			this.version0 = 12345;
-			this.version1 = this.version0 + 1;
-
-			const updates = [];
-			for (let v = 0; v <= 42; v++) { // Should flush after 500 ops
-				updates.push({
-					id: DocUpdaterClient.randomId(),
-					pathname: '/file-' + v,
-					docLines: 'a\nb'
-				});
-			}
-
-			sinon.spy(MockProjectHistoryApi, "flushProject");
-
-			// Send updates in chunks
-			const projectId = this.project_id;
-			const userId = this.project_id;
-			DocUpdaterClient.sendProjectUpdate(projectId, userId, updates.slice(0, 10), [], this.version0, function(error) {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.sendProjectUpdate(projectId, userId, updates.slice(10), [], this.version1, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 2000);
-				});
-			});
-			return null;
-		});
-
-		after(function() { return MockProjectHistoryApi.flushProject.restore(); });
-
-		return it("should not flush project history", function() {
-			return MockProjectHistoryApi.flushProject.calledWith(this.project_id).should.equal(false);
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+const Settings = require('settings-sharelatex')
+const rclient_project_history = require('redis-sharelatex').createClient(
+  Settings.redis.project_history
+)
+const ProjectHistoryKeys = Settings.redis.project_history.key_schema
+
+const MockProjectHistoryApi = require('./helpers/MockProjectHistoryApi')
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
+
+describe("Applying updates to a project's structure", function () {
+  before(function () {
+    this.user_id = 'user-id-123'
+    return (this.version = 1234)
+  })
+
+  describe('renaming a file', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.fileUpdate = {
+        id: DocUpdaterClient.randomId(),
+        pathname: '/file-path',
+        newPathname: '/new-file-path'
+      }
+      this.fileUpdates = [this.fileUpdate]
+      return DocUpdaterApp.ensureRunning((error) => {
+        if (error != null) {
+          throw error
+        }
+        return DocUpdaterClient.sendProjectUpdate(
+          this.project_id,
+          this.user_id,
+          [],
+          this.fileUpdates,
+          this.version,
+          (error) => {
+            if (error != null) {
+              throw error
+            }
+            return setTimeout(done, 200)
+          }
+        )
+      })
+    })
+
+    return it('should push the applied file renames to the project history api', function (done) {
+      rclient_project_history.lrange(
+        ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
+        0,
+        -1,
+        (error, updates) => {
+          if (error != null) {
+            throw error
+          }
+
+          const update = JSON.parse(updates[0])
+          update.file.should.equal(this.fileUpdate.id)
+          update.pathname.should.equal('/file-path')
+          update.new_pathname.should.equal('/new-file-path')
+          update.meta.user_id.should.equal(this.user_id)
+          update.meta.ts.should.be.a('string')
+          update.version.should.equal(`${this.version}.0`)
+
+          return done()
+        }
+      )
+      return null
+    })
+  })
+
+  describe('renaming a document', function () {
+    before(function () {
+      this.docUpdate = {
+        id: DocUpdaterClient.randomId(),
+        pathname: '/doc-path',
+        newPathname: '/new-doc-path'
+      }
+      return (this.docUpdates = [this.docUpdate])
+    })
+
+    describe('when the document is not loaded', function () {
+      before(function (done) {
+        this.project_id = DocUpdaterClient.randomId()
+        DocUpdaterClient.sendProjectUpdate(
+          this.project_id,
+          this.user_id,
+          this.docUpdates,
+          [],
+          this.version,
+          (error) => {
+            if (error != null) {
+              throw error
+            }
+            return setTimeout(done, 200)
+          }
+        )
+        return null
+      })
+
+      return it('should push the applied doc renames to the project history api', function (done) {
+        rclient_project_history.lrange(
+          ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
+          0,
+          -1,
+          (error, updates) => {
+            if (error != null) {
+              throw error
+            }
+
+            const update = JSON.parse(updates[0])
+            update.doc.should.equal(this.docUpdate.id)
+            update.pathname.should.equal('/doc-path')
+            update.new_pathname.should.equal('/new-doc-path')
+            update.meta.user_id.should.equal(this.user_id)
+            update.meta.ts.should.be.a('string')
+            update.version.should.equal(`${this.version}.0`)
+
+            return done()
+          }
+        )
+        return null
+      })
+    })
+
+    return describe('when the document is loaded', function () {
+      before(function (done) {
+        this.project_id = DocUpdaterClient.randomId()
+        MockWebApi.insertDoc(this.project_id, this.docUpdate.id, {})
+        DocUpdaterClient.preloadDoc(
+          this.project_id,
+          this.docUpdate.id,
+          (error) => {
+            if (error != null) {
+              throw error
+            }
+            sinon.spy(MockWebApi, 'getDocument')
+            return DocUpdaterClient.sendProjectUpdate(
+              this.project_id,
+              this.user_id,
+              this.docUpdates,
+              [],
+              this.version,
+              (error) => {
+                if (error != null) {
+                  throw error
+                }
+                return setTimeout(done, 200)
+              }
+            )
+          }
+        )
+        return null
+      })
+
+      after(function () {
+        return MockWebApi.getDocument.restore()
+      })
+
+      it('should update the doc', function (done) {
+        DocUpdaterClient.getDoc(
+          this.project_id,
+          this.docUpdate.id,
+          (error, res, doc) => {
+            doc.pathname.should.equal(this.docUpdate.newPathname)
+            return done()
+          }
+        )
+        return null
+      })
+
+      return it('should push the applied doc renames to the project history api', function (done) {
+        rclient_project_history.lrange(
+          ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
+          0,
+          -1,
+          (error, updates) => {
+            if (error != null) {
+              throw error
+            }
+
+            const update = JSON.parse(updates[0])
+            update.doc.should.equal(this.docUpdate.id)
+            update.pathname.should.equal('/doc-path')
+            update.new_pathname.should.equal('/new-doc-path')
+            update.meta.user_id.should.equal(this.user_id)
+            update.meta.ts.should.be.a('string')
+            update.version.should.equal(`${this.version}.0`)
+
+            return done()
+          }
+        )
+        return null
+      })
+    })
+  })
+
+  describe('renaming multiple documents and files', function () {
+    before(function () {
+      this.docUpdate0 = {
+        id: DocUpdaterClient.randomId(),
+        pathname: '/doc-path0',
+        newPathname: '/new-doc-path0'
+      }
+      this.docUpdate1 = {
+        id: DocUpdaterClient.randomId(),
+        pathname: '/doc-path1',
+        newPathname: '/new-doc-path1'
+      }
+      this.docUpdates = [this.docUpdate0, this.docUpdate1]
+      this.fileUpdate0 = {
+        id: DocUpdaterClient.randomId(),
+        pathname: '/file-path0',
+        newPathname: '/new-file-path0'
+      }
+      this.fileUpdate1 = {
+        id: DocUpdaterClient.randomId(),
+        pathname: '/file-path1',
+        newPathname: '/new-file-path1'
+      }
+      return (this.fileUpdates = [this.fileUpdate0, this.fileUpdate1])
+    })
+
+    return describe('when the documents are not loaded', function () {
+      before(function (done) {
+        this.project_id = DocUpdaterClient.randomId()
+        DocUpdaterClient.sendProjectUpdate(
+          this.project_id,
+          this.user_id,
+          this.docUpdates,
+          this.fileUpdates,
+          this.version,
+          (error) => {
+            if (error != null) {
+              throw error
+            }
+            return setTimeout(done, 200)
+          }
+        )
+        return null
+      })
+
+      return it('should push the applied doc renames to the project history api', function (done) {
+        rclient_project_history.lrange(
+          ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
+          0,
+          -1,
+          (error, updates) => {
+            if (error != null) {
+              throw error
+            }
+
+            let update = JSON.parse(updates[0])
+            update.doc.should.equal(this.docUpdate0.id)
+            update.pathname.should.equal('/doc-path0')
+            update.new_pathname.should.equal('/new-doc-path0')
+            update.meta.user_id.should.equal(this.user_id)
+            update.meta.ts.should.be.a('string')
+            update.version.should.equal(`${this.version}.0`)
+
+            update = JSON.parse(updates[1])
+            update.doc.should.equal(this.docUpdate1.id)
+            update.pathname.should.equal('/doc-path1')
+            update.new_pathname.should.equal('/new-doc-path1')
+            update.meta.user_id.should.equal(this.user_id)
+            update.meta.ts.should.be.a('string')
+            update.version.should.equal(`${this.version}.1`)
+
+            update = JSON.parse(updates[2])
+            update.file.should.equal(this.fileUpdate0.id)
+            update.pathname.should.equal('/file-path0')
+            update.new_pathname.should.equal('/new-file-path0')
+            update.meta.user_id.should.equal(this.user_id)
+            update.meta.ts.should.be.a('string')
+            update.version.should.equal(`${this.version}.2`)
+
+            update = JSON.parse(updates[3])
+            update.file.should.equal(this.fileUpdate1.id)
+            update.pathname.should.equal('/file-path1')
+            update.new_pathname.should.equal('/new-file-path1')
+            update.meta.user_id.should.equal(this.user_id)
+            update.meta.ts.should.be.a('string')
+            update.version.should.equal(`${this.version}.3`)
+
+            return done()
+          }
+        )
+        return null
+      })
+    })
+  })
+
+  describe('adding a file', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.fileUpdate = {
+        id: DocUpdaterClient.randomId(),
+        pathname: '/file-path',
+        url: 'filestore.example.com'
+      }
+      this.fileUpdates = [this.fileUpdate]
+      DocUpdaterClient.sendProjectUpdate(
+        this.project_id,
+        this.user_id,
+        [],
+        this.fileUpdates,
+        this.version,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(done, 200)
+        }
+      )
+      return null
+    })
+
+    return it('should push the file addition to the project history api', function (done) {
+      rclient_project_history.lrange(
+        ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
+        0,
+        -1,
+        (error, updates) => {
+          if (error != null) {
+            throw error
+          }
+
+          const update = JSON.parse(updates[0])
+          update.file.should.equal(this.fileUpdate.id)
+          update.pathname.should.equal('/file-path')
+          update.url.should.equal('filestore.example.com')
+          update.meta.user_id.should.equal(this.user_id)
+          update.meta.ts.should.be.a('string')
+          update.version.should.equal(`${this.version}.0`)
+
+          return done()
+        }
+      )
+      return null
+    })
+  })
+
+  describe('adding a doc', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.docUpdate = {
+        id: DocUpdaterClient.randomId(),
+        pathname: '/file-path',
+        docLines: 'a\nb'
+      }
+      this.docUpdates = [this.docUpdate]
+      DocUpdaterClient.sendProjectUpdate(
+        this.project_id,
+        this.user_id,
+        this.docUpdates,
+        [],
+        this.version,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(done, 200)
+        }
+      )
+      return null
+    })
+
+    return it('should push the doc addition to the project history api', function (done) {
+      rclient_project_history.lrange(
+        ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }),
+        0,
+        -1,
+        (error, updates) => {
+          if (error != null) {
+            throw error
+          }
+
+          const update = JSON.parse(updates[0])
+          update.doc.should.equal(this.docUpdate.id)
+          update.pathname.should.equal('/file-path')
+          update.docLines.should.equal('a\nb')
+          update.meta.user_id.should.equal(this.user_id)
+          update.meta.ts.should.be.a('string')
+          update.version.should.equal(`${this.version}.0`)
+
+          return done()
+        }
+      )
+      return null
+    })
+  })
+
+  describe('with enough updates to flush to the history service', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.user_id = DocUpdaterClient.randomId()
+      this.version0 = 12345
+      this.version1 = this.version0 + 1
+      const updates = []
+      for (let v = 0; v <= 599; v++) {
+        // Should flush after 500 ops
+        updates.push({
+          id: DocUpdaterClient.randomId(),
+          pathname: '/file-' + v,
+          docLines: 'a\nb'
+        })
+      }
+
+      sinon.spy(MockProjectHistoryApi, 'flushProject')
+
+      // Send updates in chunks to causes multiple flushes
+      const projectId = this.project_id
+      const userId = this.project_id
+      DocUpdaterClient.sendProjectUpdate(
+        projectId,
+        userId,
+        updates.slice(0, 250),
+        [],
+        this.version0,
+        function (error) {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.sendProjectUpdate(
+            projectId,
+            userId,
+            updates.slice(250),
+            [],
+            this.version1,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              return setTimeout(done, 2000)
+            }
+          )
+        }
+      )
+      return null
+    })
+
+    after(function () {
+      return MockProjectHistoryApi.flushProject.restore()
+    })
+
+    return it('should flush project history', function () {
+      return MockProjectHistoryApi.flushProject
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
+  })
+
+  return describe('with too few updates to flush to the history service', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.user_id = DocUpdaterClient.randomId()
+      this.version0 = 12345
+      this.version1 = this.version0 + 1
+
+      const updates = []
+      for (let v = 0; v <= 42; v++) {
+        // Should flush after 500 ops
+        updates.push({
+          id: DocUpdaterClient.randomId(),
+          pathname: '/file-' + v,
+          docLines: 'a\nb'
+        })
+      }
+
+      sinon.spy(MockProjectHistoryApi, 'flushProject')
+
+      // Send updates in chunks
+      const projectId = this.project_id
+      const userId = this.project_id
+      DocUpdaterClient.sendProjectUpdate(
+        projectId,
+        userId,
+        updates.slice(0, 10),
+        [],
+        this.version0,
+        function (error) {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.sendProjectUpdate(
+            projectId,
+            userId,
+            updates.slice(10),
+            [],
+            this.version1,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              return setTimeout(done, 2000)
+            }
+          )
+        }
+      )
+      return null
+    })
+
+    after(function () {
+      return MockProjectHistoryApi.flushProject.restore()
+    })
+
+    return it('should not flush project history', function () {
+      return MockProjectHistoryApi.flushProject
+        .calledWith(this.project_id)
+        .should.equal(false)
+    })
+  })
+})

+ 183 - 134
services/document-updater/test/acceptance/js/DeletingADocumentTests.js

@@ -10,137 +10,186 @@
  * 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");
-chai.should();
-
-const MockTrackChangesApi = require("./helpers/MockTrackChangesApi");
-const MockProjectHistoryApi = require("./helpers/MockProjectHistoryApi");
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
-
-describe("Deleting a document", function() {
-	before(function(done) {
-		this.lines = ["one", "two", "three"];
-		this.version = 42;
-		this.update = {
-			doc: this.doc_id,
-			op: [{
-				i: "one and a half\n",
-				p: 4
-			}],
-			v: this.version
-		};
-		this.result = ["one", "one and a half", "two", "three"];
-
-		sinon.spy(MockTrackChangesApi, "flushDoc");
-		sinon.spy(MockProjectHistoryApi, "flushProject");
-		return DocUpdaterApp.ensureRunning(done);
-	});
-
-	after(function() {
-		MockTrackChangesApi.flushDoc.restore();
-		return MockProjectHistoryApi.flushProject.restore();
-	});
-
-	describe("when the updated doc exists in the doc updater", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			sinon.spy(MockWebApi, "setDocument");
-			sinon.spy(MockWebApi, "getDocument");
-
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, this.update, error => {
-					if (error != null) { throw error; }
-					return setTimeout(() => {
-						return DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, (error, res, body) => {
-							this.statusCode = res.statusCode;
-							return setTimeout(done, 200);
-						});
-					}
-					, 200);
-				});
-			});
-		});
-
-		after(function() {
-			MockWebApi.setDocument.restore();
-			return MockWebApi.getDocument.restore();
-		});
-
-		it("should return a 204 status code", function() {
-			return this.statusCode.should.equal(204);
-		});
-
-		it("should send the updated document and version to the web api", function() {
-			return MockWebApi.setDocument
-				.calledWith(this.project_id, this.doc_id, this.result, this.version + 1)
-				.should.equal(true);
-		});
-
-		it("should need to reload the doc if read again", function(done) {
-			MockWebApi.getDocument.called.should.equal.false;
-			return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				MockWebApi.getDocument
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-				return done();
-			});
-		});
-
-		it("should flush track changes", function() {
-			return MockTrackChangesApi.flushDoc.calledWith(this.doc_id).should.equal(true);
-		});
-
-		return it("should flush project history", function() {
-			return MockProjectHistoryApi.flushProject.calledWith(this.project_id).should.equal(true);
-		});
-	});
-
-	return describe("when the doc is not in the doc updater", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {
-				lines: this.lines
-			});
-			sinon.spy(MockWebApi, "setDocument");
-			sinon.spy(MockWebApi, "getDocument");
-			return DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, (error, res, body) => {
-				this.statusCode = res.statusCode;
-				return setTimeout(done, 200);
-			});
-		});
-
-		after(function() {
-			MockWebApi.setDocument.restore();
-			return MockWebApi.getDocument.restore();
-		});
-
-		it("should return a 204 status code", function() {
-			return this.statusCode.should.equal(204);
-		});
-
-		it("should not need to send the updated document to the web api", function() { return MockWebApi.setDocument.called.should.equal(false); });
-
-		it("should need to reload the doc if read again", function(done) {
-			MockWebApi.getDocument.called.should.equal.false;
-			return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				MockWebApi.getDocument
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-				return done();
-			});
-		});
-
-		it("should flush track changes", function() {
-			return MockTrackChangesApi.flushDoc.calledWith(this.doc_id).should.equal(true);
-		});
-
-		return it("should flush project history", function() {
-			return MockProjectHistoryApi.flushProject.calledWith(this.project_id).should.equal(true);
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+
+const MockTrackChangesApi = require('./helpers/MockTrackChangesApi')
+const MockProjectHistoryApi = require('./helpers/MockProjectHistoryApi')
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
+
+describe('Deleting a document', function () {
+  before(function (done) {
+    this.lines = ['one', 'two', 'three']
+    this.version = 42
+    this.update = {
+      doc: this.doc_id,
+      op: [
+        {
+          i: 'one and a half\n',
+          p: 4
+        }
+      ],
+      v: this.version
+    }
+    this.result = ['one', 'one and a half', 'two', 'three']
+
+    sinon.spy(MockTrackChangesApi, 'flushDoc')
+    sinon.spy(MockProjectHistoryApi, 'flushProject')
+    return DocUpdaterApp.ensureRunning(done)
+  })
+
+  after(function () {
+    MockTrackChangesApi.flushDoc.restore()
+    return MockProjectHistoryApi.flushProject.restore()
+  })
+
+  describe('when the updated doc exists in the doc updater', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      sinon.spy(MockWebApi, 'setDocument')
+      sinon.spy(MockWebApi, 'getDocument')
+
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc_id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.sendUpdate(
+            this.project_id,
+            this.doc_id,
+            this.update,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              return setTimeout(() => {
+                return DocUpdaterClient.deleteDoc(
+                  this.project_id,
+                  this.doc_id,
+                  (error, res, body) => {
+                    this.statusCode = res.statusCode
+                    return setTimeout(done, 200)
+                  }
+                )
+              }, 200)
+            }
+          )
+        }
+      )
+    })
+
+    after(function () {
+      MockWebApi.setDocument.restore()
+      return MockWebApi.getDocument.restore()
+    })
+
+    it('should return a 204 status code', function () {
+      return this.statusCode.should.equal(204)
+    })
+
+    it('should send the updated document and version to the web api', function () {
+      return MockWebApi.setDocument
+        .calledWith(this.project_id, this.doc_id, this.result, this.version + 1)
+        .should.equal(true)
+    })
+
+    it('should need to reload the doc if read again', function (done) {
+      MockWebApi.getDocument.called.should.equal.false
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          MockWebApi.getDocument
+            .calledWith(this.project_id, this.doc_id)
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should flush track changes', function () {
+      return MockTrackChangesApi.flushDoc
+        .calledWith(this.doc_id)
+        .should.equal(true)
+    })
+
+    return it('should flush project history', function () {
+      return MockProjectHistoryApi.flushProject
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
+  })
+
+  return describe('when the doc is not in the doc updater', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines
+      })
+      sinon.spy(MockWebApi, 'setDocument')
+      sinon.spy(MockWebApi, 'getDocument')
+      return DocUpdaterClient.deleteDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, body) => {
+          this.statusCode = res.statusCode
+          return setTimeout(done, 200)
+        }
+      )
+    })
+
+    after(function () {
+      MockWebApi.setDocument.restore()
+      return MockWebApi.getDocument.restore()
+    })
+
+    it('should return a 204 status code', function () {
+      return this.statusCode.should.equal(204)
+    })
+
+    it('should not need to send the updated document to the web api', function () {
+      return MockWebApi.setDocument.called.should.equal(false)
+    })
+
+    it('should need to reload the doc if read again', function (done) {
+      MockWebApi.getDocument.called.should.equal.false
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          MockWebApi.getDocument
+            .calledWith(this.project_id, this.doc_id)
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should flush track changes', function () {
+      return MockTrackChangesApi.flushDoc
+        .calledWith(this.doc_id)
+        .should.equal(true)
+    })
+
+    return it('should flush project history', function () {
+      return MockProjectHistoryApi.flushProject
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
+  })
+})

+ 281 - 209
services/document-updater/test/acceptance/js/DeletingAProjectTests.js

@@ -11,213 +11,285 @@
  * 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");
-chai.should();
-const async = require("async");
-
-const MockTrackChangesApi = require("./helpers/MockTrackChangesApi");
-const MockProjectHistoryApi = require("./helpers/MockProjectHistoryApi");
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
-
-describe("Deleting a project", function() {
-	before(function(done) {
-		let doc_id0, doc_id1;
-		this.project_id = DocUpdaterClient.randomId();
-		this.docs = [{
-			id: (doc_id0 = DocUpdaterClient.randomId()),
-			lines: ["one", "two", "three"],
-			update: {
-				doc: doc_id0,
-				op: [{
-					i: "one and a half\n",
-					p: 4
-				}],
-				v: 0
-			},
-			updatedLines: ["one", "one and a half", "two", "three"]
-		}, {
-			id: (doc_id1 = DocUpdaterClient.randomId()),
-			lines: ["four", "five", "six"],
-			update: {
-				doc: doc_id1,
-				op: [{
-					i: "four and a half\n",
-					p: 5
-				}],
-				v: 0
-			},
-			updatedLines: ["four", "four and a half", "five", "six"]
-		}];
-		for (const doc of Array.from(this.docs)) {
-			MockWebApi.insertDoc(this.project_id, doc.id, {
-				lines: doc.lines,
-				version: doc.update.v
-			});
-		}
-
-		return DocUpdaterApp.ensureRunning(done);
-	});
-
-
-	describe("with documents which have been updated", function() {
-		before(function(done) {
-			sinon.spy(MockWebApi, "setDocument");
-			sinon.spy(MockTrackChangesApi, "flushDoc");
-			sinon.spy(MockProjectHistoryApi, "flushProject");
-
-			return async.series(this.docs.map(doc => {
-				return callback => {
-					return DocUpdaterClient.preloadDoc(this.project_id, doc.id, error => {
-						if (error != null) { return callback(error); }
-						return DocUpdaterClient.sendUpdate(this.project_id, doc.id, doc.update, error => {
-							return callback(error);
-					});
-				});
-				};
-			}), error => {
-				if (error != null) { throw error; }
-				return setTimeout(() => {
-					return DocUpdaterClient.deleteProject(this.project_id, (error, res, body) => {
-						this.statusCode = res.statusCode;
-						return done();
-					});
-				}
-				, 200);
-			});
-		});
-
-		after(function() {
-			MockWebApi.setDocument.restore();
-			MockTrackChangesApi.flushDoc.restore();
-			return MockProjectHistoryApi.flushProject.restore();
-		});
-
-		it("should return a 204 status code", function() {
-			return this.statusCode.should.equal(204);
-		});
-
-		it("should send each document to the web api", function() {
-			return Array.from(this.docs).map((doc) =>
-				MockWebApi.setDocument
-					.calledWith(this.project_id, doc.id, doc.updatedLines)
-					.should.equal(true));
-		});
-
-		it("should need to reload the docs if read again", function(done) {
-			sinon.spy(MockWebApi, "getDocument");
-			return async.series(this.docs.map(doc => {
-				return callback => {
-					MockWebApi.getDocument.calledWith(this.project_id, doc.id).should.equal(false);
-					return DocUpdaterClient.getDoc(this.project_id, doc.id, (error, res, returnedDoc) => {
-						MockWebApi.getDocument.calledWith(this.project_id, doc.id).should.equal(true);
-						return callback();
-				});
-				};
-			}), () => {
-				MockWebApi.getDocument.restore();
-				return done();
-			});
-		});
-
-		it("should flush each doc in track changes", function() {
-			return Array.from(this.docs).map((doc) =>
-				MockTrackChangesApi.flushDoc.calledWith(doc.id).should.equal(true));
-		});
-
-		return it("should flush each doc in project history", function() {
-			return MockProjectHistoryApi.flushProject.calledWith(this.project_id).should.equal(true);
-		});
-	});
-
-	describe("with the background=true parameter from realtime and no request to flush the queue", function() {
-		before(function(done) {
-			sinon.spy(MockWebApi, "setDocument");
-			sinon.spy(MockTrackChangesApi, "flushDoc");
-			sinon.spy(MockProjectHistoryApi, "flushProject");
-
-			return async.series(this.docs.map(doc => {
-				return callback => {
-					return DocUpdaterClient.preloadDoc(this.project_id, doc.id, callback);
-				};
-			}), error => {
-				if (error != null) { throw error; }
-				return setTimeout(() => {
-					return DocUpdaterClient.deleteProjectOnShutdown(this.project_id, (error, res, body) => {
-						this.statusCode = res.statusCode;
-						return done();
-					});
-				}
-				, 200);
-			});
-		});
-
-		after(function() {
-			MockWebApi.setDocument.restore();
-			MockTrackChangesApi.flushDoc.restore();
-			return MockProjectHistoryApi.flushProject.restore();
-		});
-
-		it("should return a 204 status code", function() {
-			return this.statusCode.should.equal(204);
-		});
-
-		it("should not send any documents to the web api", function() { return MockWebApi.setDocument.called.should.equal(false); });
-
-		it("should not flush any docs in track changes", function() { return MockTrackChangesApi.flushDoc.called.should.equal(false); });
-
-		return it("should not flush to project history", function() { return MockProjectHistoryApi.flushProject.called.should.equal(false); });
-	});
-
-	return describe("with the background=true parameter from realtime and a request to flush the queue", function() {
-		before(function(done) {
-			sinon.spy(MockWebApi, "setDocument");
-			sinon.spy(MockTrackChangesApi, "flushDoc");
-			sinon.spy(MockProjectHistoryApi, "flushProject");
-
-			return async.series(this.docs.map(doc => {
-				return callback => {
-					return DocUpdaterClient.preloadDoc(this.project_id, doc.id, callback);
-				};
-			}), error => {
-				if (error != null) { throw error; }
-				return setTimeout(() => {
-					return DocUpdaterClient.deleteProjectOnShutdown(this.project_id, (error, res, body) => {
-						this.statusCode = res.statusCode;
-						// after deleting the project and putting it in the queue, flush the queue
-						return setTimeout(() => DocUpdaterClient.flushOldProjects(done)
-						, 2000);
-					});
-				}
-				, 200);
-			});
-		});
-
-		after(function() {
-			MockWebApi.setDocument.restore();
-			MockTrackChangesApi.flushDoc.restore();
-			return MockProjectHistoryApi.flushProject.restore();
-		});
-
-		it("should return a 204 status code", function() {
-			return this.statusCode.should.equal(204);
-		});
-
-		it("should send each document to the web api", function() {
-			return Array.from(this.docs).map((doc) =>
-				MockWebApi.setDocument
-					.calledWith(this.project_id, doc.id, doc.updatedLines)
-					.should.equal(true));
-		});
-
-		it("should flush each doc in track changes", function() {
-			return Array.from(this.docs).map((doc) =>
-				MockTrackChangesApi.flushDoc.calledWith(doc.id).should.equal(true));
-		});
-
-		return it("should flush to project history", function() { return MockProjectHistoryApi.flushProject.called.should.equal(true); });
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+const async = require('async')
 
+const MockTrackChangesApi = require('./helpers/MockTrackChangesApi')
+const MockProjectHistoryApi = require('./helpers/MockProjectHistoryApi')
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
+
+describe('Deleting a project', function () {
+  before(function (done) {
+    let doc_id0, doc_id1
+    this.project_id = DocUpdaterClient.randomId()
+    this.docs = [
+      {
+        id: (doc_id0 = DocUpdaterClient.randomId()),
+        lines: ['one', 'two', 'three'],
+        update: {
+          doc: doc_id0,
+          op: [
+            {
+              i: 'one and a half\n',
+              p: 4
+            }
+          ],
+          v: 0
+        },
+        updatedLines: ['one', 'one and a half', 'two', 'three']
+      },
+      {
+        id: (doc_id1 = DocUpdaterClient.randomId()),
+        lines: ['four', 'five', 'six'],
+        update: {
+          doc: doc_id1,
+          op: [
+            {
+              i: 'four and a half\n',
+              p: 5
+            }
+          ],
+          v: 0
+        },
+        updatedLines: ['four', 'four and a half', 'five', 'six']
+      }
+    ]
+    for (const doc of Array.from(this.docs)) {
+      MockWebApi.insertDoc(this.project_id, doc.id, {
+        lines: doc.lines,
+        version: doc.update.v
+      })
+    }
+
+    return DocUpdaterApp.ensureRunning(done)
+  })
+
+  describe('with documents which have been updated', function () {
+    before(function (done) {
+      sinon.spy(MockWebApi, 'setDocument')
+      sinon.spy(MockTrackChangesApi, 'flushDoc')
+      sinon.spy(MockProjectHistoryApi, 'flushProject')
+
+      return async.series(
+        this.docs.map((doc) => {
+          return (callback) => {
+            return DocUpdaterClient.preloadDoc(
+              this.project_id,
+              doc.id,
+              (error) => {
+                if (error != null) {
+                  return callback(error)
+                }
+                return DocUpdaterClient.sendUpdate(
+                  this.project_id,
+                  doc.id,
+                  doc.update,
+                  (error) => {
+                    return callback(error)
+                  }
+                )
+              }
+            )
+          }
+        }),
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(() => {
+            return DocUpdaterClient.deleteProject(
+              this.project_id,
+              (error, res, body) => {
+                this.statusCode = res.statusCode
+                return done()
+              }
+            )
+          }, 200)
+        }
+      )
+    })
+
+    after(function () {
+      MockWebApi.setDocument.restore()
+      MockTrackChangesApi.flushDoc.restore()
+      return MockProjectHistoryApi.flushProject.restore()
+    })
+
+    it('should return a 204 status code', function () {
+      return this.statusCode.should.equal(204)
+    })
+
+    it('should send each document to the web api', function () {
+      return Array.from(this.docs).map((doc) =>
+        MockWebApi.setDocument
+          .calledWith(this.project_id, doc.id, doc.updatedLines)
+          .should.equal(true)
+      )
+    })
+
+    it('should need to reload the docs if read again', function (done) {
+      sinon.spy(MockWebApi, 'getDocument')
+      return async.series(
+        this.docs.map((doc) => {
+          return (callback) => {
+            MockWebApi.getDocument
+              .calledWith(this.project_id, doc.id)
+              .should.equal(false)
+            return DocUpdaterClient.getDoc(
+              this.project_id,
+              doc.id,
+              (error, res, returnedDoc) => {
+                MockWebApi.getDocument
+                  .calledWith(this.project_id, doc.id)
+                  .should.equal(true)
+                return callback()
+              }
+            )
+          }
+        }),
+        () => {
+          MockWebApi.getDocument.restore()
+          return done()
+        }
+      )
+    })
+
+    it('should flush each doc in track changes', function () {
+      return Array.from(this.docs).map((doc) =>
+        MockTrackChangesApi.flushDoc.calledWith(doc.id).should.equal(true)
+      )
+    })
+
+    return it('should flush each doc in project history', function () {
+      return MockProjectHistoryApi.flushProject
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
+  })
+
+  describe('with the background=true parameter from realtime and no request to flush the queue', function () {
+    before(function (done) {
+      sinon.spy(MockWebApi, 'setDocument')
+      sinon.spy(MockTrackChangesApi, 'flushDoc')
+      sinon.spy(MockProjectHistoryApi, 'flushProject')
+
+      return async.series(
+        this.docs.map((doc) => {
+          return (callback) => {
+            return DocUpdaterClient.preloadDoc(
+              this.project_id,
+              doc.id,
+              callback
+            )
+          }
+        }),
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(() => {
+            return DocUpdaterClient.deleteProjectOnShutdown(
+              this.project_id,
+              (error, res, body) => {
+                this.statusCode = res.statusCode
+                return done()
+              }
+            )
+          }, 200)
+        }
+      )
+    })
+
+    after(function () {
+      MockWebApi.setDocument.restore()
+      MockTrackChangesApi.flushDoc.restore()
+      return MockProjectHistoryApi.flushProject.restore()
+    })
+
+    it('should return a 204 status code', function () {
+      return this.statusCode.should.equal(204)
+    })
+
+    it('should not send any documents to the web api', function () {
+      return MockWebApi.setDocument.called.should.equal(false)
+    })
+
+    it('should not flush any docs in track changes', function () {
+      return MockTrackChangesApi.flushDoc.called.should.equal(false)
+    })
+
+    return it('should not flush to project history', function () {
+      return MockProjectHistoryApi.flushProject.called.should.equal(false)
+    })
+  })
+
+  return describe('with the background=true parameter from realtime and a request to flush the queue', function () {
+    before(function (done) {
+      sinon.spy(MockWebApi, 'setDocument')
+      sinon.spy(MockTrackChangesApi, 'flushDoc')
+      sinon.spy(MockProjectHistoryApi, 'flushProject')
+
+      return async.series(
+        this.docs.map((doc) => {
+          return (callback) => {
+            return DocUpdaterClient.preloadDoc(
+              this.project_id,
+              doc.id,
+              callback
+            )
+          }
+        }),
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(() => {
+            return DocUpdaterClient.deleteProjectOnShutdown(
+              this.project_id,
+              (error, res, body) => {
+                this.statusCode = res.statusCode
+                // after deleting the project and putting it in the queue, flush the queue
+                return setTimeout(
+                  () => DocUpdaterClient.flushOldProjects(done),
+                  2000
+                )
+              }
+            )
+          }, 200)
+        }
+      )
+    })
+
+    after(function () {
+      MockWebApi.setDocument.restore()
+      MockTrackChangesApi.flushDoc.restore()
+      return MockProjectHistoryApi.flushProject.restore()
+    })
+
+    it('should return a 204 status code', function () {
+      return this.statusCode.should.equal(204)
+    })
+
+    it('should send each document to the web api', function () {
+      return Array.from(this.docs).map((doc) =>
+        MockWebApi.setDocument
+          .calledWith(this.project_id, doc.id, doc.updatedLines)
+          .should.equal(true)
+      )
+    })
+
+    it('should flush each doc in track changes', function () {
+      return Array.from(this.docs).map((doc) =>
+        MockTrackChangesApi.flushDoc.calledWith(doc.id).should.equal(true)
+      )
+    })
+
+    return it('should flush to project history', function () {
+      return MockProjectHistoryApi.flushProject.called.should.equal(true)
+    })
+  })
+})

+ 124 - 90
services/document-updater/test/acceptance/js/FlushingAProjectTests.js

@@ -11,101 +11,135 @@
  * 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");
-chai.should();
-const async = require("async");
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+const async = require('async')
 
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
 
-describe("Flushing a project", function() {
-	before(function(done) {
-		let doc_id0, doc_id1;
-		this.project_id = DocUpdaterClient.randomId();
-		this.docs = [{
-			id: (doc_id0 = DocUpdaterClient.randomId()),
-			lines: ["one", "two", "three"],
-			update: {
-				doc: doc_id0,
-				op: [{
-					i: "one and a half\n",
-					p: 4
-				}],
-				v: 0
-			},
-			updatedLines: ["one", "one and a half", "two", "three"]
-		}, {
-			id: (doc_id1 = DocUpdaterClient.randomId()),
-			lines: ["four", "five", "six"],
-			update: {
-				doc: doc_id1,
-				op: [{
-					i: "four and a half\n",
-					p: 5
-				}],
-				v: 0
-			},
-			updatedLines: ["four", "four and a half", "five", "six"]
-		}];
-		for (const doc of Array.from(this.docs)) {
-			MockWebApi.insertDoc(this.project_id, doc.id, {
-				lines: doc.lines,
-				version: doc.update.v
-			});
-		}
-		return DocUpdaterApp.ensureRunning(done);
-	});
+describe('Flushing a project', function () {
+  before(function (done) {
+    let doc_id0, doc_id1
+    this.project_id = DocUpdaterClient.randomId()
+    this.docs = [
+      {
+        id: (doc_id0 = DocUpdaterClient.randomId()),
+        lines: ['one', 'two', 'three'],
+        update: {
+          doc: doc_id0,
+          op: [
+            {
+              i: 'one and a half\n',
+              p: 4
+            }
+          ],
+          v: 0
+        },
+        updatedLines: ['one', 'one and a half', 'two', 'three']
+      },
+      {
+        id: (doc_id1 = DocUpdaterClient.randomId()),
+        lines: ['four', 'five', 'six'],
+        update: {
+          doc: doc_id1,
+          op: [
+            {
+              i: 'four and a half\n',
+              p: 5
+            }
+          ],
+          v: 0
+        },
+        updatedLines: ['four', 'four and a half', 'five', 'six']
+      }
+    ]
+    for (const doc of Array.from(this.docs)) {
+      MockWebApi.insertDoc(this.project_id, doc.id, {
+        lines: doc.lines,
+        version: doc.update.v
+      })
+    }
+    return DocUpdaterApp.ensureRunning(done)
+  })
 
-	return describe("with documents which have been updated", function() {
-		before(function(done) {
-			sinon.spy(MockWebApi, "setDocument");
+  return describe('with documents which have been updated', function () {
+    before(function (done) {
+      sinon.spy(MockWebApi, 'setDocument')
 
-			return async.series(this.docs.map(doc => {
-				return callback => {
-					return DocUpdaterClient.preloadDoc(this.project_id, doc.id, error => {
-						if (error != null) { return callback(error); }
-						return DocUpdaterClient.sendUpdate(this.project_id, doc.id, doc.update, error => {
-							return callback(error);
-					});
-				});
-				};
-			}), error => {
-				if (error != null) { throw error; }
-				return setTimeout(() => {
-					return DocUpdaterClient.flushProject(this.project_id, (error, res, body) => {
-						this.statusCode = res.statusCode;
-						return done();
-					});
-				}
-				, 200);
-			});
-		});
+      return async.series(
+        this.docs.map((doc) => {
+          return (callback) => {
+            return DocUpdaterClient.preloadDoc(
+              this.project_id,
+              doc.id,
+              (error) => {
+                if (error != null) {
+                  return callback(error)
+                }
+                return DocUpdaterClient.sendUpdate(
+                  this.project_id,
+                  doc.id,
+                  doc.update,
+                  (error) => {
+                    return callback(error)
+                  }
+                )
+              }
+            )
+          }
+        }),
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(() => {
+            return DocUpdaterClient.flushProject(
+              this.project_id,
+              (error, res, body) => {
+                this.statusCode = res.statusCode
+                return done()
+              }
+            )
+          }, 200)
+        }
+      )
+    })
 
-		after(function() { return MockWebApi.setDocument.restore(); });
+    after(function () {
+      return MockWebApi.setDocument.restore()
+    })
 
-		it("should return a 204 status code", function() {
-			return this.statusCode.should.equal(204);
-		});
+    it('should return a 204 status code', function () {
+      return this.statusCode.should.equal(204)
+    })
 
-		it("should send each document to the web api", function() {
-			return Array.from(this.docs).map((doc) =>
-				MockWebApi.setDocument
-					.calledWith(this.project_id, doc.id, doc.updatedLines)
-					.should.equal(true));
-		});
-
-		return it("should update the lines in the doc updater", function(done) {
-			return async.series(this.docs.map(doc => {
-				return callback => {
-					return DocUpdaterClient.getDoc(this.project_id, doc.id, (error, res, returnedDoc) => {
-						returnedDoc.lines.should.deep.equal(doc.updatedLines);
-						return callback();
-				});
-				};
-			}), done);
-		});
-	});
-});
+    it('should send each document to the web api', function () {
+      return Array.from(this.docs).map((doc) =>
+        MockWebApi.setDocument
+          .calledWith(this.project_id, doc.id, doc.updatedLines)
+          .should.equal(true)
+      )
+    })
 
+    return it('should update the lines in the doc updater', function (done) {
+      return async.series(
+        this.docs.map((doc) => {
+          return (callback) => {
+            return DocUpdaterClient.getDoc(
+              this.project_id,
+              doc.id,
+              (error, res, returnedDoc) => {
+                returnedDoc.lines.should.deep.equal(doc.updatedLines)
+                return callback()
+              }
+            )
+          }
+        }),
+        done
+      )
+    })
+  })
+})

+ 137 - 92
services/document-updater/test/acceptance/js/FlushingDocsTests.js

@@ -13,108 +13,153 @@
  * 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");
-chai.should();
-const {
-    expect
-} = chai;
-const async = require("async");
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+const { expect } = chai
+const async = require('async')
 
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
 
-describe("Flushing a doc to Mongo", function() {
-	before(function(done) {
-		this.lines = ["one", "two", "three"];
-		this.version = 42;
-		this.update = {
-			doc: this.doc_id,
-			meta: { user_id: 'last-author-fake-id' },
-			op: [{
-				i: "one and a half\n",
-				p: 4
-			}],
-			v: this.version
-		};
-		this.result = ["one", "one and a half", "two", "three"];
-		return DocUpdaterApp.ensureRunning(done);
-	});
+describe('Flushing a doc to Mongo', function () {
+  before(function (done) {
+    this.lines = ['one', 'two', 'three']
+    this.version = 42
+    this.update = {
+      doc: this.doc_id,
+      meta: { user_id: 'last-author-fake-id' },
+      op: [
+        {
+          i: 'one and a half\n',
+          p: 4
+        }
+      ],
+      v: this.version
+    }
+    this.result = ['one', 'one and a half', 'two', 'three']
+    return DocUpdaterApp.ensureRunning(done)
+  })
 
-	describe("when the updated doc exists in the doc updater", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			sinon.spy(MockWebApi, "setDocument");
+  describe('when the updated doc exists in the doc updater', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      sinon.spy(MockWebApi, 'setDocument')
 
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			return DocUpdaterClient.sendUpdates(this.project_id, this.doc_id, [this.update], error => {
-				if (error != null) { throw error; }
-				return setTimeout(() => {
-					return DocUpdaterClient.flushDoc(this.project_id, this.doc_id, done);
-				}
-				, 200);
-			});
-		});
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      return DocUpdaterClient.sendUpdates(
+        this.project_id,
+        this.doc_id,
+        [this.update],
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return setTimeout(() => {
+            return DocUpdaterClient.flushDoc(this.project_id, this.doc_id, done)
+          }, 200)
+        }
+      )
+    })
 
-		after(function() { return MockWebApi.setDocument.restore(); });
+    after(function () {
+      return MockWebApi.setDocument.restore()
+    })
 
-		it("should flush the updated doc lines and version to the web api", function() {
-			return MockWebApi.setDocument
-				.calledWith(this.project_id, this.doc_id, this.result, this.version + 1)
-				.should.equal(true);
-		});
+    it('should flush the updated doc lines and version to the web api', function () {
+      return MockWebApi.setDocument
+        .calledWith(this.project_id, this.doc_id, this.result, this.version + 1)
+        .should.equal(true)
+    })
 
-		return it("should flush the last update author and time to the web api", function() {
-			const lastUpdatedAt = MockWebApi.setDocument.lastCall.args[5];
-			parseInt(lastUpdatedAt).should.be.closeTo((new Date()).getTime(), 30000);
+    return it('should flush the last update author and time to the web api', function () {
+      const lastUpdatedAt = MockWebApi.setDocument.lastCall.args[5]
+      parseInt(lastUpdatedAt).should.be.closeTo(new Date().getTime(), 30000)
 
-			const lastUpdatedBy = MockWebApi.setDocument.lastCall.args[6];
-			return lastUpdatedBy.should.equal('last-author-fake-id');
-		});
-	});
+      const lastUpdatedBy = MockWebApi.setDocument.lastCall.args[6]
+      return lastUpdatedBy.should.equal('last-author-fake-id')
+    })
+  })
 
-	describe("when the doc does not exist in the doc updater", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {
-				lines: this.lines
-			});
-			sinon.spy(MockWebApi, "setDocument");
-			return DocUpdaterClient.flushDoc(this.project_id, this.doc_id, done);
-		});
+  describe('when the doc does not exist in the doc updater', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines
+      })
+      sinon.spy(MockWebApi, 'setDocument')
+      return DocUpdaterClient.flushDoc(this.project_id, this.doc_id, done)
+    })
 
-		after(function() { return MockWebApi.setDocument.restore(); });
+    after(function () {
+      return MockWebApi.setDocument.restore()
+    })
 
-		return it("should not flush the doc to the web api", function() { return MockWebApi.setDocument.called.should.equal(false); });
-	});
+    return it('should not flush the doc to the web api', function () {
+      return MockWebApi.setDocument.called.should.equal(false)
+    })
+  })
 
-	return describe("when the web api http request takes a long time on first request", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {
-				lines: this.lines,
-				version: this.version
-			});
-			let t = 30000;
-			sinon.stub(MockWebApi, "setDocument", (project_id, doc_id, lines, version, ranges, lastUpdatedAt, lastUpdatedBy, callback) => {
-				if (callback == null) { callback = function(error) {}; }
-				setTimeout(callback, t);
-				return t = 0;
-			});
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, done);
-		});
+  return describe('when the web api http request takes a long time on first request', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      let t = 30000
+      sinon.stub(
+        MockWebApi,
+        'setDocument',
+        (
+          project_id,
+          doc_id,
+          lines,
+          version,
+          ranges,
+          lastUpdatedAt,
+          lastUpdatedBy,
+          callback
+        ) => {
+          if (callback == null) {
+            callback = function (error) {}
+          }
+          setTimeout(callback, t)
+          return (t = 0)
+        }
+      )
+      return DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, done)
+    })
 
-		after(function() { return MockWebApi.setDocument.restore(); });
-		
-		return it("should still work", function(done) {
-			const start = Date.now();
-			return DocUpdaterClient.flushDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				res.statusCode.should.equal(204);
-				const delta = Date.now() - start;
-				expect(delta).to.be.below(20000);
-				return done();
-			});
-		});
-	});
-});
+    after(function () {
+      return MockWebApi.setDocument.restore()
+    })
+
+    return it('should still work', function (done) {
+      const start = Date.now()
+      return DocUpdaterClient.flushDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          res.statusCode.should.equal(204)
+          const delta = Date.now() - start
+          expect(delta).to.be.below(20000)
+          return done()
+        }
+      )
+    })
+  })
+})

+ 270 - 176
services/document-updater/test/acceptance/js/GettingADocumentTests.js

@@ -11,184 +11,278 @@
  * 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");
-chai.should();
-const {
-    expect
-} = chai;
-
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
-
-describe("Getting a document", function() {
-	before(function(done) {
-		this.lines = ["one", "two", "three"];
-		this.version = 42;
-		return DocUpdaterApp.ensureRunning(done);
-	});
-
-	describe("when the document is not loaded", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			sinon.spy(MockWebApi, "getDocument");
-
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-
-			return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, returnedDoc) => { this.returnedDoc = returnedDoc; return done(); });
-		});
-
-		after(function() { return MockWebApi.getDocument.restore(); });
-
-		it("should load the document from the web API", function() {
-			return MockWebApi.getDocument
-				.calledWith(this.project_id, this.doc_id)
-				.should.equal(true);
-		});
-		
-		it("should return the document lines", function() {
-			return this.returnedDoc.lines.should.deep.equal(this.lines);
-		});
-
-		return it("should return the document at its current version", function() {
-			return this.returnedDoc.version.should.equal(this.version);
-		});
-	});
-
-	describe("when the document is already loaded", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-				if (error != null) { throw error; }
-				sinon.spy(MockWebApi, "getDocument");
-				return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, returnedDoc) => {	this.returnedDoc = returnedDoc; return done(); });
-			});
-		});
-
-		after(function() { return MockWebApi.getDocument.restore(); });
-
-		it("should not load the document from the web API", function() { return MockWebApi.getDocument.called.should.equal(false); });
-
-		return it("should return the document lines", function() {
-			return this.returnedDoc.lines.should.deep.equal(this.lines);
-		});
-	});
-
-	describe("when the request asks for some recent ops", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {
-				lines: (this.lines = ["one", "two", "three"])
-			});
-
-			this.updates = __range__(0, 199, true).map((v) => ({
-				doc_id: this.doc_id,
-				op: [{i: v.toString(), p: 0}],
-				v
-			}));
-
-			return DocUpdaterClient.sendUpdates(this.project_id, this.doc_id, this.updates, error => {
-				if (error != null) { throw error; }
-				sinon.spy(MockWebApi, "getDocument");
-				return done();
-			});
-		});
-
-		after(function() { return MockWebApi.getDocument.restore(); });
-			
-		describe("when the ops are loaded", function() {
-			before(function(done) {
-				return DocUpdaterClient.getDocAndRecentOps(this.project_id, this.doc_id, 190, (error, res, returnedDoc) => { this.returnedDoc = returnedDoc; return done(); });
-			});
-
-			return it("should return the recent ops", function() {
-				this.returnedDoc.ops.length.should.equal(10);
-				return Array.from(this.updates.slice(190, -1)).map((update, i) =>
-					this.returnedDoc.ops[i].op.should.deep.equal(update.op));
-			});
-		});
-					
-		return describe("when the ops are not all loaded", function() {
-			before(function(done) {
-				// We only track 100 ops
-				return DocUpdaterClient.getDocAndRecentOps(this.project_id, this.doc_id, 10, (error, res, returnedDoc) => { this.res = res; this.returnedDoc = returnedDoc; return done(); });
-			});
-
-			return it("should return UnprocessableEntity", function() {
-				return this.res.statusCode.should.equal(422);
-			});
-		});
-	});
-
-	describe("when the document does not exist", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				this.statusCode = res.statusCode;
-				return done();
-			});
-		});
-
-		return it("should return 404", function() {
-			return this.statusCode.should.equal(404);
-		});
-	});
-
-	describe("when the web api returns an error", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			sinon.stub(MockWebApi, "getDocument", (project_id, doc_id, callback) => {
-				if (callback == null) { callback = function(error, doc) {}; }
-				return callback(new Error("oops"));
-			});
-			return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				this.statusCode = res.statusCode;
-				return done();
-			});
-		});
-
-		after(function() { return MockWebApi.getDocument.restore(); });
-
-		return it("should return 500", function() {
-			return this.statusCode.should.equal(500);
-		});
-	});
-
-	return describe("when the web api http request takes a long time", function() {
-		before(function(done) {
-			this.timeout = 10000;
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			sinon.stub(MockWebApi, "getDocument", (project_id, doc_id, callback) => {
-				if (callback == null) { callback = function(error, doc) {}; }
-				return setTimeout(callback, 30000);
-			});
-			return done();
-		});
-
-		after(function() { return MockWebApi.getDocument.restore(); });
-		
-		return it("should return quickly(ish)", function(done) {
-			const start = Date.now();
-			return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				res.statusCode.should.equal(500);
-				const delta = Date.now() - start;
-				expect(delta).to.be.below(20000);
-				return done();
-			});
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+const { expect } = chai
 
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
+
+describe('Getting a document', function () {
+  before(function (done) {
+    this.lines = ['one', 'two', 'three']
+    this.version = 42
+    return DocUpdaterApp.ensureRunning(done)
+  })
+
+  describe('when the document is not loaded', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      sinon.spy(MockWebApi, 'getDocument')
+
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, returnedDoc) => {
+          this.returnedDoc = returnedDoc
+          return done()
+        }
+      )
+    })
+
+    after(function () {
+      return MockWebApi.getDocument.restore()
+    })
+
+    it('should load the document from the web API', function () {
+      return MockWebApi.getDocument
+        .calledWith(this.project_id, this.doc_id)
+        .should.equal(true)
+    })
+
+    it('should return the document lines', function () {
+      return this.returnedDoc.lines.should.deep.equal(this.lines)
+    })
+
+    return it('should return the document at its current version', function () {
+      return this.returnedDoc.version.should.equal(this.version)
+    })
+  })
+
+  describe('when the document is already loaded', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc_id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          sinon.spy(MockWebApi, 'getDocument')
+          return DocUpdaterClient.getDoc(
+            this.project_id,
+            this.doc_id,
+            (error, res, returnedDoc) => {
+              this.returnedDoc = returnedDoc
+              return done()
+            }
+          )
+        }
+      )
+    })
+
+    after(function () {
+      return MockWebApi.getDocument.restore()
+    })
+
+    it('should not load the document from the web API', function () {
+      return MockWebApi.getDocument.called.should.equal(false)
+    })
+
+    return it('should return the document lines', function () {
+      return this.returnedDoc.lines.should.deep.equal(this.lines)
+    })
+  })
+
+  describe('when the request asks for some recent ops', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: (this.lines = ['one', 'two', 'three'])
+      })
+
+      this.updates = __range__(0, 199, true).map((v) => ({
+        doc_id: this.doc_id,
+        op: [{ i: v.toString(), p: 0 }],
+        v
+      }))
+
+      return DocUpdaterClient.sendUpdates(
+        this.project_id,
+        this.doc_id,
+        this.updates,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          sinon.spy(MockWebApi, 'getDocument')
+          return done()
+        }
+      )
+    })
+
+    after(function () {
+      return MockWebApi.getDocument.restore()
+    })
+
+    describe('when the ops are loaded', function () {
+      before(function (done) {
+        return DocUpdaterClient.getDocAndRecentOps(
+          this.project_id,
+          this.doc_id,
+          190,
+          (error, res, returnedDoc) => {
+            this.returnedDoc = returnedDoc
+            return done()
+          }
+        )
+      })
+
+      return it('should return the recent ops', function () {
+        this.returnedDoc.ops.length.should.equal(10)
+        return Array.from(this.updates.slice(190, -1)).map((update, i) =>
+          this.returnedDoc.ops[i].op.should.deep.equal(update.op)
+        )
+      })
+    })
+
+    return describe('when the ops are not all loaded', function () {
+      before(function (done) {
+        // We only track 100 ops
+        return DocUpdaterClient.getDocAndRecentOps(
+          this.project_id,
+          this.doc_id,
+          10,
+          (error, res, returnedDoc) => {
+            this.res = res
+            this.returnedDoc = returnedDoc
+            return done()
+          }
+        )
+      })
+
+      return it('should return UnprocessableEntity', function () {
+        return this.res.statusCode.should.equal(422)
+      })
+    })
+  })
+
+  describe('when the document does not exist', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          this.statusCode = res.statusCode
+          return done()
+        }
+      )
+    })
+
+    return it('should return 404', function () {
+      return this.statusCode.should.equal(404)
+    })
+  })
+
+  describe('when the web api returns an error', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      sinon.stub(MockWebApi, 'getDocument', (project_id, doc_id, callback) => {
+        if (callback == null) {
+          callback = function (error, doc) {}
+        }
+        return callback(new Error('oops'))
+      })
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          this.statusCode = res.statusCode
+          return done()
+        }
+      )
+    })
+
+    after(function () {
+      return MockWebApi.getDocument.restore()
+    })
+
+    return it('should return 500', function () {
+      return this.statusCode.should.equal(500)
+    })
+  })
+
+  return describe('when the web api http request takes a long time', function () {
+    before(function (done) {
+      this.timeout = 10000
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      sinon.stub(MockWebApi, 'getDocument', (project_id, doc_id, callback) => {
+        if (callback == null) {
+          callback = function (error, doc) {}
+        }
+        return setTimeout(callback, 30000)
+      })
+      return done()
+    })
+
+    after(function () {
+      return MockWebApi.getDocument.restore()
+    })
+
+    return it('should return quickly(ish)', function (done) {
+      const start = Date.now()
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          res.statusCode.should.equal(500)
+          const delta = Date.now() - start
+          expect(delta).to.be.below(20000)
+          return done()
+        }
+      )
+    })
+  })
+})
 
 function __range__(left, right, inclusive) {
-  const range = [];
-  const ascending = left < right;
-  const end = !inclusive ? right : ascending ? right + 1 : right - 1;
+  const range = []
+  const ascending = left < right
+  const end = !inclusive ? right : ascending ? right + 1 : right - 1
   for (let i = left; ascending ? i < end : i > end; ascending ? i++ : i--) {
-    range.push(i);
+    range.push(i)
   }
-  return range;
-}
+  return range
+}

+ 148 - 90
services/document-updater/test/acceptance/js/GettingProjectDocsTests.js

@@ -11,105 +11,163 @@
  * 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");
-chai.should();
-const {
-    expect
-} = chai;
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+const { expect } = chai
 
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
 
-describe("Getting documents for project", function() {
-	before(function(done) {
-		this.lines = ["one", "two", "three"];
-		this.version = 42;
-		return DocUpdaterApp.ensureRunning(done);
-	});
+describe('Getting documents for project', function () {
+  before(function (done) {
+    this.lines = ['one', 'two', 'three']
+    this.version = 42
+    return DocUpdaterApp.ensureRunning(done)
+  })
 
-	describe("when project state hash does not match", function() {
-		before(function(done) {
-			this.projectStateHash = DocUpdaterClient.randomId();
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
+  describe('when project state hash does not match', function () {
+    before(function (done) {
+      this.projectStateHash = DocUpdaterClient.randomId()
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
 
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.getProjectDocs(this.project_id, this.projectStateHash, (error, res, returnedDocs)	=> {
-					this.res = res;
-					this.returnedDocs = returnedDocs;
-					return done();
-				});
-			});
-		});
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc_id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.getProjectDocs(
+            this.project_id,
+            this.projectStateHash,
+            (error, res, returnedDocs) => {
+              this.res = res
+              this.returnedDocs = returnedDocs
+              return done()
+            }
+          )
+        }
+      )
+    })
 
-		return it("should return a 409 Conflict response", function() {
-			return this.res.statusCode.should.equal(409);
-		});
-	});
+    return it('should return a 409 Conflict response', function () {
+      return this.res.statusCode.should.equal(409)
+    })
+  })
 
+  describe('when project state hash matches', function () {
+    before(function (done) {
+      this.projectStateHash = DocUpdaterClient.randomId()
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
 
-	describe("when project state hash matches", function() {
-		before(function(done) {
-			this.projectStateHash = DocUpdaterClient.randomId();
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc_id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.getProjectDocs(
+            this.project_id,
+            this.projectStateHash,
+            (error, res0, returnedDocs0) => {
+              // set the hash
+              this.res0 = res0
+              this.returnedDocs0 = returnedDocs0
+              return DocUpdaterClient.getProjectDocs(
+                this.project_id,
+                this.projectStateHash,
+                (error, res, returnedDocs) => {
+                  // the hash should now match
+                  this.res = res
+                  this.returnedDocs = returnedDocs
+                  return done()
+                }
+              )
+            }
+          )
+        }
+      )
+    })
 
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.getProjectDocs(this.project_id, this.projectStateHash, (error, res0, returnedDocs0)	=> {
-					// set the hash
-					this.res0 = res0;
-					this.returnedDocs0 = returnedDocs0;
-					return DocUpdaterClient.getProjectDocs(this.project_id, this.projectStateHash, (error, res, returnedDocs)	=> {
-						// the hash should now match
-						this.res = res;
-						this.returnedDocs = returnedDocs;
-						return done();
-					});
-				});
-			});
-		});
+    it('should return a 200 response', function () {
+      return this.res.statusCode.should.equal(200)
+    })
 
-		it("should return a 200 response", function() {
-			return this.res.statusCode.should.equal(200);
-		});
+    return it('should return the documents', function () {
+      return this.returnedDocs.should.deep.equal([
+        { _id: this.doc_id, lines: this.lines, v: this.version }
+      ])
+    })
+  })
 
-		return it("should return the documents", function() {
-			return this.returnedDocs.should.deep.equal([ {_id: this.doc_id, lines: this.lines, v: this.version} ]);
-	});
-});
+  return describe('when the doc has been removed', function () {
+    before(function (done) {
+      this.projectStateHash = DocUpdaterClient.randomId()
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
 
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc_id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.getProjectDocs(
+            this.project_id,
+            this.projectStateHash,
+            (error, res0, returnedDocs0) => {
+              // set the hash
+              this.res0 = res0
+              this.returnedDocs0 = returnedDocs0
+              return DocUpdaterClient.deleteDoc(
+                this.project_id,
+                this.doc_id,
+                (error, res, body) => {
+                  // delete the doc
+                  return DocUpdaterClient.getProjectDocs(
+                    this.project_id,
+                    this.projectStateHash,
+                    (error, res1, returnedDocs) => {
+                      // the hash would match, but the doc has been deleted
+                      this.res = res1
+                      this.returnedDocs = returnedDocs
+                      return done()
+                    }
+                  )
+                }
+              )
+            }
+          )
+        }
+      )
+    })
 
-	return describe("when the doc has been removed", function() {
-		before(function(done) {
-			this.projectStateHash = DocUpdaterClient.randomId();
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.getProjectDocs(this.project_id, this.projectStateHash, (error, res0, returnedDocs0)	=> {
-					// set the hash
-					this.res0 = res0;
-					this.returnedDocs0 = returnedDocs0;
-					return DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, (error, res, body) => {
-					// delete the doc
-						return DocUpdaterClient.getProjectDocs(this.project_id, this.projectStateHash, (error, res1, returnedDocs)	=> {
-							// the hash would match, but the doc has been deleted
-							this.res = res1;
-							this.returnedDocs = returnedDocs;
-							return done();
-						});
-					});
-				});
-			});
-		});
-
-		return it("should return a 409 Conflict response", function() {
-			return this.res.statusCode.should.equal(409);
-		});
-	});
-});
+    return it('should return a 409 Conflict response', function () {
+      return this.res.statusCode.should.equal(409)
+    })
+  })
+})

+ 674 - 452
services/document-updater/test/acceptance/js/RangesTests.js

@@ -11,463 +11,685 @@
  * 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");
-chai.should();
-const {
-    expect
-} = chai;
-const async = require("async");
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+const { expect } = chai
+const async = require('async')
 
-const {db, ObjectId} = require("../../../app/js/mongojs");
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
+const { db, ObjectId } = require('../../../app/js/mongojs')
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
 
-describe("Ranges", function() {
-	before(function(done) { return DocUpdaterApp.ensureRunning(done); });
+describe('Ranges', function () {
+  before(function (done) {
+    return DocUpdaterApp.ensureRunning(done)
+  })
 
-	describe("tracking changes from ops", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.user_id = DocUpdaterClient.randomId();
-			this.id_seed = "587357bd35e64f6157";
-			this.doc = {
-				id: DocUpdaterClient.randomId(),
-				lines: ["aaa"]
-			};
-			this.updates = [{
-				doc: this.doc.id,
-				op: [{ i: "123", p: 1 }],
-				v: 0,
-				meta: { user_id: this.user_id }
-			}, {
-				doc: this.doc.id,
-				op: [{ i: "456", p: 5 }],
-				v: 1,
-				meta: { user_id: this.user_id, tc: this.id_seed }
-			}, {
-				doc: this.doc.id,
-				op: [{ d: "12", p: 1 }],
-				v: 2,
-				meta: { user_id: this.user_id }
-			}];
-			MockWebApi.insertDoc(this.project_id, this.doc.id, {
-				lines: this.doc.lines,
-				version: 0
-			});
-			const jobs = [];
-			for (const update of Array.from(this.updates)) {
-				(update => {
-					return jobs.push(callback => DocUpdaterClient.sendUpdate(this.project_id, this.doc.id, update, callback));
-				})(update);
-			}
-			
-			return DocUpdaterApp.ensureRunning(error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
-					if (error != null) { throw error; }
-					return async.series(jobs, (error) => {
-						if (error != null) { throw error; }
-						return done();
-					});
-				});
-			});
-		});
-		
-		it("should update the ranges", function(done) {
-			return DocUpdaterClient.getDoc(this.project_id, this.doc.id, (error, res, data) => {
-				if (error != null) { throw error; }
-				const {
-                    ranges
-                } = data;
-				const change = ranges.changes[0];
-				change.op.should.deep.equal({ i: "456", p: 3 });
-				change.id.should.equal(this.id_seed + "000001");
-				change.metadata.user_id.should.equal(this.user_id);
-				return done();
-			});
-		});
-	
-		return describe("Adding comments", function() {
-			describe("standalone", function() {
-				before(function(done) {
-					this.project_id = DocUpdaterClient.randomId();
-					this.user_id = DocUpdaterClient.randomId();
-					this.doc = {
-						id: DocUpdaterClient.randomId(),
-						lines: ["foo bar baz"]
-					};
-					this.updates = [{
-						doc: this.doc.id,
-						op: [{ c: "bar", p: 4, t: (this.tid = DocUpdaterClient.randomId()) }],
-						v: 0
-					}];
-					MockWebApi.insertDoc(this.project_id, this.doc.id, {
-						lines: this.doc.lines,
-						version: 0
-					});
-					const jobs = [];
-					for (const update of Array.from(this.updates)) {
-						(update => {
-							return jobs.push(callback => DocUpdaterClient.sendUpdate(this.project_id, this.doc.id, update, callback));
-						})(update);
-					}
-					return DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
-						if (error != null) { throw error; }
-						return async.series(jobs, (error) => {
-							if (error != null) { throw error; }
-							return setTimeout(done, 200);
-						});
-					});
-				});
-				
-				return it("should update the ranges", function(done) {
-					return DocUpdaterClient.getDoc(this.project_id, this.doc.id, (error, res, data) => {
-						if (error != null) { throw error; }
-						const {
-                            ranges
-                        } = data;
-						const comment = ranges.comments[0];
-						comment.op.should.deep.equal({ c: "bar", p: 4, t: this.tid });
-						comment.id.should.equal(this.tid);
-						return done();
-					});
-				});
-			});
+  describe('tracking changes from ops', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.user_id = DocUpdaterClient.randomId()
+      this.id_seed = '587357bd35e64f6157'
+      this.doc = {
+        id: DocUpdaterClient.randomId(),
+        lines: ['aaa']
+      }
+      this.updates = [
+        {
+          doc: this.doc.id,
+          op: [{ i: '123', p: 1 }],
+          v: 0,
+          meta: { user_id: this.user_id }
+        },
+        {
+          doc: this.doc.id,
+          op: [{ i: '456', p: 5 }],
+          v: 1,
+          meta: { user_id: this.user_id, tc: this.id_seed }
+        },
+        {
+          doc: this.doc.id,
+          op: [{ d: '12', p: 1 }],
+          v: 2,
+          meta: { user_id: this.user_id }
+        }
+      ]
+      MockWebApi.insertDoc(this.project_id, this.doc.id, {
+        lines: this.doc.lines,
+        version: 0
+      })
+      const jobs = []
+      for (const update of Array.from(this.updates)) {
+        ;((update) => {
+          return jobs.push((callback) =>
+            DocUpdaterClient.sendUpdate(
+              this.project_id,
+              this.doc.id,
+              update,
+              callback
+            )
+          )
+        })(update)
+      }
 
-			return describe("with conflicting ops needing OT", function() {
-				before(function(done) {
-					this.project_id = DocUpdaterClient.randomId();
-					this.user_id = DocUpdaterClient.randomId();
-					this.doc = {
-						id: DocUpdaterClient.randomId(),
-						lines: ["foo bar baz"]
-					};
-					this.updates = [{
-						doc: this.doc.id,
-						op: [{ i: "ABC", p: 3 }],
-						v: 0,
-						meta: { user_id: this.user_id }
-					}, {
-						doc: this.doc.id,
-						op: [{ c: "bar", p: 4, t: (this.tid = DocUpdaterClient.randomId()) }],
-						v: 0
-					}];
-					MockWebApi.insertDoc(this.project_id, this.doc.id, {
-						lines: this.doc.lines,
-						version: 0
-					});
-					const jobs = [];
-					for (const update of Array.from(this.updates)) {
-						(update => {
-							return jobs.push(callback => DocUpdaterClient.sendUpdate(this.project_id, this.doc.id, update, callback));
-						})(update);
-					}
-					return DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
-						if (error != null) { throw error; }
-						return async.series(jobs, (error) => {
-							if (error != null) { throw error; }
-							return setTimeout(done, 200);
-						});
-					});
-				});
-				
-				return it("should update the comments with the OT shifted comment", function(done) {
-					return DocUpdaterClient.getDoc(this.project_id, this.doc.id, (error, res, data) => {
-						if (error != null) { throw error; }
-						const {
-                            ranges
-                        } = data;
-						const comment = ranges.comments[0];
-						comment.op.should.deep.equal({ c: "bar", p: 7, t: this.tid });
-						return done();
-					});
-				});
-			});
-		});
-	});
+      return DocUpdaterApp.ensureRunning((error) => {
+        if (error != null) {
+          throw error
+        }
+        return DocUpdaterClient.preloadDoc(
+          this.project_id,
+          this.doc.id,
+          (error) => {
+            if (error != null) {
+              throw error
+            }
+            return async.series(jobs, (error) => {
+              if (error != null) {
+                throw error
+              }
+              return done()
+            })
+          }
+        )
+      })
+    })
 
-	describe("Loading ranges from persistence layer", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.user_id = DocUpdaterClient.randomId();
-			this.id_seed = "587357bd35e64f6157";
-			this.doc = {
-				id: DocUpdaterClient.randomId(),
-				lines: ["a123aa"]
-			};
-			this.update = {
-				doc: this.doc.id,
-				op: [{ i: "456", p: 5 }],
-				v: 0,
-				meta: { user_id: this.user_id, tc: this.id_seed }
-			};
-			MockWebApi.insertDoc(this.project_id, this.doc.id, {
-				lines: this.doc.lines,
-				version: 0,
-				ranges: {
-					changes: [{
-						op: { i: "123", p: 1 },
-						metadata: {
-							user_id: this.user_id,
-							ts: new Date()
-						}
-					}]
-				}
-			});
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.sendUpdate(this.project_id, this.doc.id, this.update, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 200);
-				});
-			});
-		});
-		
-		it("should have preloaded the existing ranges", function(done) {
-			return DocUpdaterClient.getDoc(this.project_id, this.doc.id, (error, res, data) => {
-				if (error != null) { throw error; }
-				const {changes} = data.ranges;
-				changes[0].op.should.deep.equal({ i: "123", p: 1 });
-				changes[1].op.should.deep.equal({ i: "456", p: 5 });
-				return done();
-			});
-		});
-		
-		return it("should flush the ranges to the persistence layer again", function(done) {
-			return DocUpdaterClient.flushDoc(this.project_id, this.doc.id, error => {
-				if (error != null) { throw error; }
-				return MockWebApi.getDocument(this.project_id, this.doc.id, (error, doc) => {
-					const {changes} = doc.ranges;
-					changes[0].op.should.deep.equal({ i: "123", p: 1 });
-					changes[1].op.should.deep.equal({ i: "456", p: 5 });
-					return done();
-				});
-			});
-		});
-	});
+    it('should update the ranges', function (done) {
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc.id,
+        (error, res, data) => {
+          if (error != null) {
+            throw error
+          }
+          const { ranges } = data
+          const change = ranges.changes[0]
+          change.op.should.deep.equal({ i: '456', p: 3 })
+          change.id.should.equal(this.id_seed + '000001')
+          change.metadata.user_id.should.equal(this.user_id)
+          return done()
+        }
+      )
+    })
 
-	describe("accepting a change", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.user_id = DocUpdaterClient.randomId();
-			this.id_seed = "587357bd35e64f6157";
-			this.doc = {
-				id: DocUpdaterClient.randomId(),
-				lines: ["aaa"]
-			};
-			this.update = {
-				doc: this.doc.id,
-				op: [{ i: "456", p: 1 }],
-				v: 0,
-				meta: { user_id: this.user_id, tc: this.id_seed }
-			};
-			MockWebApi.insertDoc(this.project_id, this.doc.id, {
-				lines: this.doc.lines,
-				version: 0
-			});
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.sendUpdate(this.project_id, this.doc.id, this.update, error => {
-					if (error != null) { throw error; }
-					return setTimeout(() => {
-						return DocUpdaterClient.getDoc(this.project_id, this.doc.id, (error, res, data) => {
-							if (error != null) { throw error; }
-							const {
-                                ranges
-                            } = data;
-							const change = ranges.changes[0];
-							change.op.should.deep.equal({ i: "456", p: 1 });
-							change.id.should.equal(this.id_seed + "000001");
-							change.metadata.user_id.should.equal(this.user_id);
-							return done();
-						});
-					}
-					, 200);
-				});
-			});
-		});
-		
-		return it("should remove the change after accepting", function(done) {
-			return DocUpdaterClient.acceptChange(this.project_id, this.doc.id, this.id_seed + "000001", error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.getDoc(this.project_id, this.doc.id, (error, res, data) => {
-					if (error != null) { throw error; }
-					expect(data.ranges.changes).to.be.undefined;
-					return done();
-				});
-			});
-		});
-	});
+    return describe('Adding comments', function () {
+      describe('standalone', function () {
+        before(function (done) {
+          this.project_id = DocUpdaterClient.randomId()
+          this.user_id = DocUpdaterClient.randomId()
+          this.doc = {
+            id: DocUpdaterClient.randomId(),
+            lines: ['foo bar baz']
+          }
+          this.updates = [
+            {
+              doc: this.doc.id,
+              op: [
+                { c: 'bar', p: 4, t: (this.tid = DocUpdaterClient.randomId()) }
+              ],
+              v: 0
+            }
+          ]
+          MockWebApi.insertDoc(this.project_id, this.doc.id, {
+            lines: this.doc.lines,
+            version: 0
+          })
+          const jobs = []
+          for (const update of Array.from(this.updates)) {
+            ;((update) => {
+              return jobs.push((callback) =>
+                DocUpdaterClient.sendUpdate(
+                  this.project_id,
+                  this.doc.id,
+                  update,
+                  callback
+                )
+              )
+            })(update)
+          }
+          return DocUpdaterClient.preloadDoc(
+            this.project_id,
+            this.doc.id,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              return async.series(jobs, (error) => {
+                if (error != null) {
+                  throw error
+                }
+                return setTimeout(done, 200)
+              })
+            }
+          )
+        })
 
-	describe("deleting a comment range", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.user_id = DocUpdaterClient.randomId();
-			this.doc = {
-				id: DocUpdaterClient.randomId(),
-				lines: ["foo bar"]
-			};
-			this.update = {
-				doc: this.doc.id,
-				op: [{ c: "bar", p: 4, t: (this.tid = DocUpdaterClient.randomId()) }],
-				v: 0
-			};
-			MockWebApi.insertDoc(this.project_id, this.doc.id, {
-				lines: this.doc.lines,
-				version: 0
-			});
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.sendUpdate(this.project_id, this.doc.id, this.update, error => {
-					if (error != null) { throw error; }
-					return setTimeout(() => {
-						return DocUpdaterClient.getDoc(this.project_id, this.doc.id, (error, res, data) => {
-							if (error != null) { throw error; }
-							const {
-                                ranges
-                            } = data;
-							const change = ranges.comments[0];
-							change.op.should.deep.equal({ c: "bar", p: 4, t: this.tid });
-							change.id.should.equal(this.tid);
-							return done();
-						});
-					}
-					, 200);
-				});
-			});
-		});
-		
-		return it("should remove the comment range", function(done) {
-			return DocUpdaterClient.removeComment(this.project_id, this.doc.id, this.tid, (error, res) => {
-				if (error != null) { throw error; }
-				expect(res.statusCode).to.equal(204);
-				return DocUpdaterClient.getDoc(this.project_id, this.doc.id, (error, res, data) => {
-					if (error != null) { throw error; }
-					expect(data.ranges.comments).to.be.undefined;
-					return done();
-				});
-			});
-		});
-	});
-					
-	describe("tripping range size limit", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.user_id = DocUpdaterClient.randomId();
-			this.id_seed = DocUpdaterClient.randomId();
-			this.doc = {
-				id: DocUpdaterClient.randomId(),
-				lines: ["aaa"]
-			};
-			this.i = new Array(3 * 1024 * 1024).join("a");
-			this.updates = [{
-				doc: this.doc.id,
-				op: [{ i: this.i, p: 1 }],
-				v: 0,
-				meta: { user_id: this.user_id, tc: this.id_seed }
-			}];
-			MockWebApi.insertDoc(this.project_id, this.doc.id, {
-				lines: this.doc.lines,
-				version: 0
-			});
-			const jobs = [];
-			for (const update of Array.from(this.updates)) {
-				(update => {
-					return jobs.push(callback => DocUpdaterClient.sendUpdate(this.project_id, this.doc.id, update, callback));
-				})(update);
-			}
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc.id, error => {
-				if (error != null) { throw error; }
-				return async.series(jobs, (error) => {
-					if (error != null) { throw error; }
-					return setTimeout(done, 200);
-				});
-			});
-		});
-		
-		return it("should not update the ranges", function(done) {
-			return DocUpdaterClient.getDoc(this.project_id, this.doc.id, (error, res, data) => {
-				if (error != null) { throw error; }
-				const {
-                    ranges
-                } = data;
-				expect(ranges.changes).to.be.undefined;
-				return done();
-			});
-		});
-	});
+        return it('should update the ranges', function (done) {
+          return DocUpdaterClient.getDoc(
+            this.project_id,
+            this.doc.id,
+            (error, res, data) => {
+              if (error != null) {
+                throw error
+              }
+              const { ranges } = data
+              const comment = ranges.comments[0]
+              comment.op.should.deep.equal({ c: 'bar', p: 4, t: this.tid })
+              comment.id.should.equal(this.tid)
+              return done()
+            }
+          )
+        })
+      })
 
-	return describe("deleting text surrounding a comment", function() {
-		before(function(done) {
-			this.project_id = DocUpdaterClient.randomId();
-			this.user_id = DocUpdaterClient.randomId();
-			this.doc_id = DocUpdaterClient.randomId();
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {
-				lines: ["foo bar baz"],
-				version: 0,
-				ranges: {
-					comments: [{
-						op: { c: "a", p: 5, tid: (this.tid = DocUpdaterClient.randomId()) },
-						metadata: {
-							user_id: this.user_id,
-							ts: new Date()
-						}
-					}]
-				}
-			});
-			this.updates = [{
-				doc: this.doc_id,
-				op: [{ d: "foo ", p: 0 }],
-				v: 0,
-				meta: { user_id: this.user_id }
-			}, {
-				doc: this.doc_id,
-				op: [{ d: "bar ", p: 0 }],
-				v: 1,
-				meta: { user_id: this.user_id }
-			}];
-			const jobs = [];
-			for (const update of Array.from(this.updates)) {
-				(update => {
-					return jobs.push(callback => DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, update, callback));
-				})(update);
-			}
-			return DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-				if (error != null) { throw error; }
-				return async.series(jobs, function(error) {
-					if (error != null) { throw error; }
-					return setTimeout(() => {
-						return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, data) => {
-							if (error != null) { throw error; }
-							return done();
-						});
-					}
-					, 200);
-				});
-			});
-		});
-		
-		return it("should write a snapshot from before the destructive change", function(done) {
-			return DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, data) => {
-				if (error != null) { return done(error); }
-				return db.docSnapshots.find({
-					project_id: ObjectId(this.project_id),
-					doc_id: ObjectId(this.doc_id)
-				}, (error, docSnapshots) => {
-					if (error != null) { return done(error); }
-					expect(docSnapshots.length).to.equal(1);
-					expect(docSnapshots[0].version).to.equal(1);
-					expect(docSnapshots[0].lines).to.deep.equal(["bar baz"]);
-					expect(docSnapshots[0].ranges.comments[0].op).to.deep.equal({
-						c: "a",
-						p: 1,
-						tid: this.tid
-					});
-					return done();
-				});
-			});
-		});
-	});
-});
+      return describe('with conflicting ops needing OT', function () {
+        before(function (done) {
+          this.project_id = DocUpdaterClient.randomId()
+          this.user_id = DocUpdaterClient.randomId()
+          this.doc = {
+            id: DocUpdaterClient.randomId(),
+            lines: ['foo bar baz']
+          }
+          this.updates = [
+            {
+              doc: this.doc.id,
+              op: [{ i: 'ABC', p: 3 }],
+              v: 0,
+              meta: { user_id: this.user_id }
+            },
+            {
+              doc: this.doc.id,
+              op: [
+                { c: 'bar', p: 4, t: (this.tid = DocUpdaterClient.randomId()) }
+              ],
+              v: 0
+            }
+          ]
+          MockWebApi.insertDoc(this.project_id, this.doc.id, {
+            lines: this.doc.lines,
+            version: 0
+          })
+          const jobs = []
+          for (const update of Array.from(this.updates)) {
+            ;((update) => {
+              return jobs.push((callback) =>
+                DocUpdaterClient.sendUpdate(
+                  this.project_id,
+                  this.doc.id,
+                  update,
+                  callback
+                )
+              )
+            })(update)
+          }
+          return DocUpdaterClient.preloadDoc(
+            this.project_id,
+            this.doc.id,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              return async.series(jobs, (error) => {
+                if (error != null) {
+                  throw error
+                }
+                return setTimeout(done, 200)
+              })
+            }
+          )
+        })
+
+        return it('should update the comments with the OT shifted comment', function (done) {
+          return DocUpdaterClient.getDoc(
+            this.project_id,
+            this.doc.id,
+            (error, res, data) => {
+              if (error != null) {
+                throw error
+              }
+              const { ranges } = data
+              const comment = ranges.comments[0]
+              comment.op.should.deep.equal({ c: 'bar', p: 7, t: this.tid })
+              return done()
+            }
+          )
+        })
+      })
+    })
+  })
+
+  describe('Loading ranges from persistence layer', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.user_id = DocUpdaterClient.randomId()
+      this.id_seed = '587357bd35e64f6157'
+      this.doc = {
+        id: DocUpdaterClient.randomId(),
+        lines: ['a123aa']
+      }
+      this.update = {
+        doc: this.doc.id,
+        op: [{ i: '456', p: 5 }],
+        v: 0,
+        meta: { user_id: this.user_id, tc: this.id_seed }
+      }
+      MockWebApi.insertDoc(this.project_id, this.doc.id, {
+        lines: this.doc.lines,
+        version: 0,
+        ranges: {
+          changes: [
+            {
+              op: { i: '123', p: 1 },
+              metadata: {
+                user_id: this.user_id,
+                ts: new Date()
+              }
+            }
+          ]
+        }
+      })
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc.id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.sendUpdate(
+            this.project_id,
+            this.doc.id,
+            this.update,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              return setTimeout(done, 200)
+            }
+          )
+        }
+      )
+    })
+
+    it('should have preloaded the existing ranges', function (done) {
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc.id,
+        (error, res, data) => {
+          if (error != null) {
+            throw error
+          }
+          const { changes } = data.ranges
+          changes[0].op.should.deep.equal({ i: '123', p: 1 })
+          changes[1].op.should.deep.equal({ i: '456', p: 5 })
+          return done()
+        }
+      )
+    })
+
+    return it('should flush the ranges to the persistence layer again', function (done) {
+      return DocUpdaterClient.flushDoc(
+        this.project_id,
+        this.doc.id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return MockWebApi.getDocument(
+            this.project_id,
+            this.doc.id,
+            (error, doc) => {
+              const { changes } = doc.ranges
+              changes[0].op.should.deep.equal({ i: '123', p: 1 })
+              changes[1].op.should.deep.equal({ i: '456', p: 5 })
+              return done()
+            }
+          )
+        }
+      )
+    })
+  })
+
+  describe('accepting a change', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.user_id = DocUpdaterClient.randomId()
+      this.id_seed = '587357bd35e64f6157'
+      this.doc = {
+        id: DocUpdaterClient.randomId(),
+        lines: ['aaa']
+      }
+      this.update = {
+        doc: this.doc.id,
+        op: [{ i: '456', p: 1 }],
+        v: 0,
+        meta: { user_id: this.user_id, tc: this.id_seed }
+      }
+      MockWebApi.insertDoc(this.project_id, this.doc.id, {
+        lines: this.doc.lines,
+        version: 0
+      })
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc.id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.sendUpdate(
+            this.project_id,
+            this.doc.id,
+            this.update,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              return setTimeout(() => {
+                return DocUpdaterClient.getDoc(
+                  this.project_id,
+                  this.doc.id,
+                  (error, res, data) => {
+                    if (error != null) {
+                      throw error
+                    }
+                    const { ranges } = data
+                    const change = ranges.changes[0]
+                    change.op.should.deep.equal({ i: '456', p: 1 })
+                    change.id.should.equal(this.id_seed + '000001')
+                    change.metadata.user_id.should.equal(this.user_id)
+                    return done()
+                  }
+                )
+              }, 200)
+            }
+          )
+        }
+      )
+    })
+
+    return it('should remove the change after accepting', function (done) {
+      return DocUpdaterClient.acceptChange(
+        this.project_id,
+        this.doc.id,
+        this.id_seed + '000001',
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.getDoc(
+            this.project_id,
+            this.doc.id,
+            (error, res, data) => {
+              if (error != null) {
+                throw error
+              }
+              expect(data.ranges.changes).to.be.undefined
+              return done()
+            }
+          )
+        }
+      )
+    })
+  })
+
+  describe('deleting a comment range', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.user_id = DocUpdaterClient.randomId()
+      this.doc = {
+        id: DocUpdaterClient.randomId(),
+        lines: ['foo bar']
+      }
+      this.update = {
+        doc: this.doc.id,
+        op: [{ c: 'bar', p: 4, t: (this.tid = DocUpdaterClient.randomId()) }],
+        v: 0
+      }
+      MockWebApi.insertDoc(this.project_id, this.doc.id, {
+        lines: this.doc.lines,
+        version: 0
+      })
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc.id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.sendUpdate(
+            this.project_id,
+            this.doc.id,
+            this.update,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              return setTimeout(() => {
+                return DocUpdaterClient.getDoc(
+                  this.project_id,
+                  this.doc.id,
+                  (error, res, data) => {
+                    if (error != null) {
+                      throw error
+                    }
+                    const { ranges } = data
+                    const change = ranges.comments[0]
+                    change.op.should.deep.equal({ c: 'bar', p: 4, t: this.tid })
+                    change.id.should.equal(this.tid)
+                    return done()
+                  }
+                )
+              }, 200)
+            }
+          )
+        }
+      )
+    })
+
+    return it('should remove the comment range', function (done) {
+      return DocUpdaterClient.removeComment(
+        this.project_id,
+        this.doc.id,
+        this.tid,
+        (error, res) => {
+          if (error != null) {
+            throw error
+          }
+          expect(res.statusCode).to.equal(204)
+          return DocUpdaterClient.getDoc(
+            this.project_id,
+            this.doc.id,
+            (error, res, data) => {
+              if (error != null) {
+                throw error
+              }
+              expect(data.ranges.comments).to.be.undefined
+              return done()
+            }
+          )
+        }
+      )
+    })
+  })
+
+  describe('tripping range size limit', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.user_id = DocUpdaterClient.randomId()
+      this.id_seed = DocUpdaterClient.randomId()
+      this.doc = {
+        id: DocUpdaterClient.randomId(),
+        lines: ['aaa']
+      }
+      this.i = new Array(3 * 1024 * 1024).join('a')
+      this.updates = [
+        {
+          doc: this.doc.id,
+          op: [{ i: this.i, p: 1 }],
+          v: 0,
+          meta: { user_id: this.user_id, tc: this.id_seed }
+        }
+      ]
+      MockWebApi.insertDoc(this.project_id, this.doc.id, {
+        lines: this.doc.lines,
+        version: 0
+      })
+      const jobs = []
+      for (const update of Array.from(this.updates)) {
+        ;((update) => {
+          return jobs.push((callback) =>
+            DocUpdaterClient.sendUpdate(
+              this.project_id,
+              this.doc.id,
+              update,
+              callback
+            )
+          )
+        })(update)
+      }
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc.id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return async.series(jobs, (error) => {
+            if (error != null) {
+              throw error
+            }
+            return setTimeout(done, 200)
+          })
+        }
+      )
+    })
+
+    return it('should not update the ranges', function (done) {
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc.id,
+        (error, res, data) => {
+          if (error != null) {
+            throw error
+          }
+          const { ranges } = data
+          expect(ranges.changes).to.be.undefined
+          return done()
+        }
+      )
+    })
+  })
+
+  return describe('deleting text surrounding a comment', function () {
+    before(function (done) {
+      this.project_id = DocUpdaterClient.randomId()
+      this.user_id = DocUpdaterClient.randomId()
+      this.doc_id = DocUpdaterClient.randomId()
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: ['foo bar baz'],
+        version: 0,
+        ranges: {
+          comments: [
+            {
+              op: {
+                c: 'a',
+                p: 5,
+                tid: (this.tid = DocUpdaterClient.randomId())
+              },
+              metadata: {
+                user_id: this.user_id,
+                ts: new Date()
+              }
+            }
+          ]
+        }
+      })
+      this.updates = [
+        {
+          doc: this.doc_id,
+          op: [{ d: 'foo ', p: 0 }],
+          v: 0,
+          meta: { user_id: this.user_id }
+        },
+        {
+          doc: this.doc_id,
+          op: [{ d: 'bar ', p: 0 }],
+          v: 1,
+          meta: { user_id: this.user_id }
+        }
+      ]
+      const jobs = []
+      for (const update of Array.from(this.updates)) {
+        ;((update) => {
+          return jobs.push((callback) =>
+            DocUpdaterClient.sendUpdate(
+              this.project_id,
+              this.doc_id,
+              update,
+              callback
+            )
+          )
+        })(update)
+      }
+      return DocUpdaterClient.preloadDoc(
+        this.project_id,
+        this.doc_id,
+        (error) => {
+          if (error != null) {
+            throw error
+          }
+          return async.series(jobs, function (error) {
+            if (error != null) {
+              throw error
+            }
+            return setTimeout(() => {
+              return DocUpdaterClient.getDoc(
+                this.project_id,
+                this.doc_id,
+                (error, res, data) => {
+                  if (error != null) {
+                    throw error
+                  }
+                  return done()
+                }
+              )
+            }, 200)
+          })
+        }
+      )
+    })
+
+    return it('should write a snapshot from before the destructive change', function (done) {
+      return DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, data) => {
+          if (error != null) {
+            return done(error)
+          }
+          return db.docSnapshots.find(
+            {
+              project_id: ObjectId(this.project_id),
+              doc_id: ObjectId(this.doc_id)
+            },
+            (error, docSnapshots) => {
+              if (error != null) {
+                return done(error)
+              }
+              expect(docSnapshots.length).to.equal(1)
+              expect(docSnapshots[0].version).to.equal(1)
+              expect(docSnapshots[0].lines).to.deep.equal(['bar baz'])
+              expect(docSnapshots[0].ranges.comments[0].op).to.deep.equal({
+                c: 'a',
+                p: 1,
+                tid: this.tid
+              })
+              return done()
+            }
+          )
+        }
+      )
+    })
+  })
+})

+ 459 - 313
services/document-updater/test/acceptance/js/SettingADocumentTests.js

@@ -12,316 +12,462 @@
  * 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");
-chai.should();
-const {
-    expect
-} = require("chai");
-const Settings = require('settings-sharelatex');
-const rclient_du = require("redis-sharelatex").createClient(Settings.redis.documentupdater);
-const Keys = Settings.redis.documentupdater.key_schema;
-
-const MockTrackChangesApi = require("./helpers/MockTrackChangesApi");
-const MockProjectHistoryApi = require("./helpers/MockProjectHistoryApi");
-const MockWebApi = require("./helpers/MockWebApi");
-const DocUpdaterClient = require("./helpers/DocUpdaterClient");
-const DocUpdaterApp = require("./helpers/DocUpdaterApp");
-
-describe("Setting a document", function() {
-	before(function(done) {
-		this.lines = ["one", "two", "three"];
-		this.version = 42;
-		this.update = {
-			doc: this.doc_id,
-			op: [{
-				i: "one and a half\n",
-				p: 4
-			}],
-			v: this.version
-		};
-		this.result = ["one", "one and a half", "two", "three"];
-		this.newLines = ["these", "are", "the", "new", "lines"];
-		this.source = "dropbox";
-		this.user_id = "user-id-123";
-
-		sinon.spy(MockTrackChangesApi, "flushDoc");
-		sinon.spy(MockProjectHistoryApi, "flushProject");
-		sinon.spy(MockWebApi, "setDocument");
-		return DocUpdaterApp.ensureRunning(done);
-	});
-
-	after(function() {
-		MockTrackChangesApi.flushDoc.restore();
-		MockProjectHistoryApi.flushProject.restore();
-		return MockWebApi.setDocument.restore();
-	});
-
-	describe("when the updated doc exists in the doc updater", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-				if (error != null) { throw error; }
-				return DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, this.update, error => {
-					if (error != null) { throw error; }
-					return setTimeout(() => {
-						return DocUpdaterClient.setDocLines(this.project_id, this.doc_id, this.newLines, this.source, this.user_id, false, (error, res, body) => {
-							this.statusCode = res.statusCode;
-							return done();
-						});
-					}
-					, 200);
-				});
-			});
-			return null;
-		});
-
-		after(function() {
-			MockTrackChangesApi.flushDoc.reset();
-			MockProjectHistoryApi.flushProject.reset();
-			return MockWebApi.setDocument.reset();
-		});
-
-		it("should return a 204 status code", function() {
-			return this.statusCode.should.equal(204);
-		});
-
-		it("should send the updated doc lines and version to the web api", function() {
-			return MockWebApi.setDocument
-				.calledWith(this.project_id, this.doc_id, this.newLines)
-				.should.equal(true);
-		});
-
-		it("should update the lines in the doc updater", function(done) {
-			DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				doc.lines.should.deep.equal(this.newLines);
-				return done();
-			});
-			return null;
-		});
-
-		it("should bump the version in the doc updater", function(done) {
-			DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, doc) => {
-				doc.version.should.equal(this.version + 2);
-				return done();
-			});
-			return null;
-		});
-
-		return it("should leave the document in redis", function(done) {
-			rclient_du.get(Keys.docLines({doc_id: this.doc_id}), (error, lines) => {
-				if (error != null) { throw error; }
-				expect(JSON.parse(lines)).to.deep.equal(this.newLines);
-				return done();
-			});
-			return null;
-		});
-	});
-
-	describe("when the updated doc does not exist in the doc updater", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			DocUpdaterClient.setDocLines(this.project_id, this.doc_id, this.newLines, this.source, this.user_id, false, (error, res, body) => {
-				this.statusCode = res.statusCode;
-				return setTimeout(done, 200);
-			});
-			return null;
-		});
-
-		after(function() {
-			MockTrackChangesApi.flushDoc.reset();
-			MockProjectHistoryApi.flushProject.reset();
-			return MockWebApi.setDocument.reset();
-		});
-
-		it("should return a 204 status code", function() {
-			return this.statusCode.should.equal(204);
-		});
-
-		it("should send the updated doc lines to the web api", function() {
-			return MockWebApi.setDocument
-				.calledWith(this.project_id, this.doc_id, this.newLines)
-				.should.equal(true);
-		});
-
-		it("should flush track changes", function() {
-			return MockTrackChangesApi.flushDoc.calledWith(this.doc_id).should.equal(true);
-		});
-
-		it("should flush project history", function() {
-			return MockProjectHistoryApi.flushProject.calledWith(this.project_id).should.equal(true);
-		});
-
-		return it("should remove the document from redis", function(done) {
-			rclient_du.get(Keys.docLines({doc_id: this.doc_id}), (error, lines) => {
-				if (error != null) { throw error; }
-				expect(lines).to.not.exist;
-				return done();
-			});
-			return null;
-		});
-	});
-
-	describe("when the updated doc is too large for the body parser", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-			this.newLines = [];
-			while (JSON.stringify(this.newLines).length < (Settings.max_doc_length + (64 * 1024))) {
-				this.newLines.push("(a long line of text)".repeat(10000));
-			}
-			DocUpdaterClient.setDocLines(this.project_id, this.doc_id, this.newLines, this.source, this.user_id, false, (error, res, body) => {
-				this.statusCode = res.statusCode;
-				return setTimeout(done, 200);
-			});
-			return null;
-		});
-
-		after(function() {
-			MockTrackChangesApi.flushDoc.reset();
-			MockProjectHistoryApi.flushProject.reset();
-			return MockWebApi.setDocument.reset();
-		});
-
-		it("should return a 413 status code", function() {
-			return this.statusCode.should.equal(413);
-		});
-
-		it("should not send the updated doc lines to the web api", function() { return MockWebApi.setDocument.called.should.equal(false); });
-
-		it("should not flush track changes", function() { return MockTrackChangesApi.flushDoc.called.should.equal(false); });
-
-		return it("should not flush project history", function() { return MockProjectHistoryApi.flushProject.called.should.equal(false); });
-	});
-
-	describe("when the updated doc is large but under the bodyParser and HTTPController size limit", function() {
-		before(function(done) {
-			[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-			MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-
-			this.newLines = [];
-			while (JSON.stringify(this.newLines).length < (2 * 1024 * 1024)) { // limit in HTTPController
-				this.newLines.push("(a long line of text)".repeat(10000));
-			}
-			this.newLines.pop(); // remove the line which took it over the limit
-			DocUpdaterClient.setDocLines(this.project_id, this.doc_id, this.newLines, this.source, this.user_id, false, (error, res, body) => {
-				this.statusCode = res.statusCode;
-				return setTimeout(done, 200);
-			});
-			return null;
-		});
-
-		after(function() {
-			MockTrackChangesApi.flushDoc.reset();
-			MockProjectHistoryApi.flushProject.reset();
-			return MockWebApi.setDocument.reset();
-		});
-
-		it("should return a 204 status code", function() {
-			return this.statusCode.should.equal(204);
-		});
-
-		return it("should send the updated doc lines to the web api", function() {
-			return MockWebApi.setDocument
-				.calledWith(this.project_id, this.doc_id, this.newLines)
-				.should.equal(true);
-		});
-	});
-
-	return describe("with track changes", function() {
-		before(function() {
-			this.lines = ["one", "one and a half", "two", "three"];
-			this.id_seed = "587357bd35e64f6157";
-			return this.update = {
-				doc: this.doc_id,
-				op: [{
-					d: "one and a half\n",
-					p: 4
-				}],
-				meta: {
-					tc: this.id_seed,
-					user_id: this.user_id
-				},
-				v: this.version
-			};
-		});
-
-		describe("with the undo flag", function() {
-			before(function(done) {
-				[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-				MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-				DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-					if (error != null) { throw error; }
-					return DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, this.update, error => {
-						if (error != null) { throw error; }
-						// Go back to old lines, with undo flag
-						return DocUpdaterClient.setDocLines(this.project_id, this.doc_id, this.lines, this.source, this.user_id, true, (error, res, body) => {
-							this.statusCode = res.statusCode;
-							return setTimeout(done, 200);
-						});
-					});
-				});
-				return null;
-			});
-
-			after(function() {
-				MockTrackChangesApi.flushDoc.reset();
-				MockProjectHistoryApi.flushProject.reset();
-				return MockWebApi.setDocument.reset();
-			});
-
-			return it("should undo the tracked changes", function(done) {
-				DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, data) => {
-					if (error != null) { throw error; }
-					const {
-                        ranges
-                    } = data;
-					expect(ranges.changes).to.be.undefined;
-					return done();
-				});
-				return null;
-			});
-		});
-
-		return describe("without the undo flag", function() {
-			before(function(done) {
-				[this.project_id, this.doc_id] = Array.from([DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]);
-				MockWebApi.insertDoc(this.project_id, this.doc_id, {lines: this.lines, version: this.version});
-				DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => {
-					if (error != null) { throw error; }
-					return DocUpdaterClient.sendUpdate(this.project_id, this.doc_id, this.update, error => {
-						if (error != null) { throw error; }
-						// Go back to old lines, without undo flag
-						return DocUpdaterClient.setDocLines(this.project_id, this.doc_id, this.lines, this.source, this.user_id, false, (error, res, body) => {
-							this.statusCode = res.statusCode;
-							return setTimeout(done, 200);
-						});
-					});
-				});
-				return null;
-			});
-
-			after(function() {
-				MockTrackChangesApi.flushDoc.reset();
-				MockProjectHistoryApi.flushProject.reset();
-				return MockWebApi.setDocument.reset();
-			});
-
-			return it("should not undo the tracked changes", function(done) {
-				DocUpdaterClient.getDoc(this.project_id, this.doc_id, (error, res, data) => {
-					if (error != null) { throw error; }
-					const {
-                        ranges
-                    } = data;
-					expect(ranges.changes.length).to.equal(1);
-					return done();
-				});
-				return null;
-			});
-		});
-	});
-});
-
-
+const sinon = require('sinon')
+const chai = require('chai')
+chai.should()
+const { expect } = require('chai')
+const Settings = require('settings-sharelatex')
+const rclient_du = require('redis-sharelatex').createClient(
+  Settings.redis.documentupdater
+)
+const Keys = Settings.redis.documentupdater.key_schema
+
+const MockTrackChangesApi = require('./helpers/MockTrackChangesApi')
+const MockProjectHistoryApi = require('./helpers/MockProjectHistoryApi')
+const MockWebApi = require('./helpers/MockWebApi')
+const DocUpdaterClient = require('./helpers/DocUpdaterClient')
+const DocUpdaterApp = require('./helpers/DocUpdaterApp')
+
+describe('Setting a document', function () {
+  before(function (done) {
+    this.lines = ['one', 'two', 'three']
+    this.version = 42
+    this.update = {
+      doc: this.doc_id,
+      op: [
+        {
+          i: 'one and a half\n',
+          p: 4
+        }
+      ],
+      v: this.version
+    }
+    this.result = ['one', 'one and a half', 'two', 'three']
+    this.newLines = ['these', 'are', 'the', 'new', 'lines']
+    this.source = 'dropbox'
+    this.user_id = 'user-id-123'
+
+    sinon.spy(MockTrackChangesApi, 'flushDoc')
+    sinon.spy(MockProjectHistoryApi, 'flushProject')
+    sinon.spy(MockWebApi, 'setDocument')
+    return DocUpdaterApp.ensureRunning(done)
+  })
+
+  after(function () {
+    MockTrackChangesApi.flushDoc.restore()
+    MockProjectHistoryApi.flushProject.restore()
+    return MockWebApi.setDocument.restore()
+  })
+
+  describe('when the updated doc exists in the doc updater', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, (error) => {
+        if (error != null) {
+          throw error
+        }
+        return DocUpdaterClient.sendUpdate(
+          this.project_id,
+          this.doc_id,
+          this.update,
+          (error) => {
+            if (error != null) {
+              throw error
+            }
+            return setTimeout(() => {
+              return DocUpdaterClient.setDocLines(
+                this.project_id,
+                this.doc_id,
+                this.newLines,
+                this.source,
+                this.user_id,
+                false,
+                (error, res, body) => {
+                  this.statusCode = res.statusCode
+                  return done()
+                }
+              )
+            }, 200)
+          }
+        )
+      })
+      return null
+    })
+
+    after(function () {
+      MockTrackChangesApi.flushDoc.reset()
+      MockProjectHistoryApi.flushProject.reset()
+      return MockWebApi.setDocument.reset()
+    })
+
+    it('should return a 204 status code', function () {
+      return this.statusCode.should.equal(204)
+    })
+
+    it('should send the updated doc lines and version to the web api', function () {
+      return MockWebApi.setDocument
+        .calledWith(this.project_id, this.doc_id, this.newLines)
+        .should.equal(true)
+    })
+
+    it('should update the lines in the doc updater', function (done) {
+      DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          doc.lines.should.deep.equal(this.newLines)
+          return done()
+        }
+      )
+      return null
+    })
+
+    it('should bump the version in the doc updater', function (done) {
+      DocUpdaterClient.getDoc(
+        this.project_id,
+        this.doc_id,
+        (error, res, doc) => {
+          doc.version.should.equal(this.version + 2)
+          return done()
+        }
+      )
+      return null
+    })
+
+    return it('should leave the document in redis', function (done) {
+      rclient_du.get(Keys.docLines({ doc_id: this.doc_id }), (error, lines) => {
+        if (error != null) {
+          throw error
+        }
+        expect(JSON.parse(lines)).to.deep.equal(this.newLines)
+        return done()
+      })
+      return null
+    })
+  })
+
+  describe('when the updated doc does not exist in the doc updater', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      DocUpdaterClient.setDocLines(
+        this.project_id,
+        this.doc_id,
+        this.newLines,
+        this.source,
+        this.user_id,
+        false,
+        (error, res, body) => {
+          this.statusCode = res.statusCode
+          return setTimeout(done, 200)
+        }
+      )
+      return null
+    })
+
+    after(function () {
+      MockTrackChangesApi.flushDoc.reset()
+      MockProjectHistoryApi.flushProject.reset()
+      return MockWebApi.setDocument.reset()
+    })
+
+    it('should return a 204 status code', function () {
+      return this.statusCode.should.equal(204)
+    })
+
+    it('should send the updated doc lines to the web api', function () {
+      return MockWebApi.setDocument
+        .calledWith(this.project_id, this.doc_id, this.newLines)
+        .should.equal(true)
+    })
+
+    it('should flush track changes', function () {
+      return MockTrackChangesApi.flushDoc
+        .calledWith(this.doc_id)
+        .should.equal(true)
+    })
+
+    it('should flush project history', function () {
+      return MockProjectHistoryApi.flushProject
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
+
+    return it('should remove the document from redis', function (done) {
+      rclient_du.get(Keys.docLines({ doc_id: this.doc_id }), (error, lines) => {
+        if (error != null) {
+          throw error
+        }
+        expect(lines).to.not.exist
+        return done()
+      })
+      return null
+    })
+  })
+
+  describe('when the updated doc is too large for the body parser', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+      this.newLines = []
+      while (
+        JSON.stringify(this.newLines).length <
+        Settings.max_doc_length + 64 * 1024
+      ) {
+        this.newLines.push('(a long line of text)'.repeat(10000))
+      }
+      DocUpdaterClient.setDocLines(
+        this.project_id,
+        this.doc_id,
+        this.newLines,
+        this.source,
+        this.user_id,
+        false,
+        (error, res, body) => {
+          this.statusCode = res.statusCode
+          return setTimeout(done, 200)
+        }
+      )
+      return null
+    })
+
+    after(function () {
+      MockTrackChangesApi.flushDoc.reset()
+      MockProjectHistoryApi.flushProject.reset()
+      return MockWebApi.setDocument.reset()
+    })
+
+    it('should return a 413 status code', function () {
+      return this.statusCode.should.equal(413)
+    })
+
+    it('should not send the updated doc lines to the web api', function () {
+      return MockWebApi.setDocument.called.should.equal(false)
+    })
+
+    it('should not flush track changes', function () {
+      return MockTrackChangesApi.flushDoc.called.should.equal(false)
+    })
+
+    return it('should not flush project history', function () {
+      return MockProjectHistoryApi.flushProject.called.should.equal(false)
+    })
+  })
+
+  describe('when the updated doc is large but under the bodyParser and HTTPController size limit', function () {
+    before(function (done) {
+      ;[this.project_id, this.doc_id] = Array.from([
+        DocUpdaterClient.randomId(),
+        DocUpdaterClient.randomId()
+      ])
+      MockWebApi.insertDoc(this.project_id, this.doc_id, {
+        lines: this.lines,
+        version: this.version
+      })
+
+      this.newLines = []
+      while (JSON.stringify(this.newLines).length < 2 * 1024 * 1024) {
+        // limit in HTTPController
+        this.newLines.push('(a long line of text)'.repeat(10000))
+      }
+      this.newLines.pop() // remove the line which took it over the limit
+      DocUpdaterClient.setDocLines(
+        this.project_id,
+        this.doc_id,
+        this.newLines,
+        this.source,
+        this.user_id,
+        false,
+        (error, res, body) => {
+          this.statusCode = res.statusCode
+          return setTimeout(done, 200)
+        }
+      )
+      return null
+    })
+
+    after(function () {
+      MockTrackChangesApi.flushDoc.reset()
+      MockProjectHistoryApi.flushProject.reset()
+      return MockWebApi.setDocument.reset()
+    })
+
+    it('should return a 204 status code', function () {
+      return this.statusCode.should.equal(204)
+    })
+
+    return it('should send the updated doc lines to the web api', function () {
+      return MockWebApi.setDocument
+        .calledWith(this.project_id, this.doc_id, this.newLines)
+        .should.equal(true)
+    })
+  })
+
+  return describe('with track changes', function () {
+    before(function () {
+      this.lines = ['one', 'one and a half', 'two', 'three']
+      this.id_seed = '587357bd35e64f6157'
+      return (this.update = {
+        doc: this.doc_id,
+        op: [
+          {
+            d: 'one and a half\n',
+            p: 4
+          }
+        ],
+        meta: {
+          tc: this.id_seed,
+          user_id: this.user_id
+        },
+        v: this.version
+      })
+    })
+
+    describe('with the undo flag', function () {
+      before(function (done) {
+        ;[this.project_id, this.doc_id] = Array.from([
+          DocUpdaterClient.randomId(),
+          DocUpdaterClient.randomId()
+        ])
+        MockWebApi.insertDoc(this.project_id, this.doc_id, {
+          lines: this.lines,
+          version: this.version
+        })
+        DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.sendUpdate(
+            this.project_id,
+            this.doc_id,
+            this.update,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              // Go back to old lines, with undo flag
+              return DocUpdaterClient.setDocLines(
+                this.project_id,
+                this.doc_id,
+                this.lines,
+                this.source,
+                this.user_id,
+                true,
+                (error, res, body) => {
+                  this.statusCode = res.statusCode
+                  return setTimeout(done, 200)
+                }
+              )
+            }
+          )
+        })
+        return null
+      })
+
+      after(function () {
+        MockTrackChangesApi.flushDoc.reset()
+        MockProjectHistoryApi.flushProject.reset()
+        return MockWebApi.setDocument.reset()
+      })
+
+      return it('should undo the tracked changes', function (done) {
+        DocUpdaterClient.getDoc(
+          this.project_id,
+          this.doc_id,
+          (error, res, data) => {
+            if (error != null) {
+              throw error
+            }
+            const { ranges } = data
+            expect(ranges.changes).to.be.undefined
+            return done()
+          }
+        )
+        return null
+      })
+    })
+
+    return describe('without the undo flag', function () {
+      before(function (done) {
+        ;[this.project_id, this.doc_id] = Array.from([
+          DocUpdaterClient.randomId(),
+          DocUpdaterClient.randomId()
+        ])
+        MockWebApi.insertDoc(this.project_id, this.doc_id, {
+          lines: this.lines,
+          version: this.version
+        })
+        DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, (error) => {
+          if (error != null) {
+            throw error
+          }
+          return DocUpdaterClient.sendUpdate(
+            this.project_id,
+            this.doc_id,
+            this.update,
+            (error) => {
+              if (error != null) {
+                throw error
+              }
+              // Go back to old lines, without undo flag
+              return DocUpdaterClient.setDocLines(
+                this.project_id,
+                this.doc_id,
+                this.lines,
+                this.source,
+                this.user_id,
+                false,
+                (error, res, body) => {
+                  this.statusCode = res.statusCode
+                  return setTimeout(done, 200)
+                }
+              )
+            }
+          )
+        })
+        return null
+      })
+
+      after(function () {
+        MockTrackChangesApi.flushDoc.reset()
+        MockProjectHistoryApi.flushProject.reset()
+        return MockWebApi.setDocument.reset()
+      })
+
+      return it('should not undo the tracked changes', function (done) {
+        DocUpdaterClient.getDoc(
+          this.project_id,
+          this.doc_id,
+          (error, res, data) => {
+            if (error != null) {
+              throw error
+            }
+            const { ranges } = data
+            expect(ranges.changes.length).to.equal(1)
+            return done()
+          }
+        )
+        return null
+      })
+    })
+  })
+})

+ 32 - 28
services/document-updater/test/acceptance/js/helpers/DocUpdaterApp.js

@@ -11,33 +11,37 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const app = require('../../../../app');
-require("logger-sharelatex").logger.level("fatal");
+const app = require('../../../../app')
+require('logger-sharelatex').logger.level('fatal')
 
 module.exports = {
-	running: false,
-	initing: false,
-	callbacks: [],
-	ensureRunning(callback) {
-		if (callback == null) { callback = function(error) {}; }
-		if (this.running) {
-			return callback();
-		} else if (this.initing) {
-			return this.callbacks.push(callback);
-		} else {
-			this.initing = true;
-			this.callbacks.push(callback);
-			return app.listen(3003, "localhost", error => {
-				if (error != null) { throw error; }
-				this.running = true;
-				return (() => {
-					const result = [];
-					for (callback of Array.from(this.callbacks)) {
-						result.push(callback());
-					}
-					return result;
-				})();
-			});
-		}
-	}
-};
+  running: false,
+  initing: false,
+  callbacks: [],
+  ensureRunning(callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    if (this.running) {
+      return callback()
+    } else if (this.initing) {
+      return this.callbacks.push(callback)
+    } else {
+      this.initing = true
+      this.callbacks.push(callback)
+      return app.listen(3003, 'localhost', (error) => {
+        if (error != null) {
+          throw error
+        }
+        this.running = true
+        return (() => {
+          const result = []
+          for (callback of Array.from(this.callbacks)) {
+            result.push(callback())
+          }
+          return result
+        })()
+      })
+    }
+  }
+}

+ 260 - 159
services/document-updater/test/acceptance/js/helpers/DocUpdaterClient.js

@@ -11,167 +11,268 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let DocUpdaterClient;
-const Settings = require('settings-sharelatex');
-const rclient = require("redis-sharelatex").createClient(Settings.redis.documentupdater);
-const keys = Settings.redis.documentupdater.key_schema;
-const request = require("request").defaults({jar: false});
-const async = require("async");
-
-const rclient_sub = require("redis-sharelatex").createClient(Settings.redis.pubsub);
-rclient_sub.subscribe("applied-ops");
-rclient_sub.setMaxListeners(0);
-	
-module.exports = (DocUpdaterClient = {
-	randomId() {
-		const chars = __range__(1, 24, true).map((i) =>
-			Math.random().toString(16)[2]);
-		return chars.join("");
-	},
-	
-	subscribeToAppliedOps(callback) {
-		if (callback == null) { callback = function(message) {}; }
-		return rclient_sub.on("message", callback);
-	},
-
-	sendUpdate(project_id, doc_id, update, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return rclient.rpush(keys.pendingUpdates({doc_id}), JSON.stringify(update), (error) => {
-			if (error != null) { return callback(error); }
-			const doc_key = `${project_id}:${doc_id}`;
-			return rclient.sadd("DocsWithPendingUpdates", doc_key, (error) => {
-				if (error != null) { return callback(error); }
-				return rclient.rpush("pending-updates-list", doc_key, callback);
-			});
-		});
-	},
-
-	sendUpdates(project_id, doc_id, updates, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return DocUpdaterClient.preloadDoc(project_id, doc_id, (error) => {
-			if (error != null) { return callback(error); }
-			const jobs = [];
-			for (const update of Array.from(updates)) {
-				((update => jobs.push(callback => DocUpdaterClient.sendUpdate(project_id, doc_id, update, callback))))(update);
-			}
-			return async.series(jobs, err => DocUpdaterClient.waitForPendingUpdates(project_id, doc_id, callback));
-		});
-	},
-
-	waitForPendingUpdates(project_id, doc_id, callback) {
-		return async.retry({times: 30, interval: 100}, cb => rclient.llen(keys.pendingUpdates({doc_id}), (err, length) => {
-            if (length > 0) {
-                return cb(new Error("updates still pending"));
-            } else {
-                return cb();
-            }
+let DocUpdaterClient
+const Settings = require('settings-sharelatex')
+const rclient = require('redis-sharelatex').createClient(
+  Settings.redis.documentupdater
+)
+const keys = Settings.redis.documentupdater.key_schema
+const request = require('request').defaults({ jar: false })
+const async = require('async')
+
+const rclient_sub = require('redis-sharelatex').createClient(
+  Settings.redis.pubsub
+)
+rclient_sub.subscribe('applied-ops')
+rclient_sub.setMaxListeners(0)
+
+module.exports = DocUpdaterClient = {
+  randomId() {
+    const chars = __range__(1, 24, true).map(
+      (i) => Math.random().toString(16)[2]
+    )
+    return chars.join('')
+  },
+
+  subscribeToAppliedOps(callback) {
+    if (callback == null) {
+      callback = function (message) {}
+    }
+    return rclient_sub.on('message', callback)
+  },
+
+  sendUpdate(project_id, doc_id, update, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return rclient.rpush(
+      keys.pendingUpdates({ doc_id }),
+      JSON.stringify(update),
+      (error) => {
+        if (error != null) {
+          return callback(error)
+        }
+        const doc_key = `${project_id}:${doc_id}`
+        return rclient.sadd('DocsWithPendingUpdates', doc_key, (error) => {
+          if (error != null) {
+            return callback(error)
+          }
+          return rclient.rpush('pending-updates-list', doc_key, callback)
         })
-		, callback);
-	},
-
-	getDoc(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error, res, body) {}; }
-		return request.get(`http://localhost:3003/project/${project_id}/doc/${doc_id}`, (error, res, body) => {
-			if ((body != null) && (res.statusCode >= 200) && (res.statusCode < 300)) {
-				body = JSON.parse(body);
-			}
-			return callback(error, res, body);
-		});
-	},
-
-	getDocAndRecentOps(project_id, doc_id, fromVersion, callback) {
-		if (callback == null) { callback = function(error, res, body) {}; }
-		return request.get(`http://localhost:3003/project/${project_id}/doc/${doc_id}?fromVersion=${fromVersion}`, (error, res, body) => {
-			if ((body != null) && (res.statusCode >= 200) && (res.statusCode < 300)) {
-				body = JSON.parse(body);
-			}
-			return callback(error, res, body);
-		});
-	},
-
-	preloadDoc(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return DocUpdaterClient.getDoc(project_id, doc_id, callback);
-	},
-
-	flushDoc(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return request.post(`http://localhost:3003/project/${project_id}/doc/${doc_id}/flush`, (error, res, body) => callback(error, res, body));
-	},
-
-	setDocLines(project_id, doc_id, lines, source, user_id, undoing, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return request.post({
-			url: `http://localhost:3003/project/${project_id}/doc/${doc_id}`,
-			json: {
-				lines,
-				source,
-				user_id,
-				undoing
-			}
-		}, (error, res, body) => callback(error, res, body));
-	},
-
-	deleteDoc(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return request.del(`http://localhost:3003/project/${project_id}/doc/${doc_id}`, (error, res, body) => callback(error, res, body));
-	},
-
-	flushProject(project_id, callback) {
-		if (callback == null) { callback = function() {}; }
-		return request.post(`http://localhost:3003/project/${project_id}/flush`, callback);
-	},
-
-	deleteProject(project_id, callback) {
-		if (callback == null) { callback = function() {}; }
-		return request.del(`http://localhost:3003/project/${project_id}`, callback);
-	},
-
-	deleteProjectOnShutdown(project_id, callback) {
-		if (callback == null) { callback = function() {}; }
-		return request.del(`http://localhost:3003/project/${project_id}?background=true&shutdown=true`, callback);
-	},
-
-	flushOldProjects(callback) {
-		if (callback == null) { callback = function() {}; }
-		return request.get("http://localhost:3003/flush_queued_projects?min_delete_age=1", callback);
-	},
-
-	acceptChange(project_id, doc_id, change_id, callback) {
-		if (callback == null) { callback = function() {}; }
-		return request.post(`http://localhost:3003/project/${project_id}/doc/${doc_id}/change/${change_id}/accept`, callback);
-	},
-
-	removeComment(project_id, doc_id, comment, callback) {
-		if (callback == null) { callback = function() {}; }
-		return request.del(`http://localhost:3003/project/${project_id}/doc/${doc_id}/comment/${comment}`, callback);
-	},
-
-	getProjectDocs(project_id, projectStateHash, callback) {
-		if (callback == null) { callback = function() {}; }
-		return request.get(`http://localhost:3003/project/${project_id}/doc?state=${projectStateHash}`, (error, res, body) => {
-			if ((body != null) && (res.statusCode >= 200) && (res.statusCode < 300)) {
-				body = JSON.parse(body);
-			}
-			return callback(error, res, body);
-		});
-	},
-
-	sendProjectUpdate(project_id, userId, docUpdates, fileUpdates, version, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return request.post({
-			url: `http://localhost:3003/project/${project_id}`,
-			json: { userId, docUpdates, fileUpdates, version }
-		}, (error, res, body) => callback(error, res, body));
-	}
-});
+      }
+    )
+  },
+
+  sendUpdates(project_id, doc_id, updates, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return DocUpdaterClient.preloadDoc(project_id, doc_id, (error) => {
+      if (error != null) {
+        return callback(error)
+      }
+      const jobs = []
+      for (const update of Array.from(updates)) {
+        ;((update) =>
+          jobs.push((callback) =>
+            DocUpdaterClient.sendUpdate(project_id, doc_id, update, callback)
+          ))(update)
+      }
+      return async.series(jobs, (err) =>
+        DocUpdaterClient.waitForPendingUpdates(project_id, doc_id, callback)
+      )
+    })
+  },
+
+  waitForPendingUpdates(project_id, doc_id, callback) {
+    return async.retry(
+      { times: 30, interval: 100 },
+      (cb) =>
+        rclient.llen(keys.pendingUpdates({ doc_id }), (err, length) => {
+          if (length > 0) {
+            return cb(new Error('updates still pending'))
+          } else {
+            return cb()
+          }
+        }),
+      callback
+    )
+  },
+
+  getDoc(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error, res, body) {}
+    }
+    return request.get(
+      `http://localhost:3003/project/${project_id}/doc/${doc_id}`,
+      (error, res, body) => {
+        if (body != null && res.statusCode >= 200 && res.statusCode < 300) {
+          body = JSON.parse(body)
+        }
+        return callback(error, res, body)
+      }
+    )
+  },
+
+  getDocAndRecentOps(project_id, doc_id, fromVersion, callback) {
+    if (callback == null) {
+      callback = function (error, res, body) {}
+    }
+    return request.get(
+      `http://localhost:3003/project/${project_id}/doc/${doc_id}?fromVersion=${fromVersion}`,
+      (error, res, body) => {
+        if (body != null && res.statusCode >= 200 && res.statusCode < 300) {
+          body = JSON.parse(body)
+        }
+        return callback(error, res, body)
+      }
+    )
+  },
+
+  preloadDoc(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return DocUpdaterClient.getDoc(project_id, doc_id, callback)
+  },
+
+  flushDoc(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return request.post(
+      `http://localhost:3003/project/${project_id}/doc/${doc_id}/flush`,
+      (error, res, body) => callback(error, res, body)
+    )
+  },
+
+  setDocLines(project_id, doc_id, lines, source, user_id, undoing, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return request.post(
+      {
+        url: `http://localhost:3003/project/${project_id}/doc/${doc_id}`,
+        json: {
+          lines,
+          source,
+          user_id,
+          undoing
+        }
+      },
+      (error, res, body) => callback(error, res, body)
+    )
+  },
+
+  deleteDoc(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return request.del(
+      `http://localhost:3003/project/${project_id}/doc/${doc_id}`,
+      (error, res, body) => callback(error, res, body)
+    )
+  },
+
+  flushProject(project_id, callback) {
+    if (callback == null) {
+      callback = function () {}
+    }
+    return request.post(
+      `http://localhost:3003/project/${project_id}/flush`,
+      callback
+    )
+  },
+
+  deleteProject(project_id, callback) {
+    if (callback == null) {
+      callback = function () {}
+    }
+    return request.del(`http://localhost:3003/project/${project_id}`, callback)
+  },
+
+  deleteProjectOnShutdown(project_id, callback) {
+    if (callback == null) {
+      callback = function () {}
+    }
+    return request.del(
+      `http://localhost:3003/project/${project_id}?background=true&shutdown=true`,
+      callback
+    )
+  },
+
+  flushOldProjects(callback) {
+    if (callback == null) {
+      callback = function () {}
+    }
+    return request.get(
+      'http://localhost:3003/flush_queued_projects?min_delete_age=1',
+      callback
+    )
+  },
+
+  acceptChange(project_id, doc_id, change_id, callback) {
+    if (callback == null) {
+      callback = function () {}
+    }
+    return request.post(
+      `http://localhost:3003/project/${project_id}/doc/${doc_id}/change/${change_id}/accept`,
+      callback
+    )
+  },
+
+  removeComment(project_id, doc_id, comment, callback) {
+    if (callback == null) {
+      callback = function () {}
+    }
+    return request.del(
+      `http://localhost:3003/project/${project_id}/doc/${doc_id}/comment/${comment}`,
+      callback
+    )
+  },
+
+  getProjectDocs(project_id, projectStateHash, callback) {
+    if (callback == null) {
+      callback = function () {}
+    }
+    return request.get(
+      `http://localhost:3003/project/${project_id}/doc?state=${projectStateHash}`,
+      (error, res, body) => {
+        if (body != null && res.statusCode >= 200 && res.statusCode < 300) {
+          body = JSON.parse(body)
+        }
+        return callback(error, res, body)
+      }
+    )
+  },
+
+  sendProjectUpdate(
+    project_id,
+    userId,
+    docUpdates,
+    fileUpdates,
+    version,
+    callback
+  ) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return request.post(
+      {
+        url: `http://localhost:3003/project/${project_id}`,
+        json: { userId, docUpdates, fileUpdates, version }
+      },
+      (error, res, body) => callback(error, res, body)
+    )
+  }
+}
 
 function __range__(left, right, inclusive) {
-  const range = [];
-  const ascending = left < right;
-  const end = !inclusive ? right : ascending ? right + 1 : right - 1;
+  const range = []
+  const ascending = left < right
+  const end = !inclusive ? right : ascending ? right + 1 : right - 1
   for (let i = left; ascending ? i < end : i > end; ascending ? i++ : i--) {
-    range.push(i);
+    range.push(i)
   }
-  return range;
-}
+  return range
+}

+ 28 - 24
services/document-updater/test/acceptance/js/helpers/MockProjectHistoryApi.js

@@ -10,31 +10,35 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let MockProjectHistoryApi;
-const express = require("express");
-const app = express();
+let MockProjectHistoryApi
+const express = require('express')
+const app = express()
 
-module.exports = (MockProjectHistoryApi = {
-	flushProject(doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return callback();
-	},
+module.exports = MockProjectHistoryApi = {
+  flushProject(doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return callback()
+  },
 
-	run() {
-		app.post("/project/:project_id/flush", (req, res, next) => {
-			return this.flushProject(req.params.project_id, (error) => {
-				if (error != null) {
-					return res.sendStatus(500);
-				} else {
-					return res.sendStatus(204);
-				}
-			});
-		});
+  run() {
+    app.post('/project/:project_id/flush', (req, res, next) => {
+      return this.flushProject(req.params.project_id, (error) => {
+        if (error != null) {
+          return res.sendStatus(500)
+        } else {
+          return res.sendStatus(204)
+        }
+      })
+    })
 
-		return app.listen(3054, (error) => {
-			if (error != null) { throw error; }
-		});
-	}
-});
+    return app.listen(3054, (error) => {
+      if (error != null) {
+        throw error
+      }
+    })
+  }
+}
 
-MockProjectHistoryApi.run();
+MockProjectHistoryApi.run()

+ 33 - 28
services/document-updater/test/acceptance/js/helpers/MockTrackChangesApi.js

@@ -10,35 +10,40 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let MockTrackChangesApi;
-const express = require("express");
-const app = express();
+let MockTrackChangesApi
+const express = require('express')
+const app = express()
 
-module.exports = (MockTrackChangesApi = {
-	flushDoc(doc_id, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		return callback();
-	},
+module.exports = MockTrackChangesApi = {
+  flushDoc(doc_id, callback) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    return callback()
+  },
 
-	run() {
-		app.post("/project/:project_id/doc/:doc_id/flush", (req, res, next) => {
-			return this.flushDoc(req.params.doc_id, (error) => {
-				if (error != null) {
-					return res.sendStatus(500);
-				} else {
-					return res.sendStatus(204);
-				}
-			});
-		});
+  run() {
+    app.post('/project/:project_id/doc/:doc_id/flush', (req, res, next) => {
+      return this.flushDoc(req.params.doc_id, (error) => {
+        if (error != null) {
+          return res.sendStatus(500)
+        } else {
+          return res.sendStatus(204)
+        }
+      })
+    })
 
-		return app.listen(3015, (error) => {
-			if (error != null) { throw error; }
-	}).on("error", (error) => {
-			console.error("error starting MockTrackChangesApi:", error.message);
-			return process.exit(1);
-		});
-	}
-});
-
-MockTrackChangesApi.run();
+    return app
+      .listen(3015, (error) => {
+        if (error != null) {
+          throw error
+        }
+      })
+      .on('error', (error) => {
+        console.error('error starting MockTrackChangesApi:', error.message)
+        return process.exit(1)
+      })
+  }
+}
 
+MockTrackChangesApi.run()

+ 101 - 60
services/document-updater/test/acceptance/js/helpers/MockWebApi.js

@@ -11,72 +11,113 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let MockWebApi;
-const express = require("express");
-const bodyParser = require("body-parser");
-const app = express();
-const MAX_REQUEST_SIZE = 2*((2*1024*1024) + (64*1024));
+let MockWebApi
+const express = require('express')
+const bodyParser = require('body-parser')
+const app = express()
+const MAX_REQUEST_SIZE = 2 * (2 * 1024 * 1024 + 64 * 1024)
 
-module.exports = (MockWebApi = {
-	docs: {},
+module.exports = MockWebApi = {
+  docs: {},
 
-	clearDocs() { return this.docs = {}; },
+  clearDocs() {
+    return (this.docs = {})
+  },
 
-	insertDoc(project_id, doc_id, doc) {
-		if (doc.version == null) { doc.version = 0; }
-		if (doc.lines == null) { doc.lines = []; }
-		doc.pathname = '/a/b/c.tex';
-		return this.docs[`${project_id}:${doc_id}`] = doc;
-	},
+  insertDoc(project_id, doc_id, doc) {
+    if (doc.version == null) {
+      doc.version = 0
+    }
+    if (doc.lines == null) {
+      doc.lines = []
+    }
+    doc.pathname = '/a/b/c.tex'
+    return (this.docs[`${project_id}:${doc_id}`] = doc)
+  },
 
-	setDocument(project_id, doc_id, lines, version, ranges, lastUpdatedAt, lastUpdatedBy, callback) {
-		if (callback == null) { callback = function(error) {}; }
-		const doc = this.docs[`${project_id}:${doc_id}`] || (this.docs[`${project_id}:${doc_id}`] = {});
-		doc.lines = lines;
-		doc.version = version;
-		doc.ranges = ranges;
-		doc.pathname = '/a/b/c.tex';
-		doc.lastUpdatedAt = lastUpdatedAt;
-		doc.lastUpdatedBy = lastUpdatedBy;
-		return callback(null);
-	},
+  setDocument(
+    project_id,
+    doc_id,
+    lines,
+    version,
+    ranges,
+    lastUpdatedAt,
+    lastUpdatedBy,
+    callback
+  ) {
+    if (callback == null) {
+      callback = function (error) {}
+    }
+    const doc =
+      this.docs[`${project_id}:${doc_id}`] ||
+      (this.docs[`${project_id}:${doc_id}`] = {})
+    doc.lines = lines
+    doc.version = version
+    doc.ranges = ranges
+    doc.pathname = '/a/b/c.tex'
+    doc.lastUpdatedAt = lastUpdatedAt
+    doc.lastUpdatedBy = lastUpdatedBy
+    return callback(null)
+  },
 
-	getDocument(project_id, doc_id, callback) {
-		if (callback == null) { callback = function(error, doc) {}; }
-		return callback(null, this.docs[`${project_id}:${doc_id}`]);
-	},
+  getDocument(project_id, doc_id, callback) {
+    if (callback == null) {
+      callback = function (error, doc) {}
+    }
+    return callback(null, this.docs[`${project_id}:${doc_id}`])
+  },
 
-	run() {
-		app.get("/project/:project_id/doc/:doc_id", (req, res, next) => {
-			return this.getDocument(req.params.project_id, req.params.doc_id, (error, doc) => {
-				if (error != null) {
-					return res.sendStatus(500);
-				} else if (doc != null) {
-					return res.send(JSON.stringify(doc));
-				} else {
-					return res.sendStatus(404);
-				}
-			});
-		});
+  run() {
+    app.get('/project/:project_id/doc/:doc_id', (req, res, next) => {
+      return this.getDocument(
+        req.params.project_id,
+        req.params.doc_id,
+        (error, doc) => {
+          if (error != null) {
+            return res.sendStatus(500)
+          } else if (doc != null) {
+            return res.send(JSON.stringify(doc))
+          } else {
+            return res.sendStatus(404)
+          }
+        }
+      )
+    })
 
-		app.post("/project/:project_id/doc/:doc_id", bodyParser.json({limit: MAX_REQUEST_SIZE}), (req, res, next) => {
-			return MockWebApi.setDocument(req.params.project_id, req.params.doc_id, req.body.lines, req.body.version, req.body.ranges, req.body.lastUpdatedAt, req.body.lastUpdatedBy, (error) => {
-				if (error != null) {
-					return res.sendStatus(500);
-				} else {
-					return res.sendStatus(204);
-				}
-			});
-		});
+    app.post(
+      '/project/:project_id/doc/:doc_id',
+      bodyParser.json({ limit: MAX_REQUEST_SIZE }),
+      (req, res, next) => {
+        return MockWebApi.setDocument(
+          req.params.project_id,
+          req.params.doc_id,
+          req.body.lines,
+          req.body.version,
+          req.body.ranges,
+          req.body.lastUpdatedAt,
+          req.body.lastUpdatedBy,
+          (error) => {
+            if (error != null) {
+              return res.sendStatus(500)
+            } else {
+              return res.sendStatus(204)
+            }
+          }
+        )
+      }
+    )
 
-		return app.listen(3000, (error) => {
-			if (error != null) { throw error; }
-	}).on("error", (error) => {
-			console.error("error starting MockWebApi:", error.message);
-			return process.exit(1);
-		});
-	}
-});
-
-MockWebApi.run();
+    return app
+      .listen(3000, (error) => {
+        if (error != null) {
+          throw error
+        }
+      })
+      .on('error', (error) => {
+        console.error('error starting MockWebApi:', error.message)
+        return process.exit(1)
+      })
+  }
+}
 
+MockWebApi.run()