Преглед на файлове

prettier: convert test/unit decaffeinated files to Prettier format

mserranom преди 6 години
родител
ревизия
7741730574

+ 450 - 390
services/track-changes/test/unit/js/DiffGenerator/DiffGeneratorTests.js

@@ -10,393 +10,453 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/DiffGenerator.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe("DiffGenerator", function() {
-	beforeEach(function() {
-		this.DiffGenerator = SandboxedModule.require(modulePath, { requires: {
-			"logger-sharelatex": { warn: sinon.stub() }
-		}
-	});
-		this.ts = Date.now();
-		this.user_id = "mock-user-id";
-		this.user_id_2 = "mock-user-id-2";
-		return this.meta = {
-			start_ts: this.ts, end_ts: this.ts, user_id: this.user_id
-		};});
-
-	describe("rewindOp", function() {
-		describe("rewinding an insert", function() { return it("should undo the insert", function() {
-				const content = "hello world";
-				const rewoundContent = this.DiffGenerator.rewindOp(content, { p: 6, i: "wo" });
-				return rewoundContent.should.equal("hello rld");
-			}); }
-		);
-
-		describe("rewinding a delete", function() { return it("should undo the delete", function() {
-				const content = "hello rld";
-				const rewoundContent = this.DiffGenerator.rewindOp(content, { p: 6, d: "wo" });
-				return rewoundContent.should.equal("hello world");
-			}); }
-		);
-
-		describe("with an inconsistent update", function() { return it("should throw an error", function() {
-				const content = "hello world";
-				return expect( () => {
-					return this.DiffGenerator.rewindOp(content, { p: 6, i: "foo" });
-				}).to.throw(this.DiffGenerator.ConsistencyError);
-			}); }
-		);
-		
-		return describe("with an update which is beyond the length of the content", function() { return it("should undo the insert as if it were at the end of the content", function() {
-				const content = "foobar";
-				const rewoundContent = this.DiffGenerator.rewindOp(content, { p: 4, i: "bar" });
-				return rewoundContent.should.equal("foo");
-			}); }
-		);
-	});
-
-	describe("rewindUpdate", function() { return it("should rewind ops in reverse", function() {
-			const content = "aaabbbccc";
-			const update =
-				{op: [{ p: 3, i: "bbb" }, { p: 6, i: "ccc" }]};
-			const rewoundContent = this.DiffGenerator.rewindUpdate(content, update);
-			return rewoundContent.should.equal("aaa");
-		}); }
-	);
-
-	describe("rewindUpdates", function() { return it("should rewind updates in reverse", function() {
-			const content = "aaabbbccc";
-			const updates = [
-				{ op: [{ p: 3, i: "bbb" }] },
-				{ op: [{ p: 6, i: "ccc" }] }
-			];
-			const rewoundContent = this.DiffGenerator.rewindUpdates(content, updates);
-			return rewoundContent.should.equal("aaa");
-		}); }
-	);
-
-	describe("buildDiff", function() {
-		beforeEach(function() {
-			this.diff = [ {u: "mock-diff"} ];
-			this.content = "Hello world";
-			this.updates = [
-				{ i: "mock-update-1" },
-				{ i: "mock-update-2" },
-				{ i: "mock-update-3" }
-			];
-			this.DiffGenerator.applyUpdateToDiff = sinon.stub().returns(this.diff);
-			this.DiffGenerator.compressDiff = sinon.stub().returns(this.diff);
-			return this.result = this.DiffGenerator.buildDiff(this.content, this.updates);
-		});
-
-		it("should return the diff", function() {
-			return this.result.should.deep.equal(this.diff);
-		});
-
-		it("should build the content into an initial diff", function() {
-			return this.DiffGenerator.applyUpdateToDiff
-				.calledWith([{
-					u: this.content
-				}], this.updates[0])
-				.should.equal(true);
-		});
-
-		it("should apply each update", function() {
-			return Array.from(this.updates).map((update) =>
-				this.DiffGenerator.applyUpdateToDiff
-					.calledWith(sinon.match.any, update)
-					.should.equal(true));
-		});
-
-		return it("should compress the diff", function() {
-			return this.DiffGenerator.compressDiff
-				.calledWith(this.diff)
-				.should.equal(true);
-		});
-	});
-
-	describe("compressDiff", function() {
-		describe("with adjacent inserts with the same user_id", function() { return it("should create one update with combined meta data and min/max timestamps", function() {
-				const diff = this.DiffGenerator.compressDiff([
-					{ i: "foo", meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }},
-					{ i: "bar", meta: { start_ts: 5,  end_ts: 15, user: { id: this.user_id } }}
-				]);
-				return expect(diff).to.deep.equal([
-					{ i: "foobar", meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } }}
-				]);
-			}); }
-		);
-
-		describe("with adjacent inserts with different user_ids", function() { return it("should leave the inserts unchanged", function() {
-				const input = [
-					{ i: "foo", meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }},
-					{ i: "bar", meta: { start_ts: 5,  end_ts: 15, user: { id: this.user_id_2 } }}
-				];
-				const output = this.DiffGenerator.compressDiff(input);
-				return expect(output).to.deep.equal(input);
-			}); }
-		);
-
-		describe("with adjacent deletes with the same user_id", function() { return it("should create one update with combined meta data and min/max timestamps", function() {
-				const diff = this.DiffGenerator.compressDiff([
-					{ d: "foo", meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }},
-					{ d: "bar", meta: { start_ts: 5,  end_ts: 15, user: { id: this.user_id } }}
-				]);
-				return expect(diff).to.deep.equal([
-					{ d: "foobar", meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } }}
-				]);
-			}); }
-		);
-
-		return describe("with adjacent deletes with different user_ids", function() { return it("should leave the deletes unchanged", function() {
-				const input = [
-					{ d: "foo", meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }},
-					{ d: "bar", meta: { start_ts: 5,  end_ts: 15, user: { id: this.user_id_2 } }}
-				];
-				const output = this.DiffGenerator.compressDiff(input);
-				return expect(output).to.deep.equal(input);
-			}); }
-		);
-	});
-
-	return describe("applyUpdateToDiff", function() {
-		describe("an insert", function() {
-			it("should insert into the middle of (u)nchanged text", function() {
-				const diff = this.DiffGenerator.applyUpdateToDiff(
-					[ { u: "foobar" } ],
-					{ op: [{ p: 3, i: "baz" }], meta: this.meta }
-				);
-				return expect(diff).to.deep.equal([
-					{ u: "foo" },
-					{ i: "baz", meta: this.meta },
-					{ u: "bar" }
-				]);
-			});
-
-			it("should insert into the start of (u)changed text", function() {
-				const diff = this.DiffGenerator.applyUpdateToDiff(
-					[ { u: "foobar" } ],
-					{ op: [{ p: 0, i: "baz" }], meta: this.meta }
-				);
-				return expect(diff).to.deep.equal([
-					{ i: "baz", meta: this.meta },
-					{ u: "foobar" }
-				]);
-			});
-
-			it("should insert into the end of (u)changed text", function() {
-				const diff = this.DiffGenerator.applyUpdateToDiff(
-					[ { u: "foobar" } ],
-					{ op: [{ p: 6, i: "baz" }], meta: this.meta }
-				);
-				return expect(diff).to.deep.equal([
-					{ u: "foobar" },
-					{ i: "baz", meta: this.meta }
-				]);
-			});
-
-			it("should insert into the middle of (i)inserted text", function() {
-				const diff = this.DiffGenerator.applyUpdateToDiff(
-					[ { i: "foobar", meta: this.meta } ],
-					{ op: [{ p: 3, i: "baz" }], meta: this.meta }
-				);
-				return expect(diff).to.deep.equal([
-					{ i: "foo", meta: this.meta },
-					{ i: "baz", meta: this.meta },
-					{ i: "bar", meta: this.meta }
-				]);
-			});
-
-			return it("should not count deletes in the running length total", function() {
-				const diff = this.DiffGenerator.applyUpdateToDiff(
-					[
-						{ d: "deleted", meta: this.meta },
-						{ u: "foobar" }
-					],
-					{ op: [{ p: 3, i: "baz" }], meta: this.meta }
-				);
-				return expect(diff).to.deep.equal([
-					{ d: "deleted", meta: this.meta },
-					{ u: "foo" },
-					{ i: "baz", meta: this.meta },
-					{ u: "bar" }
-				]);
-			});
-		});
-
-		return describe("a delete", function() {
-			describe("deleting unchanged text", function() {
-				it("should delete from the middle of (u)nchanged text", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { u: "foobazbar" } ],
-						{ op: [{ p: 3, d: "baz" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ u: "foo" },
-						{ d: "baz", meta: this.meta },
-						{ u: "bar" }
-					]);
-				});
-
-				it("should delete from the start of (u)nchanged text", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { u: "foobazbar" } ],
-						{ op: [{ p: 0, d: "foo" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ d: "foo", meta: this.meta },
-						{ u: "bazbar" }
-					]);
-				});
-
-				it("should delete from the end of (u)nchanged text", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { u: "foobazbar" } ],
-						{ op: [{ p: 6, d: "bar" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ u: "foobaz" },
-						{ d: "bar", meta: this.meta }
-					]);
-				});
-
-				return it("should delete across multiple (u)changed text parts", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { u: "foo" }, { u: "baz" }, { u: "bar" } ],
-						{ op: [{ p: 2, d: "obazb" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ u: "fo" },
-						{ d: "o", meta: this.meta },
-						{ d: "baz", meta: this.meta },
-						{ d: "b", meta: this.meta },
-						{ u: "ar" }
-					]);
-				});
-			});
-
-			describe("deleting inserts", function() {
-				it("should delete from the middle of (i)nserted text", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { i: "foobazbar", meta: this.meta } ],
-						{ op: [{ p: 3, d: "baz" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ i: "foo", meta: this.meta },
-						{ i: "bar", meta: this.meta }
-					]);
-				});
-
-				it("should delete from the start of (u)nchanged text", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { i: "foobazbar", meta: this.meta } ],
-						{ op: [{ p: 0, d: "foo" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ i: "bazbar", meta: this.meta }
-					]);
-				});
-
-				it("should delete from the end of (u)nchanged text", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { i: "foobazbar", meta: this.meta } ],
-						{ op: [{ p: 6, d: "bar" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ i: "foobaz", meta: this.meta }
-					]);
-				});
-
-				return it("should delete across multiple (u)changed and (i)nserted text parts", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { u: "foo" }, { i: "baz", meta: this.meta }, { u: "bar" } ],
-						{ op: [{ p: 2, d: "obazb" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ u: "fo" },
-						{ d: "o", meta: this.meta },
-						{ d: "b", meta: this.meta },
-						{ u: "ar" }
-					]);
-				});
-			});
-
-			describe("deleting over existing deletes", function() { return it("should delete across multiple (u)changed and (d)deleted text parts", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { u: "foo" }, { d: "baz", meta: this.meta }, { u: "bar" } ],
-						{ op: [{ p: 2, d: "ob" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ u: "fo" },
-						{ d: "o", meta: this.meta },
-						{ d: "baz", meta: this.meta },
-						{ d: "b", meta: this.meta },
-						{ u: "ar" }
-					]);
-				}); }
-			);
-
-			describe("deleting when the text doesn't match", function() {
-				it("should throw an error when deleting from the middle of (u)nchanged text", function() {
-					return expect(
-						() => this.DiffGenerator.applyUpdateToDiff(
-							[ { u: "foobazbar" } ],
-							{ op: [{ p: 3, d: "xxx" }], meta: this.meta }
-						)
-					).to.throw(this.DiffGenerator.ConsistencyError);
-				});
-
-				it("should throw an error when deleting from the start of (u)nchanged text", function() {
-					return expect(
-						() => this.DiffGenerator.applyUpdateToDiff(
-							[ { u: "foobazbar" } ],
-							{ op: [{ p: 0, d: "xxx" }], meta: this.meta }
-						)
-					).to.throw(this.DiffGenerator.ConsistencyError);
-				});
-
-				return it("should throw an error when deleting from the end of (u)nchanged text", function() {
-					return expect(
-						() => this.DiffGenerator.applyUpdateToDiff(
-							[ { u: "foobazbar" } ],
-							{ op: [{ p: 6, d: "xxx" }] , meta: this.meta }
-						)
-					).to.throw(this.DiffGenerator.ConsistencyError);
-				});
-			});
-
-			describe("when the last update in the existing diff is a delete", function() { return it("should insert the new update before the delete", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { u: "foo" }, { d: "bar", meta: this.meta } ],
-						{ op: [{ p: 3, i: "baz" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ u: "foo" },
-						{ i: "baz", meta: this.meta },
-						{ d: "bar", meta: this.meta }
-					]);
-				}); }
-			);
-
-			return describe("when the only update in the existing diff is a delete", function() { return it("should insert the new update after the delete", function() {
-					const diff = this.DiffGenerator.applyUpdateToDiff(
-						[ { d: "bar", meta: this.meta } ],
-						{ op: [{ p: 0, i: "baz" }], meta: this.meta }
-					);
-					return expect(diff).to.deep.equal([
-						{ d: "bar", meta: this.meta },
-						{ i: "baz", meta: this.meta }
-					]);
-				}); }
-			);
-		});
-	});
-});
-
-
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/DiffGenerator.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('DiffGenerator', function() {
+  beforeEach(function() {
+    this.DiffGenerator = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': { warn: sinon.stub() }
+      }
+    })
+    this.ts = Date.now()
+    this.user_id = 'mock-user-id'
+    this.user_id_2 = 'mock-user-id-2'
+    return (this.meta = {
+      start_ts: this.ts,
+      end_ts: this.ts,
+      user_id: this.user_id
+    })
+  })
+
+  describe('rewindOp', function() {
+    describe('rewinding an insert', function() {
+      return it('should undo the insert', function() {
+        const content = 'hello world'
+        const rewoundContent = this.DiffGenerator.rewindOp(content, {
+          p: 6,
+          i: 'wo'
+        })
+        return rewoundContent.should.equal('hello rld')
+      })
+    })
+
+    describe('rewinding a delete', function() {
+      return it('should undo the delete', function() {
+        const content = 'hello rld'
+        const rewoundContent = this.DiffGenerator.rewindOp(content, {
+          p: 6,
+          d: 'wo'
+        })
+        return rewoundContent.should.equal('hello world')
+      })
+    })
+
+    describe('with an inconsistent update', function() {
+      return it('should throw an error', function() {
+        const content = 'hello world'
+        return expect(() => {
+          return this.DiffGenerator.rewindOp(content, { p: 6, i: 'foo' })
+        }).to.throw(this.DiffGenerator.ConsistencyError)
+      })
+    })
+
+    return describe('with an update which is beyond the length of the content', function() {
+      return it('should undo the insert as if it were at the end of the content', function() {
+        const content = 'foobar'
+        const rewoundContent = this.DiffGenerator.rewindOp(content, {
+          p: 4,
+          i: 'bar'
+        })
+        return rewoundContent.should.equal('foo')
+      })
+    })
+  })
+
+  describe('rewindUpdate', function() {
+    return it('should rewind ops in reverse', function() {
+      const content = 'aaabbbccc'
+      const update = {
+        op: [
+          { p: 3, i: 'bbb' },
+          { p: 6, i: 'ccc' }
+        ]
+      }
+      const rewoundContent = this.DiffGenerator.rewindUpdate(content, update)
+      return rewoundContent.should.equal('aaa')
+    })
+  })
+
+  describe('rewindUpdates', function() {
+    return it('should rewind updates in reverse', function() {
+      const content = 'aaabbbccc'
+      const updates = [
+        { op: [{ p: 3, i: 'bbb' }] },
+        { op: [{ p: 6, i: 'ccc' }] }
+      ]
+      const rewoundContent = this.DiffGenerator.rewindUpdates(content, updates)
+      return rewoundContent.should.equal('aaa')
+    })
+  })
+
+  describe('buildDiff', function() {
+    beforeEach(function() {
+      this.diff = [{ u: 'mock-diff' }]
+      this.content = 'Hello world'
+      this.updates = [
+        { i: 'mock-update-1' },
+        { i: 'mock-update-2' },
+        { i: 'mock-update-3' }
+      ]
+      this.DiffGenerator.applyUpdateToDiff = sinon.stub().returns(this.diff)
+      this.DiffGenerator.compressDiff = sinon.stub().returns(this.diff)
+      return (this.result = this.DiffGenerator.buildDiff(
+        this.content,
+        this.updates
+      ))
+    })
+
+    it('should return the diff', function() {
+      return this.result.should.deep.equal(this.diff)
+    })
+
+    it('should build the content into an initial diff', function() {
+      return this.DiffGenerator.applyUpdateToDiff
+        .calledWith(
+          [
+            {
+              u: this.content
+            }
+          ],
+          this.updates[0]
+        )
+        .should.equal(true)
+    })
+
+    it('should apply each update', function() {
+      return Array.from(this.updates).map(update =>
+        this.DiffGenerator.applyUpdateToDiff
+          .calledWith(sinon.match.any, update)
+          .should.equal(true)
+      )
+    })
+
+    return it('should compress the diff', function() {
+      return this.DiffGenerator.compressDiff
+        .calledWith(this.diff)
+        .should.equal(true)
+    })
+  })
+
+  describe('compressDiff', function() {
+    describe('with adjacent inserts with the same user_id', function() {
+      return it('should create one update with combined meta data and min/max timestamps', function() {
+        const diff = this.DiffGenerator.compressDiff([
+          {
+            i: 'foo',
+            meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }
+          },
+          {
+            i: 'bar',
+            meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id } }
+          }
+        ])
+        return expect(diff).to.deep.equal([
+          {
+            i: 'foobar',
+            meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } }
+          }
+        ])
+      })
+    })
+
+    describe('with adjacent inserts with different user_ids', function() {
+      return it('should leave the inserts unchanged', function() {
+        const input = [
+          {
+            i: 'foo',
+            meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }
+          },
+          {
+            i: 'bar',
+            meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id_2 } }
+          }
+        ]
+        const output = this.DiffGenerator.compressDiff(input)
+        return expect(output).to.deep.equal(input)
+      })
+    })
+
+    describe('with adjacent deletes with the same user_id', function() {
+      return it('should create one update with combined meta data and min/max timestamps', function() {
+        const diff = this.DiffGenerator.compressDiff([
+          {
+            d: 'foo',
+            meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }
+          },
+          {
+            d: 'bar',
+            meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id } }
+          }
+        ])
+        return expect(diff).to.deep.equal([
+          {
+            d: 'foobar',
+            meta: { start_ts: 5, end_ts: 20, user: { id: this.user_id } }
+          }
+        ])
+      })
+    })
+
+    return describe('with adjacent deletes with different user_ids', function() {
+      return it('should leave the deletes unchanged', function() {
+        const input = [
+          {
+            d: 'foo',
+            meta: { start_ts: 10, end_ts: 20, user: { id: this.user_id } }
+          },
+          {
+            d: 'bar',
+            meta: { start_ts: 5, end_ts: 15, user: { id: this.user_id_2 } }
+          }
+        ]
+        const output = this.DiffGenerator.compressDiff(input)
+        return expect(output).to.deep.equal(input)
+      })
+    })
+  })
+
+  return describe('applyUpdateToDiff', function() {
+    describe('an insert', function() {
+      it('should insert into the middle of (u)nchanged text', function() {
+        const diff = this.DiffGenerator.applyUpdateToDiff([{ u: 'foobar' }], {
+          op: [{ p: 3, i: 'baz' }],
+          meta: this.meta
+        })
+        return expect(diff).to.deep.equal([
+          { u: 'foo' },
+          { i: 'baz', meta: this.meta },
+          { u: 'bar' }
+        ])
+      })
+
+      it('should insert into the start of (u)changed text', function() {
+        const diff = this.DiffGenerator.applyUpdateToDiff([{ u: 'foobar' }], {
+          op: [{ p: 0, i: 'baz' }],
+          meta: this.meta
+        })
+        return expect(diff).to.deep.equal([
+          { i: 'baz', meta: this.meta },
+          { u: 'foobar' }
+        ])
+      })
+
+      it('should insert into the end of (u)changed text', function() {
+        const diff = this.DiffGenerator.applyUpdateToDiff([{ u: 'foobar' }], {
+          op: [{ p: 6, i: 'baz' }],
+          meta: this.meta
+        })
+        return expect(diff).to.deep.equal([
+          { u: 'foobar' },
+          { i: 'baz', meta: this.meta }
+        ])
+      })
+
+      it('should insert into the middle of (i)inserted text', function() {
+        const diff = this.DiffGenerator.applyUpdateToDiff(
+          [{ i: 'foobar', meta: this.meta }],
+          { op: [{ p: 3, i: 'baz' }], meta: this.meta }
+        )
+        return expect(diff).to.deep.equal([
+          { i: 'foo', meta: this.meta },
+          { i: 'baz', meta: this.meta },
+          { i: 'bar', meta: this.meta }
+        ])
+      })
+
+      return it('should not count deletes in the running length total', function() {
+        const diff = this.DiffGenerator.applyUpdateToDiff(
+          [{ d: 'deleted', meta: this.meta }, { u: 'foobar' }],
+          { op: [{ p: 3, i: 'baz' }], meta: this.meta }
+        )
+        return expect(diff).to.deep.equal([
+          { d: 'deleted', meta: this.meta },
+          { u: 'foo' },
+          { i: 'baz', meta: this.meta },
+          { u: 'bar' }
+        ])
+      })
+    })
+
+    return describe('a delete', function() {
+      describe('deleting unchanged text', function() {
+        it('should delete from the middle of (u)nchanged text', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ u: 'foobazbar' }],
+            { op: [{ p: 3, d: 'baz' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([
+            { u: 'foo' },
+            { d: 'baz', meta: this.meta },
+            { u: 'bar' }
+          ])
+        })
+
+        it('should delete from the start of (u)nchanged text', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ u: 'foobazbar' }],
+            { op: [{ p: 0, d: 'foo' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([
+            { d: 'foo', meta: this.meta },
+            { u: 'bazbar' }
+          ])
+        })
+
+        it('should delete from the end of (u)nchanged text', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ u: 'foobazbar' }],
+            { op: [{ p: 6, d: 'bar' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([
+            { u: 'foobaz' },
+            { d: 'bar', meta: this.meta }
+          ])
+        })
+
+        return it('should delete across multiple (u)changed text parts', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ u: 'foo' }, { u: 'baz' }, { u: 'bar' }],
+            { op: [{ p: 2, d: 'obazb' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([
+            { u: 'fo' },
+            { d: 'o', meta: this.meta },
+            { d: 'baz', meta: this.meta },
+            { d: 'b', meta: this.meta },
+            { u: 'ar' }
+          ])
+        })
+      })
+
+      describe('deleting inserts', function() {
+        it('should delete from the middle of (i)nserted text', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ i: 'foobazbar', meta: this.meta }],
+            { op: [{ p: 3, d: 'baz' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([
+            { i: 'foo', meta: this.meta },
+            { i: 'bar', meta: this.meta }
+          ])
+        })
+
+        it('should delete from the start of (u)nchanged text', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ i: 'foobazbar', meta: this.meta }],
+            { op: [{ p: 0, d: 'foo' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([{ i: 'bazbar', meta: this.meta }])
+        })
+
+        it('should delete from the end of (u)nchanged text', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ i: 'foobazbar', meta: this.meta }],
+            { op: [{ p: 6, d: 'bar' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([{ i: 'foobaz', meta: this.meta }])
+        })
+
+        return it('should delete across multiple (u)changed and (i)nserted text parts', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ u: 'foo' }, { i: 'baz', meta: this.meta }, { u: 'bar' }],
+            { op: [{ p: 2, d: 'obazb' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([
+            { u: 'fo' },
+            { d: 'o', meta: this.meta },
+            { d: 'b', meta: this.meta },
+            { u: 'ar' }
+          ])
+        })
+      })
+
+      describe('deleting over existing deletes', function() {
+        return it('should delete across multiple (u)changed and (d)deleted text parts', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ u: 'foo' }, { d: 'baz', meta: this.meta }, { u: 'bar' }],
+            { op: [{ p: 2, d: 'ob' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([
+            { u: 'fo' },
+            { d: 'o', meta: this.meta },
+            { d: 'baz', meta: this.meta },
+            { d: 'b', meta: this.meta },
+            { u: 'ar' }
+          ])
+        })
+      })
+
+      describe("deleting when the text doesn't match", function() {
+        it('should throw an error when deleting from the middle of (u)nchanged text', function() {
+          return expect(() =>
+            this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], {
+              op: [{ p: 3, d: 'xxx' }],
+              meta: this.meta
+            })
+          ).to.throw(this.DiffGenerator.ConsistencyError)
+        })
+
+        it('should throw an error when deleting from the start of (u)nchanged text', function() {
+          return expect(() =>
+            this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], {
+              op: [{ p: 0, d: 'xxx' }],
+              meta: this.meta
+            })
+          ).to.throw(this.DiffGenerator.ConsistencyError)
+        })
+
+        return it('should throw an error when deleting from the end of (u)nchanged text', function() {
+          return expect(() =>
+            this.DiffGenerator.applyUpdateToDiff([{ u: 'foobazbar' }], {
+              op: [{ p: 6, d: 'xxx' }],
+              meta: this.meta
+            })
+          ).to.throw(this.DiffGenerator.ConsistencyError)
+        })
+      })
+
+      describe('when the last update in the existing diff is a delete', function() {
+        return it('should insert the new update before the delete', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ u: 'foo' }, { d: 'bar', meta: this.meta }],
+            { op: [{ p: 3, i: 'baz' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([
+            { u: 'foo' },
+            { i: 'baz', meta: this.meta },
+            { d: 'bar', meta: this.meta }
+          ])
+        })
+      })
+
+      return describe('when the only update in the existing diff is a delete', function() {
+        return it('should insert the new update after the delete', function() {
+          const diff = this.DiffGenerator.applyUpdateToDiff(
+            [{ d: 'bar', meta: this.meta }],
+            { op: [{ p: 0, i: 'baz' }], meta: this.meta }
+          )
+          return expect(diff).to.deep.equal([
+            { d: 'bar', meta: this.meta },
+            { i: 'baz', meta: this.meta }
+          ])
+        })
+      })
+    })
+  })
+})

+ 436 - 298
services/track-changes/test/unit/js/DiffManager/DiffManagerTests.js

@@ -10,301 +10,439 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/DiffManager.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe("DiffManager", function() {
-	beforeEach(function() {
-		this.DiffManager = SandboxedModule.require(modulePath, { requires: {
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub(), warn: sinon.stub() }),
-			"./UpdatesManager": (this.UpdatesManager = {}),
-			"./DocumentUpdaterManager": (this.DocumentUpdaterManager = {}),
-			"./DiffGenerator": (this.DiffGenerator = {})
-		}
-	});
-		this.callback = sinon.stub();
-		this.from = new Date();
-		this.to = new Date(Date.now() + 10000);
-		this.project_id = "mock-project-id";
-		return this.doc_id = "mock-doc-id";
-	});
-
-	describe("getLatestDocAndUpdates", function() {
-		beforeEach(function() {
-			this.content = "hello world";
-			this.version = 42;
-			this.updates = [ "mock-update-1", "mock-update-2" ];
-
-			this.DocumentUpdaterManager.getDocument = sinon.stub().callsArgWith(2, null, this.content, this.version);
-			return this.UpdatesManager.getDocUpdatesWithUserInfo = sinon.stub().callsArgWith(3, null, this.updates);
-		});
-
-		describe("with a fromVersion", function() {
-			beforeEach(function() {
-				return this.DiffManager.getLatestDocAndUpdates(this.project_id, this.doc_id, this.from, this.callback);
-			});
-
-			it("should get the latest version of the doc", function() {
-				return this.DocumentUpdaterManager.getDocument
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should get the latest updates", function() {
-				return this.UpdatesManager.getDocUpdatesWithUserInfo
-					.calledWith(this.project_id, this.doc_id, {from: this.from})
-					.should.equal(true);
-			});
-
-			return it("should call the callback with the content, version and updates", function() {
-				return this.callback.calledWith(null, this.content, this.version, this.updates).should.equal(true);
-			});
-		});
-
-		return describe("with no fromVersion", function() {
-			beforeEach(function() {
-				return this.DiffManager.getLatestDocAndUpdates(this.project_id, this.doc_id, null, this.callback);
-			});
-
-			it("should get the latest version of the doc", function() {
-				return this.DocumentUpdaterManager.getDocument
-					.calledWith(this.project_id, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should not get the latest updates", function() {
-				return this.UpdatesManager.getDocUpdatesWithUserInfo
-					.called.should.equal(false);
-			});
-
-			return it("should call the callback with the content, version and blank updates", function() {
-				return this.callback.calledWith(null, this.content, this.version, []).should.equal(true);
-			});
-		});
-	});
-			
-
-	describe("getDiff", function() {
-		beforeEach(function() {
-			this.content = "hello world";
-			// Op versions are the version they were applied to, so doc is always one version
-			// ahead.s
-			this.version = 43;
-			this.updates = [
-				{ op: "mock-4", v: 42, meta: { start_ts: new Date(this.to.getTime() + 20)} },
-				{ op: "mock-3", v: 41, meta: { start_ts: new Date(this.to.getTime() + 10)} },
-				{ op: "mock-2", v: 40, meta: { start_ts: new Date(this.to.getTime() - 10)} },
-				{ op: "mock-1", v: 39, meta: { start_ts: new Date(this.to.getTime() - 20)} }
-			];
-			this.fromVersion = 39;
-			this.toVersion = 40;
-			this.diffed_updates = this.updates.slice(2);
-			this.rewound_content = "rewound-content";
-			return this.diff = [ {u: "mock-diff"} ];});
-			
-		describe("with matching versions", function() {
-			beforeEach(function() {
-				this.DiffManager.getDocumentBeforeVersion = sinon.stub().callsArgWith(3, null, this.rewound_content, this.updates);
-				this.DiffGenerator.buildDiff = sinon.stub().returns(this.diff);
-				return this.DiffManager.getDiff(this.project_id, this.doc_id, this.fromVersion, this.toVersion, this.callback);
-			});
-
-			it("should get the latest doc and version with all recent updates", function() {
-				return this.DiffManager.getDocumentBeforeVersion
-					.calledWith(this.project_id, this.doc_id, this.fromVersion)
-					.should.equal(true);
-			});
-
-			it("should generate the diff", function() {
-				return this.DiffGenerator.buildDiff
-					.calledWith(this.rewound_content, this.diffed_updates.slice().reverse())
-					.should.equal(true);
-			});
-
-			return it("should call the callback with the diff", function() {
-				return this.callback.calledWith(null, this.diff).should.equal(true);
-			});
-		});
-
-		return describe("when the updates are inconsistent", function() {
-			beforeEach(function() {
-				this.DiffManager.getLatestDocAndUpdates = sinon.stub().callsArgWith(3, null, this.content, this.version, this.updates);
-				this.DiffGenerator.buildDiff = sinon.stub().throws(this.error = new Error("inconsistent!"));
-				return this.DiffManager.getDiff(this.project_id, this.doc_id, this.fromVersion, this.toVersion, this.callback);
-			});
-
-			return it("should call the callback with an error", function() {
-				return this.callback
-					.calledWith(this.error)
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("getDocumentBeforeVersion", function() {
-		beforeEach(function() {
-			this.DiffManager._tryGetDocumentBeforeVersion = sinon.stub();
-			this.document = "mock-documents";
-			return this.rewound_updates = "mock-rewound-updates";
-		});
-
-		describe("succesfully", function() {
-			beforeEach(function() {
-				this.DiffManager._tryGetDocumentBeforeVersion.yields(null, this.document, this.rewound_updates);
-				return this.DiffManager.getDocumentBeforeVersion(this.project_id, this.doc_id, this.version, this.callback);
-			});
-			
-			it("should call _tryGetDocumentBeforeVersion", function() {
-				return this.DiffManager._tryGetDocumentBeforeVersion
-					.calledWith(this.project_id, this.doc_id, this.version)
-					.should.equal(true);
-			});
-			
-			return it("should call the callback with the response", function() {
-				return this.callback.calledWith(null, this.document, this.rewound_updates).should.equal(true);
-			});
-		});
-		
-		describe("with a retry needed", function() {
-			beforeEach(function() {
-				let retried = false;
-				this.DiffManager._tryGetDocumentBeforeVersion = (project_id, doc_id, version, callback) => {
-					if (!retried) {
-						retried = true;
-						const error = new Error();
-						error.retry = true;
-						return callback(error);
-					} else {
-						return callback(null, this.document, this.rewound_updates);
-					}
-				};
-				sinon.spy(this.DiffManager, "_tryGetDocumentBeforeVersion");
-				return this.DiffManager.getDocumentBeforeVersion(this.project_id, this.doc_id, this.version, this.callback);
-			});
-			
-			it("should call _tryGetDocumentBeforeVersion twice", function() {
-				return this.DiffManager._tryGetDocumentBeforeVersion
-					.calledTwice
-					.should.equal(true);
-			});
-			
-			return it("should call the callback with the response", function() {
-				return this.callback.calledWith(null, this.document, this.rewound_updates).should.equal(true);
-			});
-		});
-		
-		describe("with a non-retriable error", function() {
-			beforeEach(function() {
-				this.error = new Error("oops");
-				this.DiffManager._tryGetDocumentBeforeVersion.yields(this.error);
-				return this.DiffManager.getDocumentBeforeVersion(this.project_id, this.doc_id, this.version, this.callback);
-			});
-			
-			it("should call _tryGetDocumentBeforeVersion once", function() {
-				return this.DiffManager._tryGetDocumentBeforeVersion
-					.calledOnce
-					.should.equal(true);
-			});
-			
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-		
-		return describe("when retry limit is matched", function() {
-			beforeEach(function() {
-				this.error = new Error("oops");
-				this.error.retry = true;
-				this.DiffManager._tryGetDocumentBeforeVersion.yields(this.error);
-				return this.DiffManager.getDocumentBeforeVersion(this.project_id, this.doc_id, this.version, this.callback);
-			});
-			
-			it("should call _tryGetDocumentBeforeVersion three times (max retries)", function() {
-				return this.DiffManager._tryGetDocumentBeforeVersion
-					.calledThrice
-					.should.equal(true);
-			});
-			
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-	});
-
-	return describe("_tryGetDocumentBeforeVersion", function() {
-		beforeEach(function() {
-			this.content = "hello world";
-			// Op versions are the version they were applied to, so doc is always one version
-			// ahead.s
-			this.version = 43;
-			this.updates = [
-				{ op: "mock-4", v: 42, meta: { start_ts: new Date(this.to.getTime() + 20)} },
-				{ op: "mock-3", v: 41, meta: { start_ts: new Date(this.to.getTime() + 10)} },
-				{ op: "mock-2", v: 40, meta: { start_ts: new Date(this.to.getTime() - 10)} },
-				{ op: "mock-1", v: 39, meta: { start_ts: new Date(this.to.getTime() - 20)} }
-			];
-			this.fromVersion = 39;
-			this.rewound_content = "rewound-content";
-			return this.diff = [ {u: "mock-diff"} ];});
-			
-		describe("with matching versions", function() {
-			beforeEach(function() {
-				this.DiffManager.getLatestDocAndUpdates = sinon.stub().callsArgWith(3, null, this.content, this.version, this.updates);
-				this.DiffGenerator.rewindUpdates = sinon.spy((content, updates) => {
-					// the rewindUpdates method reverses the 'updates' array
-					updates.reverse();
-					return this.rewound_content;
-				});
-				this.rewindUpdatesWithArgs = this.DiffGenerator.rewindUpdates.withArgs(this.content, this.updates.slice().reverse());
-				return this.DiffManager._tryGetDocumentBeforeVersion(this.project_id, this.doc_id, this.fromVersion, this.callback);
-			});
-
-			it("should get the latest doc and version with all recent updates", function() {
-				return this.DiffManager.getLatestDocAndUpdates
-					.calledWith(this.project_id, this.doc_id, this.fromVersion)
-					.should.equal(true);
-			});
-
-			it("should rewind the diff", function() {
-				return sinon.assert.calledOnce(this.rewindUpdatesWithArgs);
-			});
-
-			return it("should call the callback with the rewound document and updates", function() {
-				return this.callback.calledWith(null, this.rewound_content, this.updates).should.equal(true);
-			});
-		});
-
-		describe("with mismatching versions", function() {
-			beforeEach(function() {
-				this.version = 50;
-				this.updates = [ { op: "mock-1", v: 40 }, { op: "mock-1", v: 39 } ];
-				this.DiffManager.getLatestDocAndUpdates = sinon.stub().callsArgWith(3, null, this.content, this.version, this.updates);
-				return this.DiffManager._tryGetDocumentBeforeVersion(this.project_id, this.doc_id, this.fromVersion, this.callback);
-			});
-
-			return it("should call the callback with an error with retry = true set", function() {
-				this.callback.calledOnce.should.equal(true);
-				const error = this.callback.args[0][0];
-				return expect(error.retry).to.equal(true);
-			});
-		});
-
-		return describe("when the updates are inconsistent", function() {
-			beforeEach(function() {
-				this.DiffManager.getLatestDocAndUpdates = sinon.stub().callsArgWith(3, null, this.content, this.version, this.updates);
-				this.DiffGenerator.rewindUpdates = sinon.stub().throws(this.error = new Error("inconsistent!"));
-				return this.DiffManager.getDocumentBeforeVersion(this.project_id, this.doc_id, this.fromVersion, this.callback);
-			});
-
-			return it("should call the callback with an error", function() {
-				return this.callback
-					.calledWith(this.error)
-					.should.equal(true);
-			});
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/DiffManager.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('DiffManager', function() {
+  beforeEach(function() {
+    this.DiffManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub(),
+          warn: sinon.stub()
+        }),
+        './UpdatesManager': (this.UpdatesManager = {}),
+        './DocumentUpdaterManager': (this.DocumentUpdaterManager = {}),
+        './DiffGenerator': (this.DiffGenerator = {})
+      }
+    })
+    this.callback = sinon.stub()
+    this.from = new Date()
+    this.to = new Date(Date.now() + 10000)
+    this.project_id = 'mock-project-id'
+    return (this.doc_id = 'mock-doc-id')
+  })
+
+  describe('getLatestDocAndUpdates', function() {
+    beforeEach(function() {
+      this.content = 'hello world'
+      this.version = 42
+      this.updates = ['mock-update-1', 'mock-update-2']
+
+      this.DocumentUpdaterManager.getDocument = sinon
+        .stub()
+        .callsArgWith(2, null, this.content, this.version)
+      return (this.UpdatesManager.getDocUpdatesWithUserInfo = sinon
+        .stub()
+        .callsArgWith(3, null, this.updates))
+    })
+
+    describe('with a fromVersion', function() {
+      beforeEach(function() {
+        return this.DiffManager.getLatestDocAndUpdates(
+          this.project_id,
+          this.doc_id,
+          this.from,
+          this.callback
+        )
+      })
+
+      it('should get the latest version of the doc', function() {
+        return this.DocumentUpdaterManager.getDocument
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should get the latest updates', function() {
+        return this.UpdatesManager.getDocUpdatesWithUserInfo
+          .calledWith(this.project_id, this.doc_id, { from: this.from })
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the content, version and updates', function() {
+        return this.callback
+          .calledWith(null, this.content, this.version, this.updates)
+          .should.equal(true)
+      })
+    })
+
+    return describe('with no fromVersion', function() {
+      beforeEach(function() {
+        return this.DiffManager.getLatestDocAndUpdates(
+          this.project_id,
+          this.doc_id,
+          null,
+          this.callback
+        )
+      })
+
+      it('should get the latest version of the doc', function() {
+        return this.DocumentUpdaterManager.getDocument
+          .calledWith(this.project_id, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should not get the latest updates', function() {
+        return this.UpdatesManager.getDocUpdatesWithUserInfo.called.should.equal(
+          false
+        )
+      })
+
+      return it('should call the callback with the content, version and blank updates', function() {
+        return this.callback
+          .calledWith(null, this.content, this.version, [])
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('getDiff', function() {
+    beforeEach(function() {
+      this.content = 'hello world'
+      // Op versions are the version they were applied to, so doc is always one version
+      // ahead.s
+      this.version = 43
+      this.updates = [
+        {
+          op: 'mock-4',
+          v: 42,
+          meta: { start_ts: new Date(this.to.getTime() + 20) }
+        },
+        {
+          op: 'mock-3',
+          v: 41,
+          meta: { start_ts: new Date(this.to.getTime() + 10) }
+        },
+        {
+          op: 'mock-2',
+          v: 40,
+          meta: { start_ts: new Date(this.to.getTime() - 10) }
+        },
+        {
+          op: 'mock-1',
+          v: 39,
+          meta: { start_ts: new Date(this.to.getTime() - 20) }
+        }
+      ]
+      this.fromVersion = 39
+      this.toVersion = 40
+      this.diffed_updates = this.updates.slice(2)
+      this.rewound_content = 'rewound-content'
+      return (this.diff = [{ u: 'mock-diff' }])
+    })
+
+    describe('with matching versions', function() {
+      beforeEach(function() {
+        this.DiffManager.getDocumentBeforeVersion = sinon
+          .stub()
+          .callsArgWith(3, null, this.rewound_content, this.updates)
+        this.DiffGenerator.buildDiff = sinon.stub().returns(this.diff)
+        return this.DiffManager.getDiff(
+          this.project_id,
+          this.doc_id,
+          this.fromVersion,
+          this.toVersion,
+          this.callback
+        )
+      })
+
+      it('should get the latest doc and version with all recent updates', function() {
+        return this.DiffManager.getDocumentBeforeVersion
+          .calledWith(this.project_id, this.doc_id, this.fromVersion)
+          .should.equal(true)
+      })
+
+      it('should generate the diff', function() {
+        return this.DiffGenerator.buildDiff
+          .calledWith(
+            this.rewound_content,
+            this.diffed_updates.slice().reverse()
+          )
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the diff', function() {
+        return this.callback.calledWith(null, this.diff).should.equal(true)
+      })
+    })
+
+    return describe('when the updates are inconsistent', function() {
+      beforeEach(function() {
+        this.DiffManager.getLatestDocAndUpdates = sinon
+          .stub()
+          .callsArgWith(3, null, this.content, this.version, this.updates)
+        this.DiffGenerator.buildDiff = sinon
+          .stub()
+          .throws((this.error = new Error('inconsistent!')))
+        return this.DiffManager.getDiff(
+          this.project_id,
+          this.doc_id,
+          this.fromVersion,
+          this.toVersion,
+          this.callback
+        )
+      })
+
+      return it('should call the callback with an error', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+  })
+
+  describe('getDocumentBeforeVersion', function() {
+    beforeEach(function() {
+      this.DiffManager._tryGetDocumentBeforeVersion = sinon.stub()
+      this.document = 'mock-documents'
+      return (this.rewound_updates = 'mock-rewound-updates')
+    })
+
+    describe('succesfully', function() {
+      beforeEach(function() {
+        this.DiffManager._tryGetDocumentBeforeVersion.yields(
+          null,
+          this.document,
+          this.rewound_updates
+        )
+        return this.DiffManager.getDocumentBeforeVersion(
+          this.project_id,
+          this.doc_id,
+          this.version,
+          this.callback
+        )
+      })
+
+      it('should call _tryGetDocumentBeforeVersion', function() {
+        return this.DiffManager._tryGetDocumentBeforeVersion
+          .calledWith(this.project_id, this.doc_id, this.version)
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the response', function() {
+        return this.callback
+          .calledWith(null, this.document, this.rewound_updates)
+          .should.equal(true)
+      })
+    })
+
+    describe('with a retry needed', function() {
+      beforeEach(function() {
+        let retried = false
+        this.DiffManager._tryGetDocumentBeforeVersion = (
+          project_id,
+          doc_id,
+          version,
+          callback
+        ) => {
+          if (!retried) {
+            retried = true
+            const error = new Error()
+            error.retry = true
+            return callback(error)
+          } else {
+            return callback(null, this.document, this.rewound_updates)
+          }
+        }
+        sinon.spy(this.DiffManager, '_tryGetDocumentBeforeVersion')
+        return this.DiffManager.getDocumentBeforeVersion(
+          this.project_id,
+          this.doc_id,
+          this.version,
+          this.callback
+        )
+      })
+
+      it('should call _tryGetDocumentBeforeVersion twice', function() {
+        return this.DiffManager._tryGetDocumentBeforeVersion.calledTwice.should.equal(
+          true
+        )
+      })
+
+      return it('should call the callback with the response', function() {
+        return this.callback
+          .calledWith(null, this.document, this.rewound_updates)
+          .should.equal(true)
+      })
+    })
+
+    describe('with a non-retriable error', function() {
+      beforeEach(function() {
+        this.error = new Error('oops')
+        this.DiffManager._tryGetDocumentBeforeVersion.yields(this.error)
+        return this.DiffManager.getDocumentBeforeVersion(
+          this.project_id,
+          this.doc_id,
+          this.version,
+          this.callback
+        )
+      })
+
+      it('should call _tryGetDocumentBeforeVersion once', function() {
+        return this.DiffManager._tryGetDocumentBeforeVersion.calledOnce.should.equal(
+          true
+        )
+      })
+
+      return it('should call the callback with the error', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    return describe('when retry limit is matched', function() {
+      beforeEach(function() {
+        this.error = new Error('oops')
+        this.error.retry = true
+        this.DiffManager._tryGetDocumentBeforeVersion.yields(this.error)
+        return this.DiffManager.getDocumentBeforeVersion(
+          this.project_id,
+          this.doc_id,
+          this.version,
+          this.callback
+        )
+      })
+
+      it('should call _tryGetDocumentBeforeVersion three times (max retries)', function() {
+        return this.DiffManager._tryGetDocumentBeforeVersion.calledThrice.should.equal(
+          true
+        )
+      })
+
+      return it('should call the callback with the error', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+  })
+
+  return describe('_tryGetDocumentBeforeVersion', function() {
+    beforeEach(function() {
+      this.content = 'hello world'
+      // Op versions are the version they were applied to, so doc is always one version
+      // ahead.s
+      this.version = 43
+      this.updates = [
+        {
+          op: 'mock-4',
+          v: 42,
+          meta: { start_ts: new Date(this.to.getTime() + 20) }
+        },
+        {
+          op: 'mock-3',
+          v: 41,
+          meta: { start_ts: new Date(this.to.getTime() + 10) }
+        },
+        {
+          op: 'mock-2',
+          v: 40,
+          meta: { start_ts: new Date(this.to.getTime() - 10) }
+        },
+        {
+          op: 'mock-1',
+          v: 39,
+          meta: { start_ts: new Date(this.to.getTime() - 20) }
+        }
+      ]
+      this.fromVersion = 39
+      this.rewound_content = 'rewound-content'
+      return (this.diff = [{ u: 'mock-diff' }])
+    })
+
+    describe('with matching versions', function() {
+      beforeEach(function() {
+        this.DiffManager.getLatestDocAndUpdates = sinon
+          .stub()
+          .callsArgWith(3, null, this.content, this.version, this.updates)
+        this.DiffGenerator.rewindUpdates = sinon.spy((content, updates) => {
+          // the rewindUpdates method reverses the 'updates' array
+          updates.reverse()
+          return this.rewound_content
+        })
+        this.rewindUpdatesWithArgs = this.DiffGenerator.rewindUpdates.withArgs(
+          this.content,
+          this.updates.slice().reverse()
+        )
+        return this.DiffManager._tryGetDocumentBeforeVersion(
+          this.project_id,
+          this.doc_id,
+          this.fromVersion,
+          this.callback
+        )
+      })
+
+      it('should get the latest doc and version with all recent updates', function() {
+        return this.DiffManager.getLatestDocAndUpdates
+          .calledWith(this.project_id, this.doc_id, this.fromVersion)
+          .should.equal(true)
+      })
+
+      it('should rewind the diff', function() {
+        return sinon.assert.calledOnce(this.rewindUpdatesWithArgs)
+      })
+
+      return it('should call the callback with the rewound document and updates', function() {
+        return this.callback
+          .calledWith(null, this.rewound_content, this.updates)
+          .should.equal(true)
+      })
+    })
+
+    describe('with mismatching versions', function() {
+      beforeEach(function() {
+        this.version = 50
+        this.updates = [
+          { op: 'mock-1', v: 40 },
+          { op: 'mock-1', v: 39 }
+        ]
+        this.DiffManager.getLatestDocAndUpdates = sinon
+          .stub()
+          .callsArgWith(3, null, this.content, this.version, this.updates)
+        return this.DiffManager._tryGetDocumentBeforeVersion(
+          this.project_id,
+          this.doc_id,
+          this.fromVersion,
+          this.callback
+        )
+      })
+
+      return it('should call the callback with an error with retry = true set', function() {
+        this.callback.calledOnce.should.equal(true)
+        const error = this.callback.args[0][0]
+        return expect(error.retry).to.equal(true)
+      })
+    })
+
+    return describe('when the updates are inconsistent', function() {
+      beforeEach(function() {
+        this.DiffManager.getLatestDocAndUpdates = sinon
+          .stub()
+          .callsArgWith(3, null, this.content, this.version, this.updates)
+        this.DiffGenerator.rewindUpdates = sinon
+          .stub()
+          .throws((this.error = new Error('inconsistent!')))
+        return this.DiffManager.getDocumentBeforeVersion(
+          this.project_id,
+          this.doc_id,
+          this.fromVersion,
+          this.callback
+        )
+      })
+
+      return it('should call the callback with an error', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+  })
+})

+ 93 - 77
services/track-changes/test/unit/js/DocArchive/MongoAWS.js

@@ -9,88 +9,104 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const chai = require('chai');
-chai.should();
-const sinon = require("sinon");
-const modulePath = "../../../../app/js/MongoAWS.js";
-const SandboxedModule = require('sandboxed-module');
-const {ObjectId} = require("mongojs");
-const MemoryStream = require('memorystream');
-const zlib = require("zlib");
+const chai = require('chai')
+chai.should()
+const sinon = require('sinon')
+const modulePath = '../../../../app/js/MongoAWS.js'
+const SandboxedModule = require('sandboxed-module')
+const { ObjectId } = require('mongojs')
+const MemoryStream = require('memorystream')
+const zlib = require('zlib')
 
-describe("MongoAWS", function() {
-	beforeEach(function() {
-		this.MongoAWS = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex": (this.settings = {
-				trackchanges: {
-					s3: {
-						secret: "s3-secret",
-						key: "s3-key"
-					},
-					stores: {
-						doc_history: "s3-bucket"
-					}
-				}
-			}),
-			"child_process": (this.child_process = {}),
-			"mongo-uri": (this.mongouri = {}),
-			"logger-sharelatex": (this.logger = {log: sinon.stub(), error: sinon.stub(), err() {}}),
-			"aws-sdk": (this.awssdk = {}),
-			"fs": (this.fs = {}),
-			"s3-streams": (this.S3S = {}),
-			"./mongojs" : { db: (this.db = {}), ObjectId },
-			"JSONStream": (this.JSONStream = {}),
-			"readline-stream": (this.readline = sinon.stub()),
-			'metrics-sharelatex': {inc(){}}
-		}
-	});
+describe('MongoAWS', function() {
+  beforeEach(function() {
+    this.MongoAWS = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': (this.settings = {
+          trackchanges: {
+            s3: {
+              secret: 's3-secret',
+              key: 's3-key'
+            },
+            stores: {
+              doc_history: 's3-bucket'
+            }
+          }
+        }),
+        child_process: (this.child_process = {}),
+        'mongo-uri': (this.mongouri = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub(),
+          err() {}
+        }),
+        'aws-sdk': (this.awssdk = {}),
+        fs: (this.fs = {}),
+        's3-streams': (this.S3S = {}),
+        './mongojs': { db: (this.db = {}), ObjectId },
+        JSONStream: (this.JSONStream = {}),
+        'readline-stream': (this.readline = sinon.stub()),
+        'metrics-sharelatex': { inc() {} }
+      }
+    })
 
-		this.project_id = ObjectId().toString();
-		this.doc_id = ObjectId().toString();
-		this.pack_id = ObjectId();
-		this.update = { v:123 };
-		return this.callback = sinon.stub();
-	});
+    this.project_id = ObjectId().toString()
+    this.doc_id = ObjectId().toString()
+    this.pack_id = ObjectId()
+    this.update = { v: 123 }
+    return (this.callback = sinon.stub())
+  })
 
-	describe("archivePack", function() {
+  describe('archivePack', function() {
+    beforeEach(function(done) {
+      this.awssdk.config = { update: sinon.stub() }
+      this.awssdk.S3 = sinon.stub()
+      this.S3S.WriteStream = () => MemoryStream.createWriteStream()
+      this.db.docHistory = {}
+      this.db.docHistory.findOne = sinon
+        .stub()
+        .callsArgWith(1, null, { pack: 'hello' })
 
-		beforeEach(function(done) {
-			this.awssdk.config = { update: sinon.stub() };
-			this.awssdk.S3 = sinon.stub();
-			this.S3S.WriteStream = () => MemoryStream.createWriteStream();
-			this.db.docHistory = {};
-			this.db.docHistory.findOne = sinon.stub().callsArgWith(1, null, {"pack":"hello"});
+      return this.MongoAWS.archivePack(
+        this.project_id,
+        this.doc_id,
+        this.pack_id,
+        (err, result) => {
+          this.callback()
+          return done()
+        }
+      )
+    })
 
-			return this.MongoAWS.archivePack(this.project_id, this.doc_id, this.pack_id, (err, result) => {
-				this.callback();
-				return done();
-			});
-		});
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
 
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
+  return describe('unArchivePack', function() {
+    beforeEach(function(done) {
+      return zlib.gzip('{"pack":"123"}', (err, zbuf) => {
+        this.awssdk.config = { update: sinon.stub() }
+        this.awssdk.S3 = sinon.stub()
+        this.S3S.ReadStream = () =>
+          MemoryStream.createReadStream(zbuf, { readable: true })
+        this.db.docHistory = {}
+        this.db.docHistory.insert = sinon.stub().callsArgWith(1, null, 'pack')
 
-	return describe("unArchivePack", function() {
+        return this.MongoAWS.unArchivePack(
+          this.project_id,
+          this.doc_id,
+          this.pack_id,
+          (err, result) => {
+            this.callback()
+            return done()
+          }
+        )
+      })
+    })
 
-		beforeEach(function(done) {
-			return zlib.gzip('{"pack":"123"}', (err, zbuf) => {
-				this.awssdk.config = { update: sinon.stub() };
-				this.awssdk.S3 = sinon.stub();
-				this.S3S.ReadStream = () => MemoryStream.createReadStream(zbuf, {readable:true});
-				this.db.docHistory = {};
-				this.db.docHistory.insert = sinon.stub().callsArgWith(1, null, "pack");
-
-				return this.MongoAWS.unArchivePack(this.project_id, this.doc_id, this.pack_id, (err, result) => {
-					this.callback();
-					return done();
-				});
-			});
-		});
-
-		return it("should call db.docHistory.insert", function() {
-			return this.db.docHistory.insert.called.should.equal(true);
-		});
-	});
-});
+    return it('should call db.docHistory.insert', function() {
+      return this.db.docHistory.insert.called.should.equal(true)
+    })
+  })
+})

+ 194 - 124
services/track-changes/test/unit/js/DocumentUpdaterManager/DocumentUpdaterManagerTests.js

@@ -9,127 +9,197 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/DocumentUpdaterManager.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe("DocumentUpdaterManager", function() {
-	beforeEach(function() {
-		this.DocumentUpdaterManager = SandboxedModule.require(modulePath, { requires: {
-			"request": (this.request = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			'settings-sharelatex': (this.settings =
-				{apis : {documentupdater: {url : "http://example.com"}}})
-		}
-	}
-		);
-		this.callback = sinon.stub();
-		this.lines = ["one", "two", "three"];
-		return this.version = 42;
-	});
-
-	describe("getDocument", function() {
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.body = JSON.stringify({
-					lines: this.lines,
-					version: this.version,
-					ops: []});
-				this.request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, this.body);
-				return this.DocumentUpdaterManager.getDocument(this.project_id, this.doc_id, this.callback);
-			});
-
-			it('should get the document from the document updater', function() {
-				const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}`;
-				return this.request.get.calledWith(url).should.equal(true);
-			});
-
-			return it("should call the callback with the content and version", function() {
-				return this.callback.calledWith(null, this.lines.join("\n"), this.version).should.equal(true);
-			});
-		});
-
-		describe("when the document updater API returns an error", function() {
-			beforeEach(function() {
-				this.request.get = sinon.stub().callsArgWith(1, (this.error = new Error("something went wrong")), null, null);
-				return this.DocumentUpdaterManager.getDocument(this.project_id, this.doc_id, this.callback);
-			});
-
-			return it("should return an error to the callback", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		return describe("when the document updater returns a failure error code", function() {
-			beforeEach(function() {
-				this.request.get = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "");
-				return this.DocumentUpdaterManager.getDocument(this.project_id, this.doc_id, this.callback);
-			});
-
-			return it("should return the callback with an error", function() {
-				return this.callback
-					.calledWith(sinon.match.has('message', "doc updater returned a non-success status code: 500"))
-					.should.equal(true);
-			});
-		});
-	});
-
-	return describe("setDocument", function() {
-		beforeEach(function() {
-			this.content = "mock content";
-			return this.user_id = "user-id-123";
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.request.post = sinon.stub().callsArgWith(1, null, {statusCode: 200});
-				return this.DocumentUpdaterManager.setDocument(this.project_id, this.doc_id, this.content, this.user_id, this.callback);
-			});
-
-			it('should set the document in the document updater', function() {
-				const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}`;
-				return this.request.post
-					.calledWith({
-						url,
-						json: {
-							lines: this.content.split("\n"),
-							source: "restore",
-							user_id: this.user_id,
-							undoing: true
-						}
-					}).should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.calledWith(null).should.equal(true);
-			});
-		});
-
-		describe("when the document updater API returns an error", function() {
-			beforeEach(function() {
-				this.request.post = sinon.stub().callsArgWith(1, (this.error = new Error("something went wrong")), null, null);
-				return this.DocumentUpdaterManager.setDocument(this.project_id, this.doc_id, this.content, this.user_id, this.callback);
-			});
-
-			return it("should return an error to the callback", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		return describe("when the document updater returns a failure error code", function() {
-			beforeEach(function() {
-				this.request.post = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "");
-				return this.DocumentUpdaterManager.setDocument(this.project_id, this.doc_id, this.content, this.user_id, this.callback);
-			});
-
-			return it("should return the callback with an error", function() {
-				return this.callback
-					.calledWith(sinon.match.has('message', "doc updater returned a non-success status code: 500"))
-					.should.equal(true);
-			});
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/DocumentUpdaterManager.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('DocumentUpdaterManager', function() {
+  beforeEach(function() {
+    this.DocumentUpdaterManager = SandboxedModule.require(modulePath, {
+      requires: {
+        request: (this.request = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        'settings-sharelatex': (this.settings = {
+          apis: { documentupdater: { url: 'http://example.com' } }
+        })
+      }
+    })
+    this.callback = sinon.stub()
+    this.lines = ['one', 'two', 'three']
+    return (this.version = 42)
+  })
+
+  describe('getDocument', function() {
+    describe('successfully', function() {
+      beforeEach(function() {
+        this.body = JSON.stringify({
+          lines: this.lines,
+          version: this.version,
+          ops: []
+        })
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 }, this.body)
+        return this.DocumentUpdaterManager.getDocument(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the document from the document updater', function() {
+        const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}`
+        return this.request.get.calledWith(url).should.equal(true)
+      })
+
+      return it('should call the callback with the content and version', function() {
+        return this.callback
+          .calledWith(null, this.lines.join('\n'), this.version)
+          .should.equal(true)
+      })
+    })
+
+    describe('when the document updater API returns an error', function() {
+      beforeEach(function() {
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(
+            1,
+            (this.error = new Error('something went wrong')),
+            null,
+            null
+          )
+        return this.DocumentUpdaterManager.getDocument(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      return it('should return an error to the callback', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    return describe('when the document updater returns a failure error code', function() {
+      beforeEach(function() {
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 500 }, '')
+        return this.DocumentUpdaterManager.getDocument(
+          this.project_id,
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      return it('should return the callback with an error', function() {
+        return this.callback
+          .calledWith(
+            sinon.match.has(
+              'message',
+              'doc updater returned a non-success status code: 500'
+            )
+          )
+          .should.equal(true)
+      })
+    })
+  })
+
+  return describe('setDocument', function() {
+    beforeEach(function() {
+      this.content = 'mock content'
+      return (this.user_id = 'user-id-123')
+    })
+
+    describe('successfully', function() {
+      beforeEach(function() {
+        this.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 })
+        return this.DocumentUpdaterManager.setDocument(
+          this.project_id,
+          this.doc_id,
+          this.content,
+          this.user_id,
+          this.callback
+        )
+      })
+
+      it('should set the document in the document updater', function() {
+        const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}`
+        return this.request.post
+          .calledWith({
+            url,
+            json: {
+              lines: this.content.split('\n'),
+              source: 'restore',
+              user_id: this.user_id,
+              undoing: true
+            }
+          })
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.calledWith(null).should.equal(true)
+      })
+    })
+
+    describe('when the document updater API returns an error', function() {
+      beforeEach(function() {
+        this.request.post = sinon
+          .stub()
+          .callsArgWith(
+            1,
+            (this.error = new Error('something went wrong')),
+            null,
+            null
+          )
+        return this.DocumentUpdaterManager.setDocument(
+          this.project_id,
+          this.doc_id,
+          this.content,
+          this.user_id,
+          this.callback
+        )
+      })
+
+      return it('should return an error to the callback', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    return describe('when the document updater returns a failure error code', function() {
+      beforeEach(function() {
+        this.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 500 }, '')
+        return this.DocumentUpdaterManager.setDocument(
+          this.project_id,
+          this.doc_id,
+          this.content,
+          this.user_id,
+          this.callback
+        )
+      })
+
+      return it('should return the callback with an error', function() {
+        return this.callback
+          .calledWith(
+            sinon.match.has(
+              'message',
+              'doc updater returned a non-success status code: 500'
+            )
+          )
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 190 - 172
services/track-changes/test/unit/js/HttpController/HttpControllerTests.js

@@ -9,175 +9,193 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/HttpController.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe("HttpController", function() {
-	beforeEach(function() {
-		this.HttpController = SandboxedModule.require(modulePath, { requires: {
-			"logger-sharelatex": { log: sinon.stub() },
-			"./UpdatesManager": (this.UpdatesManager = {}),
-			"./DiffManager": (this.DiffManager = {}),
-			"./RestoreManager": (this.RestoreManager = {}),
-			"./PackManager": (this.PackManager = {}),
-			"./DocArchiveManager": (this.DocArchiveManager = {}),
-			"./HealthChecker": (this.HealthChecker = {})
-		}
-	});
-		this.doc_id = "doc-id-123";
-		this.project_id = "project-id-123";
-		this.next = sinon.stub();
-		this.user_id = "mock-user-123";
-		return this.now = Date.now();
-	});
-
-	describe("flushDoc", function() {
-		beforeEach(function() {
-			this.req = {
-				params: {
-					doc_id: this.doc_id,
-					project_id: this.project_id
-				}
-			};
-			this.res =
-				{send: sinon.stub()};
-			this.UpdatesManager.processUncompressedUpdatesWithLock = sinon.stub().callsArg(2);
-			return this.HttpController.flushDoc(this.req, this.res, this.next);
-		});
-
-		it("should process the updates", function() {
-			return this.UpdatesManager.processUncompressedUpdatesWithLock
-				.calledWith(this.project_id, this.doc_id)
-				.should.equal(true);
-		});
-
-		return it("should return a success code", function() {
-			return this.res.send.calledWith(204).should.equal(true);
-		});
-	});
-
-	describe("flushProject", function() {
-		beforeEach(function() {
-			this.req = {
-				params: {
-					project_id: this.project_id
-				}
-			};
-			this.res =
-				{send: sinon.stub()};
-			this.UpdatesManager.processUncompressedUpdatesForProject = sinon.stub().callsArg(1);
-			return this.HttpController.flushProject(this.req, this.res, this.next);
-		});
-
-		it("should process the updates", function() {
-			return this.UpdatesManager.processUncompressedUpdatesForProject
-				.calledWith(this.project_id)
-				.should.equal(true);
-		});
-
-		return it("should return a success code", function() {
-			return this.res.send.calledWith(204).should.equal(true);
-		});
-	});
-
-
-	describe("getDiff", function() {
-		beforeEach(function() {
-			this.from = 42;
-			this.to = 45;
-			this.req = {
-				params: {
-					doc_id: this.doc_id,
-					project_id: this.project_id
-				},
-				query: {
-					from: this.from.toString(),
-					to: this.to.toString()
-				}
-			};
-			this.res =
-				{json: sinon.stub()};
-			this.diff = [ {u: "mock-diff"} ];
-			this.DiffManager.getDiff = sinon.stub().callsArgWith(4, null, this.diff);
-			return this.HttpController.getDiff(this.req, this.res, this.next);
-		});
-
-		it("should get the diff", function() {
-			return this.DiffManager.getDiff
-				.calledWith(this.project_id, this.doc_id, parseInt(this.from, 10), parseInt(this.to, 10))
-				.should.equal(true);
-		});
-
-		return it("should return the diff", function() {
-			return this.res.json.calledWith({diff: this.diff}).should.equal(true);
-		});
-	});
-
-	describe("getUpdates", function() {
-		beforeEach(function() {
-			this.before = Date.now();
-			this.nextBeforeTimestamp = this.before - 100;
-			this.min_count = 10;
-			this.req = {
-				params: {
-					project_id: this.project_id
-				},
-				query: {
-					before:    this.before.toString(),
-					min_count: this.min_count.toString()
-				}
-			};
-			this.res =
-				{json: sinon.stub()};
-			this.updates = ["mock-summarized-updates"];
-			this.UpdatesManager.getSummarizedProjectUpdates = sinon.stub().callsArgWith(2, null, this.updates, this.nextBeforeTimestamp);
-			return this.HttpController.getUpdates(this.req, this.res, this.next);
-		});
-
-		it("should get the updates", function() {
-			return this.UpdatesManager.getSummarizedProjectUpdates
-				.calledWith(this.project_id, {before: this.before, min_count: this.min_count})
-				.should.equal(true);
-		});
-
-		return it("should return the formatted updates", function() {
-			return this.res.json.calledWith({updates: this.updates, nextBeforeTimestamp: this.nextBeforeTimestamp}).should.equal(true);
-		});
-	});
-
-	return describe("RestoreManager", function() {
-		beforeEach(function() {
-			this.version = "42";
-			this.req = {
-				params: {
-					doc_id: this.doc_id,
-					project_id: this.project_id,
-					version: this.version
-				},
-				headers: {
-					"x-user-id": this.user_id
-				}
-			};
-			this.res =
-				{send: sinon.stub()};
-
-			this.RestoreManager.restoreToBeforeVersion = sinon.stub().callsArg(4);
-			return this.HttpController.restore(this.req, this.res, this.next);
-		});
-
-		it("should restore the document", function() {
-			return this.RestoreManager.restoreToBeforeVersion
-				.calledWith(this.project_id, this.doc_id, parseInt(this.version, 10), this.user_id)
-				.should.equal(true);
-		});
-
-		return it("should return a success code", function() {
-			return this.res.send.calledWith(204).should.equal(true);
-		});
-	});
-});
-
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/HttpController.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('HttpController', function() {
+  beforeEach(function() {
+    this.HttpController = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': { log: sinon.stub() },
+        './UpdatesManager': (this.UpdatesManager = {}),
+        './DiffManager': (this.DiffManager = {}),
+        './RestoreManager': (this.RestoreManager = {}),
+        './PackManager': (this.PackManager = {}),
+        './DocArchiveManager': (this.DocArchiveManager = {}),
+        './HealthChecker': (this.HealthChecker = {})
+      }
+    })
+    this.doc_id = 'doc-id-123'
+    this.project_id = 'project-id-123'
+    this.next = sinon.stub()
+    this.user_id = 'mock-user-123'
+    return (this.now = Date.now())
+  })
+
+  describe('flushDoc', function() {
+    beforeEach(function() {
+      this.req = {
+        params: {
+          doc_id: this.doc_id,
+          project_id: this.project_id
+        }
+      }
+      this.res = { send: sinon.stub() }
+      this.UpdatesManager.processUncompressedUpdatesWithLock = sinon
+        .stub()
+        .callsArg(2)
+      return this.HttpController.flushDoc(this.req, this.res, this.next)
+    })
+
+    it('should process the updates', function() {
+      return this.UpdatesManager.processUncompressedUpdatesWithLock
+        .calledWith(this.project_id, this.doc_id)
+        .should.equal(true)
+    })
+
+    return it('should return a success code', function() {
+      return this.res.send.calledWith(204).should.equal(true)
+    })
+  })
+
+  describe('flushProject', function() {
+    beforeEach(function() {
+      this.req = {
+        params: {
+          project_id: this.project_id
+        }
+      }
+      this.res = { send: sinon.stub() }
+      this.UpdatesManager.processUncompressedUpdatesForProject = sinon
+        .stub()
+        .callsArg(1)
+      return this.HttpController.flushProject(this.req, this.res, this.next)
+    })
+
+    it('should process the updates', function() {
+      return this.UpdatesManager.processUncompressedUpdatesForProject
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
+
+    return it('should return a success code', function() {
+      return this.res.send.calledWith(204).should.equal(true)
+    })
+  })
+
+  describe('getDiff', function() {
+    beforeEach(function() {
+      this.from = 42
+      this.to = 45
+      this.req = {
+        params: {
+          doc_id: this.doc_id,
+          project_id: this.project_id
+        },
+        query: {
+          from: this.from.toString(),
+          to: this.to.toString()
+        }
+      }
+      this.res = { json: sinon.stub() }
+      this.diff = [{ u: 'mock-diff' }]
+      this.DiffManager.getDiff = sinon.stub().callsArgWith(4, null, this.diff)
+      return this.HttpController.getDiff(this.req, this.res, this.next)
+    })
+
+    it('should get the diff', function() {
+      return this.DiffManager.getDiff
+        .calledWith(
+          this.project_id,
+          this.doc_id,
+          parseInt(this.from, 10),
+          parseInt(this.to, 10)
+        )
+        .should.equal(true)
+    })
+
+    return it('should return the diff', function() {
+      return this.res.json.calledWith({ diff: this.diff }).should.equal(true)
+    })
+  })
+
+  describe('getUpdates', function() {
+    beforeEach(function() {
+      this.before = Date.now()
+      this.nextBeforeTimestamp = this.before - 100
+      this.min_count = 10
+      this.req = {
+        params: {
+          project_id: this.project_id
+        },
+        query: {
+          before: this.before.toString(),
+          min_count: this.min_count.toString()
+        }
+      }
+      this.res = { json: sinon.stub() }
+      this.updates = ['mock-summarized-updates']
+      this.UpdatesManager.getSummarizedProjectUpdates = sinon
+        .stub()
+        .callsArgWith(2, null, this.updates, this.nextBeforeTimestamp)
+      return this.HttpController.getUpdates(this.req, this.res, this.next)
+    })
+
+    it('should get the updates', function() {
+      return this.UpdatesManager.getSummarizedProjectUpdates
+        .calledWith(this.project_id, {
+          before: this.before,
+          min_count: this.min_count
+        })
+        .should.equal(true)
+    })
+
+    return it('should return the formatted updates', function() {
+      return this.res.json
+        .calledWith({
+          updates: this.updates,
+          nextBeforeTimestamp: this.nextBeforeTimestamp
+        })
+        .should.equal(true)
+    })
+  })
+
+  return describe('RestoreManager', function() {
+    beforeEach(function() {
+      this.version = '42'
+      this.req = {
+        params: {
+          doc_id: this.doc_id,
+          project_id: this.project_id,
+          version: this.version
+        },
+        headers: {
+          'x-user-id': this.user_id
+        }
+      }
+      this.res = { send: sinon.stub() }
+
+      this.RestoreManager.restoreToBeforeVersion = sinon.stub().callsArg(4)
+      return this.HttpController.restore(this.req, this.res, this.next)
+    })
+
+    it('should restore the document', function() {
+      return this.RestoreManager.restoreToBeforeVersion
+        .calledWith(
+          this.project_id,
+          this.doc_id,
+          parseInt(this.version, 10),
+          this.user_id
+        )
+        .should.equal(true)
+    })
+
+    return it('should return a success code', function() {
+      return this.res.send.calledWith(204).should.equal(true)
+    })
+  })
+})

+ 303 - 267
services/track-changes/test/unit/js/LockManager/LockManagerTests.js

@@ -15,270 +15,306 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/LockManager.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe("LockManager", function() {
-	beforeEach(function() {
-		this.Settings = { 		
-			redis: {
-				lock:{}
-			}
-		};
-		this.LockManager = SandboxedModule.require(modulePath, { requires: {
-			"redis-sharelatex": {
-				createClient: () => { return this.rclient =
-					{auth: sinon.stub()}; }
-			},
-			"settings-sharelatex": this.Settings,
-			"logger-sharelatex": {error() {}}
-		}
-	});
-
-		this.key = "lock-key";
-		return this.callback = sinon.stub();
-	});
-
-	describe("checkLock", function() {
-		describe("when the lock is taken", function() {
-			beforeEach(function() {
-				this.rclient.exists = sinon.stub().callsArgWith(1, null, "1");
-				return this.LockManager.checkLock(this.key, this.callback);
-			});
-
-			it("should check the lock in redis", function() {
-				return this.rclient.exists
-					.calledWith(this.key)
-					.should.equal(true);
-			});
-
-			return it("should return the callback with false", function() {
-				return this.callback.calledWith(null, false).should.equal(true);
-			});
-		});
-
-		return describe("when the lock is free", function() {
-			beforeEach(function() {
-				this.rclient.exists = sinon.stub().callsArgWith(1, null, "0");
-				return this.LockManager.checkLock(this.key, this.callback);
-			});
-
-			return it("should return the callback with true", function() {
-				return this.callback.calledWith(null, true).should.equal(true);
-			});
-		});
-	});
-
-
-	describe("tryLock", function() {
-		describe("when the lock is taken", function() {
-			beforeEach(function() {
-				this.rclient.set = sinon.stub().callsArgWith(5, null, null);
-				this.LockManager.randomLock = sinon.stub().returns("locked-random-value");
-				return this.LockManager.tryLock(this.key, this.callback);
-			});
-
-			it("should check the lock in redis", function() {
-				return this.rclient.set
-					.calledWith(this.key, "locked-random-value", "EX", this.LockManager.LOCK_TTL, "NX")
-					.should.equal(true);
-			});
-
-			return it("should return the callback with false", function() {
-				return this.callback.calledWith(null, false).should.equal(true);
-			});
-		});
-
-		return describe("when the lock is free", function() {
-			beforeEach(function() {
-				this.rclient.set = sinon.stub().callsArgWith(5, null, "OK");
-				return this.LockManager.tryLock(this.key, this.callback);
-			});
-
-			return it("should return the callback with true", function() {
-				return this.callback.calledWith(null, true).should.equal(true);
-			});
-		});
-	});
-
-	describe("deleteLock", function() { return beforeEach(function() { 
-			beforeEach(function() {
-				this.rclient.del = sinon.stub().callsArg(1);
-				return this.LockManager.deleteLock(this.key, this.callback);
-			});
-
-			it("should delete the lock in redis", function() {
-				return this.rclient.del
-					.calledWith(key)
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		}); }
-	);
-
-	describe("getLock", function() {
-		describe("when the lock is not taken", function() {
-			beforeEach(function(done) {
-				this.LockManager.tryLock = sinon.stub().callsArgWith(1, null, true);
-				return this.LockManager.getLock(this.key, (...args) => {
-					this.callback(...Array.from(args || []));
-					return done();
-				});
-			});
-
-			it("should try to get the lock", function() {
-				return this.LockManager.tryLock
-					.calledWith(this.key)
-					.should.equal(true);
-			});
-
-			it("should only need to try once", function() {
-				return this.LockManager.tryLock.callCount.should.equal(1);
-			});
-
-			return it("should return the callback", function() {
-				return this.callback.calledWith(null).should.equal(true);
-			});
-		});
-
-		describe("when the lock is initially set", function() {
-			beforeEach(function(done) {
-				const startTime = Date.now();
-				this.LockManager.LOCK_TEST_INTERVAL = 5;
-				this.LockManager.tryLock = function(doc_id, callback) {
-					if (callback == null) { callback = function(error, isFree) {}; }
-					if ((Date.now() - startTime) < 100) {
-						return callback(null, false);
-					} else {
-						return callback(null, true);
-					}
-				};
-				sinon.spy(this.LockManager, "tryLock");
-
-				return this.LockManager.getLock(this.key, (...args) => {
-					this.callback(...Array.from(args || []));
-					return done();
-				});
-			});
-
-			it("should call tryLock multiple times until free", function() {
-				return (this.LockManager.tryLock.callCount > 1).should.equal(true);
-			});
-
-			return it("should return the callback", function() {
-				return this.callback.calledWith(null).should.equal(true);
-			});
-		});
-
-		return describe("when the lock times out", function() {
-			beforeEach(function(done) {
-				const time = Date.now();
-				this.LockManager.MAX_LOCK_WAIT_TIME = 5;
-				this.LockManager.tryLock = sinon.stub().callsArgWith(1, null, false);
-				return this.LockManager.getLock(this.key, (...args) => {
-					this.callback(...Array.from(args || []));
-					return done();
-				});
-			});
-
-			return it("should return the callback with an error", function() {
-				return this.callback.calledWith(sinon.match.instanceOf(Error)).should.equal(true);
-			});
-		});
-	});
-
-	return describe("runWithLock", function() {
-		describe("with successful run", function() {
-			beforeEach(function() {
-				this.runner = function(releaseLock) {
-					if (releaseLock == null) { releaseLock = function(error) {}; }
-					return releaseLock();
-				};
-				sinon.spy(this, "runner");
-				this.LockManager.getLock = sinon.stub().callsArg(1);
-				this.LockManager.releaseLock = sinon.stub().callsArg(2);
-				return this.LockManager.runWithLock(this.key, this.runner, this.callback);
-			});
-
-			it("should get the lock", function() {
-				return this.LockManager.getLock
-					.calledWith(this.key)
-					.should.equal(true);
-			});
-
-			it("should run the passed function", function() {
-				return this.runner.called.should.equal(true);
-			});
-
-			it("should release the lock", function() {
-				return this.LockManager.releaseLock
-					.calledWith(this.key)
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		describe("when the runner function returns an error", function() {
-			beforeEach(function() {
-				this.error = new Error("oops");
-				this.runner = releaseLock => {
-					if (releaseLock == null) { releaseLock = function(error) {}; }
-					return releaseLock(this.error);
-				};
-				sinon.spy(this, "runner");
-				this.LockManager.getLock = sinon.stub().callsArg(1);
-				this.LockManager.releaseLock = sinon.stub().callsArg(2);
-				return this.LockManager.runWithLock(this.key, this.runner, this.callback);
-			});
-
-			it("should release the lock", function() {
-				return this.LockManager.releaseLock
-					.calledWith(this.key)
-					.should.equal(true);
-			});
-
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		return describe("releaseLock", function() {
-			describe("when the lock is current", function() {
-				beforeEach(function() {
-					this.rclient.eval = sinon.stub().yields(null, 1);
-					return this.LockManager.releaseLock(this.key, this.lockValue, this.callback);
-				});
-
-				it('should clear the data from redis', function() {
-					return this.rclient.eval.calledWith(this.LockManager.unlockScript, 1, this.key, this.lockValue).should.equal(true);
-				});
-
-				return it('should call the callback', function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-
-			return describe("when the lock has expired", function() {
-				beforeEach(function() {
-					this.rclient.eval = sinon.stub().yields(null, 0);
-					return this.LockManager.releaseLock(this.key, this.lockValue, this.callback);
-				});
-
-				return it('should return an error if the lock has expired', function() {
-					return this.callback.calledWith(sinon.match.has('message', "tried to release timed out lock")).should.equal(true);
-				});
-			});
-		});
-	});
-});
-
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/LockManager.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('LockManager', function() {
+  beforeEach(function() {
+    this.Settings = {
+      redis: {
+        lock: {}
+      }
+    }
+    this.LockManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'redis-sharelatex': {
+          createClient: () => {
+            return (this.rclient = { auth: sinon.stub() })
+          }
+        },
+        'settings-sharelatex': this.Settings,
+        'logger-sharelatex': { error() {} }
+      }
+    })
+
+    this.key = 'lock-key'
+    return (this.callback = sinon.stub())
+  })
+
+  describe('checkLock', function() {
+    describe('when the lock is taken', function() {
+      beforeEach(function() {
+        this.rclient.exists = sinon.stub().callsArgWith(1, null, '1')
+        return this.LockManager.checkLock(this.key, this.callback)
+      })
+
+      it('should check the lock in redis', function() {
+        return this.rclient.exists.calledWith(this.key).should.equal(true)
+      })
+
+      return it('should return the callback with false', function() {
+        return this.callback.calledWith(null, false).should.equal(true)
+      })
+    })
+
+    return describe('when the lock is free', function() {
+      beforeEach(function() {
+        this.rclient.exists = sinon.stub().callsArgWith(1, null, '0')
+        return this.LockManager.checkLock(this.key, this.callback)
+      })
+
+      return it('should return the callback with true', function() {
+        return this.callback.calledWith(null, true).should.equal(true)
+      })
+    })
+  })
+
+  describe('tryLock', function() {
+    describe('when the lock is taken', function() {
+      beforeEach(function() {
+        this.rclient.set = sinon.stub().callsArgWith(5, null, null)
+        this.LockManager.randomLock = sinon
+          .stub()
+          .returns('locked-random-value')
+        return this.LockManager.tryLock(this.key, this.callback)
+      })
+
+      it('should check the lock in redis', function() {
+        return this.rclient.set
+          .calledWith(
+            this.key,
+            'locked-random-value',
+            'EX',
+            this.LockManager.LOCK_TTL,
+            'NX'
+          )
+          .should.equal(true)
+      })
+
+      return it('should return the callback with false', function() {
+        return this.callback.calledWith(null, false).should.equal(true)
+      })
+    })
+
+    return describe('when the lock is free', function() {
+      beforeEach(function() {
+        this.rclient.set = sinon.stub().callsArgWith(5, null, 'OK')
+        return this.LockManager.tryLock(this.key, this.callback)
+      })
+
+      return it('should return the callback with true', function() {
+        return this.callback.calledWith(null, true).should.equal(true)
+      })
+    })
+  })
+
+  describe('deleteLock', function() {
+    return beforeEach(function() {
+      beforeEach(function() {
+        this.rclient.del = sinon.stub().callsArg(1)
+        return this.LockManager.deleteLock(this.key, this.callback)
+      })
+
+      it('should delete the lock in redis', function() {
+        return this.rclient.del.calledWith(key).should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+  })
+
+  describe('getLock', function() {
+    describe('when the lock is not taken', function() {
+      beforeEach(function(done) {
+        this.LockManager.tryLock = sinon.stub().callsArgWith(1, null, true)
+        return this.LockManager.getLock(this.key, (...args) => {
+          this.callback(...Array.from(args || []))
+          return done()
+        })
+      })
+
+      it('should try to get the lock', function() {
+        return this.LockManager.tryLock.calledWith(this.key).should.equal(true)
+      })
+
+      it('should only need to try once', function() {
+        return this.LockManager.tryLock.callCount.should.equal(1)
+      })
+
+      return it('should return the callback', function() {
+        return this.callback.calledWith(null).should.equal(true)
+      })
+    })
+
+    describe('when the lock is initially set', function() {
+      beforeEach(function(done) {
+        const startTime = Date.now()
+        this.LockManager.LOCK_TEST_INTERVAL = 5
+        this.LockManager.tryLock = function(doc_id, callback) {
+          if (callback == null) {
+            callback = function(error, isFree) {}
+          }
+          if (Date.now() - startTime < 100) {
+            return callback(null, false)
+          } else {
+            return callback(null, true)
+          }
+        }
+        sinon.spy(this.LockManager, 'tryLock')
+
+        return this.LockManager.getLock(this.key, (...args) => {
+          this.callback(...Array.from(args || []))
+          return done()
+        })
+      })
+
+      it('should call tryLock multiple times until free', function() {
+        return (this.LockManager.tryLock.callCount > 1).should.equal(true)
+      })
+
+      return it('should return the callback', function() {
+        return this.callback.calledWith(null).should.equal(true)
+      })
+    })
+
+    return describe('when the lock times out', function() {
+      beforeEach(function(done) {
+        const time = Date.now()
+        this.LockManager.MAX_LOCK_WAIT_TIME = 5
+        this.LockManager.tryLock = sinon.stub().callsArgWith(1, null, false)
+        return this.LockManager.getLock(this.key, (...args) => {
+          this.callback(...Array.from(args || []))
+          return done()
+        })
+      })
+
+      return it('should return the callback with an error', function() {
+        return this.callback
+          .calledWith(sinon.match.instanceOf(Error))
+          .should.equal(true)
+      })
+    })
+  })
+
+  return describe('runWithLock', function() {
+    describe('with successful run', function() {
+      beforeEach(function() {
+        this.runner = function(releaseLock) {
+          if (releaseLock == null) {
+            releaseLock = function(error) {}
+          }
+          return releaseLock()
+        }
+        sinon.spy(this, 'runner')
+        this.LockManager.getLock = sinon.stub().callsArg(1)
+        this.LockManager.releaseLock = sinon.stub().callsArg(2)
+        return this.LockManager.runWithLock(
+          this.key,
+          this.runner,
+          this.callback
+        )
+      })
+
+      it('should get the lock', function() {
+        return this.LockManager.getLock.calledWith(this.key).should.equal(true)
+      })
+
+      it('should run the passed function', function() {
+        return this.runner.called.should.equal(true)
+      })
+
+      it('should release the lock', function() {
+        return this.LockManager.releaseLock
+          .calledWith(this.key)
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    describe('when the runner function returns an error', function() {
+      beforeEach(function() {
+        this.error = new Error('oops')
+        this.runner = releaseLock => {
+          if (releaseLock == null) {
+            releaseLock = function(error) {}
+          }
+          return releaseLock(this.error)
+        }
+        sinon.spy(this, 'runner')
+        this.LockManager.getLock = sinon.stub().callsArg(1)
+        this.LockManager.releaseLock = sinon.stub().callsArg(2)
+        return this.LockManager.runWithLock(
+          this.key,
+          this.runner,
+          this.callback
+        )
+      })
+
+      it('should release the lock', function() {
+        return this.LockManager.releaseLock
+          .calledWith(this.key)
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the error', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    return describe('releaseLock', function() {
+      describe('when the lock is current', function() {
+        beforeEach(function() {
+          this.rclient.eval = sinon.stub().yields(null, 1)
+          return this.LockManager.releaseLock(
+            this.key,
+            this.lockValue,
+            this.callback
+          )
+        })
+
+        it('should clear the data from redis', function() {
+          return this.rclient.eval
+            .calledWith(
+              this.LockManager.unlockScript,
+              1,
+              this.key,
+              this.lockValue
+            )
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+
+      return describe('when the lock has expired', function() {
+        beforeEach(function() {
+          this.rclient.eval = sinon.stub().yields(null, 0)
+          return this.LockManager.releaseLock(
+            this.key,
+            this.lockValue,
+            this.callback
+          )
+        })
+
+        return it('should return an error if the lock has expired', function() {
+          return this.callback
+            .calledWith(
+              sinon.match.has('message', 'tried to release timed out lock')
+            )
+            .should.equal(true)
+        })
+      })
+    })
+  })
+})

+ 234 - 192
services/track-changes/test/unit/js/MongoManager/MongoManagerTests.js

@@ -9,195 +9,237 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/MongoManager.js";
-const packModulePath = "../../../../app/js/PackManager.js";
-const SandboxedModule = require('sandboxed-module');
-const {ObjectId} = require("mongojs");
-const tk = require("timekeeper");
-
-describe("MongoManager", function() {
-	beforeEach(function() {
-		tk.freeze(new Date());
-		this.MongoManager = SandboxedModule.require(modulePath, { requires: {
-			"./mongojs" : { db: (this.db = {}), ObjectId },
-			"./PackManager" : (this.PackManager = {}),
-			'metrics-sharelatex': {timeAsyncMethod(){}},
-			'logger-sharelatex': {log(){}}
-		}
-	});
-		this.callback = sinon.stub();
-		this.doc_id = ObjectId().toString();
-		return this.project_id = ObjectId().toString();
-	});
-
-	afterEach(function() { return tk.reset(); });
-
-	describe("getLastCompressedUpdate", function() {
-		beforeEach(function() {
-			this.update = "mock-update";
-			this.db.docHistory = {};
-			this.db.docHistory.find = sinon.stub().returns(this.db.docHistory);
-			this.db.docHistory.findOne = sinon.stub().returns(this.db.docHistory);
-			this.db.docHistory.sort = sinon.stub().returns(this.db.docHistory);
-			this.db.docHistory.limit = sinon.stub().returns(this.db.docHistory);
-			this.db.docHistory.toArray = sinon.stub().callsArgWith(0, null, [this.update]);
-
-			return this.MongoManager.getLastCompressedUpdate(this.doc_id, this.callback);
-		});
-
-		it("should find the updates for the doc", function() {
-			return this.db.docHistory.find
-				.calledWith({doc_id: ObjectId(this.doc_id)})
-				.should.equal(true);
-		});
-
-		it("should limit to one result", function() {
-			return this.db.docHistory.limit
-				.calledWith(1)
-				.should.equal(true);
-		});
-
-		it("should sort in descending version order", function() {
-			return this.db.docHistory.sort
-				.calledWith({v: -1})
-				.should.equal(true);
-		});
-
-		return it("should call the call back with the update", function() {
-			return this.callback.calledWith(null, this.update).should.equal(true);
-		});
-	});
-
-
-	describe("peekLastCompressedUpdate", function() {
-		describe("when there is no last update", function() {
-			beforeEach(function() {
-				this.PackManager.getLastPackFromIndex =  sinon.stub().callsArgWith(1, null, null);
-				this.MongoManager.getLastCompressedUpdate = sinon.stub().callsArgWith(1, null, null);
-				return this.MongoManager.peekLastCompressedUpdate(this.doc_id, this.callback);
-			});
-
-			it("should get the last update", function() {
-				return this.MongoManager.getLastCompressedUpdate
-					.calledWith(this.doc_id)
-					.should.equal(true);
-			});
-
-			return it("should call the callback with no update", function() {
-				return this.callback.calledWith(null, null).should.equal(true);
-			});
-		});
-
-		describe("when there is an update", function() {
-			beforeEach(function() {
-				this.update = { _id: Object() };
-				this.MongoManager.getLastCompressedUpdate = sinon.stub().callsArgWith(1, null, this.update);
-				return this.MongoManager.peekLastCompressedUpdate(this.doc_id, this.callback);
-			});
-
-			it("should get the last update", function() {
-				return this.MongoManager.getLastCompressedUpdate
-					.calledWith(this.doc_id)
-					.should.equal(true);
-			});
-
-			return it("should call the callback with the update", function() {
-				return this.callback.calledWith(null, this.update).should.equal(true);
-			});
-		});
-
-		return describe("when there is a last update in S3", function() {
-			beforeEach(function() {
-				this.update = { _id: Object(), v: 12345, v_end: 12345, inS3:true};
-				this.PackManager.getLastPackFromIndex =  sinon.stub().callsArgWith(1, null, this.update);
-				this.MongoManager.getLastCompressedUpdate = sinon.stub().callsArgWith(1, null);
-				return this.MongoManager.peekLastCompressedUpdate(this.doc_id, this.callback);
-			});
-
-			it("should get the last update", function() {
-				return this.MongoManager.getLastCompressedUpdate
-					.calledWith(this.doc_id)
-					.should.equal(true);
-			});
-
-			return it("should call the callback with a null update and the correct version", function() {
-				return this.callback.calledWith(null, null, this.update.v_end).should.equal(true);
-			});
-		});
-	});
-
-
-	describe("backportProjectId", function() {
-		beforeEach(function() {
-			this.db.docHistory =
-				{update: sinon.stub().callsArg(3)};
-			return this.MongoManager.backportProjectId(this.project_id, this.doc_id, this.callback);
-		});
-
-		it("should insert the project_id into all entries for the doc_id which don't have it set", function() {
-			return this.db.docHistory.update
-				.calledWith({
-					doc_id: ObjectId(this.doc_id),
-					project_id: { $exists: false }
-				}, {
-					$set: { project_id: ObjectId(this.project_id) }
-				}, {
-					multi: true
-				})
-				.should.equal(true);
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-
-	describe("getProjectMetaData", function() {
-		beforeEach(function() {
-			this.metadata = { "mock": "metadata" };
-			this.db.projectHistoryMetaData =
-				{find: sinon.stub().callsArgWith(1, null, [this.metadata])};
-			return this.MongoManager.getProjectMetaData(this.project_id, this.callback);
-		});
-
-		it("should look up the meta data in the db", function() {
-			return this.db.projectHistoryMetaData.find
-				.calledWith({ project_id: ObjectId(this.project_id) })
-				.should.equal(true);
-		});
-
-		return it("should return the metadata", function() {
-			return this.callback.calledWith(null, this.metadata).should.equal(true);
-		});
-	});
-
-	return describe("setProjectMetaData", function() {
-		beforeEach(function() {
-			this.metadata = { "mock": "metadata" };
-			this.db.projectHistoryMetaData =
-				{update: sinon.stub().callsArgWith(3, null, [this.metadata])};
-			return this.MongoManager.setProjectMetaData(this.project_id, this.metadata, this.callback);
-		});
-
-		it("should upsert the metadata into the DB", function() {
-			return this.db.projectHistoryMetaData.update
-				.calledWith({
-					project_id: ObjectId(this.project_id)
-				}, {
-					$set: this.metadata
-				}, {
-					upsert: true
-				})
-				.should.equal(true);
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-});
-
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/MongoManager.js'
+const packModulePath = '../../../../app/js/PackManager.js'
+const SandboxedModule = require('sandboxed-module')
+const { ObjectId } = require('mongojs')
+const tk = require('timekeeper')
+
+describe('MongoManager', function() {
+  beforeEach(function() {
+    tk.freeze(new Date())
+    this.MongoManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './mongojs': { db: (this.db = {}), ObjectId },
+        './PackManager': (this.PackManager = {}),
+        'metrics-sharelatex': { timeAsyncMethod() {} },
+        'logger-sharelatex': { log() {} }
+      }
+    })
+    this.callback = sinon.stub()
+    this.doc_id = ObjectId().toString()
+    return (this.project_id = ObjectId().toString())
+  })
+
+  afterEach(function() {
+    return tk.reset()
+  })
+
+  describe('getLastCompressedUpdate', function() {
+    beforeEach(function() {
+      this.update = 'mock-update'
+      this.db.docHistory = {}
+      this.db.docHistory.find = sinon.stub().returns(this.db.docHistory)
+      this.db.docHistory.findOne = sinon.stub().returns(this.db.docHistory)
+      this.db.docHistory.sort = sinon.stub().returns(this.db.docHistory)
+      this.db.docHistory.limit = sinon.stub().returns(this.db.docHistory)
+      this.db.docHistory.toArray = sinon
+        .stub()
+        .callsArgWith(0, null, [this.update])
+
+      return this.MongoManager.getLastCompressedUpdate(
+        this.doc_id,
+        this.callback
+      )
+    })
+
+    it('should find the updates for the doc', function() {
+      return this.db.docHistory.find
+        .calledWith({ doc_id: ObjectId(this.doc_id) })
+        .should.equal(true)
+    })
+
+    it('should limit to one result', function() {
+      return this.db.docHistory.limit.calledWith(1).should.equal(true)
+    })
+
+    it('should sort in descending version order', function() {
+      return this.db.docHistory.sort.calledWith({ v: -1 }).should.equal(true)
+    })
+
+    return it('should call the call back with the update', function() {
+      return this.callback.calledWith(null, this.update).should.equal(true)
+    })
+  })
+
+  describe('peekLastCompressedUpdate', function() {
+    describe('when there is no last update', function() {
+      beforeEach(function() {
+        this.PackManager.getLastPackFromIndex = sinon
+          .stub()
+          .callsArgWith(1, null, null)
+        this.MongoManager.getLastCompressedUpdate = sinon
+          .stub()
+          .callsArgWith(1, null, null)
+        return this.MongoManager.peekLastCompressedUpdate(
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the last update', function() {
+        return this.MongoManager.getLastCompressedUpdate
+          .calledWith(this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('should call the callback with no update', function() {
+        return this.callback.calledWith(null, null).should.equal(true)
+      })
+    })
+
+    describe('when there is an update', function() {
+      beforeEach(function() {
+        this.update = { _id: Object() }
+        this.MongoManager.getLastCompressedUpdate = sinon
+          .stub()
+          .callsArgWith(1, null, this.update)
+        return this.MongoManager.peekLastCompressedUpdate(
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the last update', function() {
+        return this.MongoManager.getLastCompressedUpdate
+          .calledWith(this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the update', function() {
+        return this.callback.calledWith(null, this.update).should.equal(true)
+      })
+    })
+
+    return describe('when there is a last update in S3', function() {
+      beforeEach(function() {
+        this.update = { _id: Object(), v: 12345, v_end: 12345, inS3: true }
+        this.PackManager.getLastPackFromIndex = sinon
+          .stub()
+          .callsArgWith(1, null, this.update)
+        this.MongoManager.getLastCompressedUpdate = sinon
+          .stub()
+          .callsArgWith(1, null)
+        return this.MongoManager.peekLastCompressedUpdate(
+          this.doc_id,
+          this.callback
+        )
+      })
+
+      it('should get the last update', function() {
+        return this.MongoManager.getLastCompressedUpdate
+          .calledWith(this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('should call the callback with a null update and the correct version', function() {
+        return this.callback
+          .calledWith(null, null, this.update.v_end)
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('backportProjectId', function() {
+    beforeEach(function() {
+      this.db.docHistory = { update: sinon.stub().callsArg(3) }
+      return this.MongoManager.backportProjectId(
+        this.project_id,
+        this.doc_id,
+        this.callback
+      )
+    })
+
+    it("should insert the project_id into all entries for the doc_id which don't have it set", function() {
+      return this.db.docHistory.update
+        .calledWith(
+          {
+            doc_id: ObjectId(this.doc_id),
+            project_id: { $exists: false }
+          },
+          {
+            $set: { project_id: ObjectId(this.project_id) }
+          },
+          {
+            multi: true
+          }
+        )
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+
+  describe('getProjectMetaData', function() {
+    beforeEach(function() {
+      this.metadata = { mock: 'metadata' }
+      this.db.projectHistoryMetaData = {
+        find: sinon.stub().callsArgWith(1, null, [this.metadata])
+      }
+      return this.MongoManager.getProjectMetaData(
+        this.project_id,
+        this.callback
+      )
+    })
+
+    it('should look up the meta data in the db', function() {
+      return this.db.projectHistoryMetaData.find
+        .calledWith({ project_id: ObjectId(this.project_id) })
+        .should.equal(true)
+    })
+
+    return it('should return the metadata', function() {
+      return this.callback.calledWith(null, this.metadata).should.equal(true)
+    })
+  })
+
+  return describe('setProjectMetaData', function() {
+    beforeEach(function() {
+      this.metadata = { mock: 'metadata' }
+      this.db.projectHistoryMetaData = {
+        update: sinon.stub().callsArgWith(3, null, [this.metadata])
+      }
+      return this.MongoManager.setProjectMetaData(
+        this.project_id,
+        this.metadata,
+        this.callback
+      )
+    })
+
+    it('should upsert the metadata into the DB', function() {
+      return this.db.projectHistoryMetaData.update
+        .calledWith(
+          {
+            project_id: ObjectId(this.project_id)
+          },
+          {
+            $set: this.metadata
+          },
+          {
+            upsert: true
+          }
+        )
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+})

+ 694 - 454
services/track-changes/test/unit/js/PackManager/PackManagerTests.js

@@ -10,461 +10,701 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const { assert } = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/PackManager.js";
-const SandboxedModule = require('sandboxed-module');
-const {ObjectId} = require("mongojs");
-const bson = require("bson");
-const BSON = new bson.BSONPure();
-const _ = require("underscore");
-
-const tk = require("timekeeper");
-
-describe("PackManager", function() {
-	beforeEach(function() {
-		tk.freeze(new Date());
-		this.PackManager = SandboxedModule.require(modulePath, { requires: {
-			"./mongojs" : { db: (this.db = {}), ObjectId, BSON },
-			"./LockManager" : {},
-			"./MongoAWS": {},
-			"logger-sharelatex": { log: sinon.stub(), error: sinon.stub() },
-			'metrics-sharelatex': {inc(){}},
-			"./ProjectIterator": require("../../../../app/js/ProjectIterator.js"), // Cache for speed
-			"settings-sharelatex": {
-				redis: {lock: {key_schema: {}}}
-			}
-		}
-	});
-		this.callback = sinon.stub();
-		this.doc_id = ObjectId().toString();
-		this.project_id = ObjectId().toString();
-		return this.PackManager.MAX_COUNT = 512;
-	});
-
-	afterEach(function() { return tk.reset(); });
-
-	describe("insertCompressedUpdates", function() {
-		beforeEach(function() {
-			this.lastUpdate = {
-				_id: "12345",
-				pack: [
-					{ op: "op-1", meta: "meta-1", v: 1},
-					{ op: "op-2", meta: "meta-2", v: 2}
-				],
-				n : 2,
-				sz : 100
-			};
-			this.newUpdates = [
-				{ op: "op-3", meta: "meta-3", v: 3},
-				{ op: "op-4", meta: "meta-4", v: 4}
-			];
-			return this.db.docHistory = {
-				save: sinon.stub().callsArg(1),
-				insert: sinon.stub().callsArg(1),
-				findAndModify: sinon.stub().callsArg(1)
-			};
-		});
-
-		describe("with no last update", function() {
-			beforeEach(function() {
-				this.PackManager.insertUpdatesIntoNewPack = sinon.stub().callsArg(4);
-				return this.PackManager.insertCompressedUpdates(this.project_id, this.doc_id, null, this.newUpdates, true, this.callback);
-			});
-
-			describe("for a small update", function() {
-				it("should insert the update into a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates, true).should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-
-			return describe("for many small updates", function() {
-				beforeEach(function() {
-					this.newUpdates = (__range__(0, 2048, true).map((i) => ({ op: `op-${i}`, meta: `meta-${i}`, v: i})));
-					return this.PackManager.insertCompressedUpdates(this.project_id, this.doc_id, null, this.newUpdates, false, this.callback);
-				});
-
-				it("should append the initial updates to the existing pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(0, 512), false).should.equal(true);
-				});
-
-				it("should insert the first set remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(512, 1024), false).should.equal(true);
-				});
-
-				it("should insert the second set of remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(1024, 1536), false).should.equal(true);
-				});
-
-				it("should insert the third set of remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(1536, 2048), false).should.equal(true);
-				});
-
-				it("should insert the final set of remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(2048, 2049), false).should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-		});
-
-
-
-		describe("with an existing pack as the last update", function() {
-			beforeEach(function() {
-				this.PackManager.appendUpdatesToExistingPack = sinon.stub().callsArg(5);
-				this.PackManager.insertUpdatesIntoNewPack = sinon.stub().callsArg(4);
-				return this.PackManager.insertCompressedUpdates(this.project_id, this.doc_id, this.lastUpdate, this.newUpdates, false, this.callback);
-			});
-
-			describe("for a small update", function() {
-				it("should append the update to the existing pack", function() {
-					return this.PackManager.appendUpdatesToExistingPack.calledWith(this.project_id, this.doc_id, this.lastUpdate, this.newUpdates, false).should.equal(true);
-				});
-				it("should not insert any new packs", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.called.should.equal(false);
-				});
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-
-			describe("for many small updates", function() {
-				beforeEach(function() {
-					this.newUpdates = (__range__(0, 2048, true).map((i) => ({ op: `op-${i}`, meta: `meta-${i}`, v: i})));
-					return this.PackManager.insertCompressedUpdates(this.project_id, this.doc_id, this.lastUpdate, this.newUpdates, false, this.callback);
-				});
-
-				it("should append the initial updates to the existing pack", function() {
-					return this.PackManager.appendUpdatesToExistingPack.calledWith(this.project_id, this.doc_id, this.lastUpdate, this.newUpdates.slice(0, 510), false).should.equal(true);
-				});
-
-				it("should insert the first set remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(510, 1022), false).should.equal(true);
-				});
-
-				it("should insert the second set of remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(1022, 1534), false).should.equal(true);
-				});
-
-				it("should insert the third set of remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(1534, 2046), false).should.equal(true);
-				});
-
-				it("should insert the final set of remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(2046, 2049), false).should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-
-			return describe("for many big updates", function() {
-				beforeEach(function() {
-					const longString = (__range__(0, (0.75*this.PackManager.MAX_SIZE), true).map((j) => "a")).join("");
-					this.newUpdates = ([0, 1, 2, 3, 4].map((i) => ({ op: `op-${i}-${longString}`, meta: `meta-${i}`, v: i})));
-					return this.PackManager.insertCompressedUpdates(this.project_id, this.doc_id, this.lastUpdate, this.newUpdates, false, this.callback);
-				});
-
-				it("should append the initial updates to the existing pack", function() {
-					return this.PackManager.appendUpdatesToExistingPack.calledWith(this.project_id, this.doc_id, this.lastUpdate, this.newUpdates.slice(0, 1), false).should.equal(true);
-				});
-
-				it("should insert the first set remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(1, 2), false).should.equal(true);
-				});
-
-				it("should insert the second set of remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(2, 3), false).should.equal(true);
-				});
-
-				it("should insert the third set of remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(3, 4), false).should.equal(true);
-				});
-
-				it("should insert the final set of remaining updates as a new pack", function() {
-					return this.PackManager.insertUpdatesIntoNewPack.calledWith(this.project_id, this.doc_id, this.newUpdates.slice(4, 5), false).should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-		});
-
-		describe("flushCompressedUpdates", function() { return describe("when there is no previous update",  function() {
-				beforeEach(function() {
-					return this.PackManager.flushCompressedUpdates(this.project_id, this.doc_id, null, this.newUpdates, true, this.callback);
-				});
-
-				return describe("for a small update  that will expire", function() {
-					it("should insert the update into mongo", function() {
-						return this.db.docHistory.save.calledWithMatch({
-							pack: this.newUpdates,
-							project_id: ObjectId(this.project_id),
-							doc_id: ObjectId(this.doc_id),
-							n: this.newUpdates.length,
-							v: this.newUpdates[0].v,
-							v_end: this.newUpdates[this.newUpdates.length-1].v
-						}).should.equal(true);
-					});
-
-					it("should set an expiry time in the future", function() {
-						return this.db.docHistory.save.calledWithMatch({
-							expiresAt: new Date(Date.now() + (7 * 24 * 3600 * 1000))
-						}).should.equal(true);
-					});
-
-					return it("should call the callback", function() {
-						return this.callback.called.should.equal(true);
-					});
-				});
-			}); }
-		);
-
-		describe("when there is a recent previous update in mongo that expires", function() {
-			beforeEach(function() {
-				this.lastUpdate = {
-					_id: "12345",
-					pack: [
-						{ op: "op-1", meta: "meta-1", v: 1},
-						{ op: "op-2", meta: "meta-2", v: 2}
-					],
-					n : 2,
-					sz : 100,
-					meta: {start_ts: Date.now() - (6 * 3600 * 1000)},
-					expiresAt: new Date(Date.now())
-				};
-
-				return this.PackManager.flushCompressedUpdates(this.project_id, this.doc_id, this.lastUpdate, this.newUpdates, true, this.callback);
-			});
-
-			return describe("for a small update that will expire", function() {
-				it("should append the update in mongo", function() {
-					return this.db.docHistory.findAndModify.calledWithMatch({
-						query: {_id: this.lastUpdate._id},
-						update: { $push: {"pack" : {$each: this.newUpdates}}, $set: {v_end: this.newUpdates[this.newUpdates.length-1].v}}
-					}).should.equal(true);
-				});
-
-				it("should set an expiry time in the future", function() {
-					return this.db.docHistory.findAndModify.calledWithMatch({
-						update: {$set: {expiresAt: new Date(Date.now() + (7 * 24 * 3600 * 1000))}}
-					}).should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-		});
-
-
-		describe("when there is a recent previous update in mongo that expires", function() {
-			beforeEach(function() {
-				this.PackManager.updateIndex = sinon.stub().callsArg(2);
-
-				this.lastUpdate = {
-					_id: "12345",
-					pack: [
-						{ op: "op-1", meta: "meta-1", v: 1},
-						{ op: "op-2", meta: "meta-2", v: 2}
-					],
-					n : 2,
-					sz : 100,
-					meta: {start_ts: Date.now() - (6 * 3600 * 1000)},
-					expiresAt: new Date(Date.now())
-				};
-
-				return this.PackManager.flushCompressedUpdates(this.project_id, this.doc_id, this.lastUpdate, this.newUpdates, false, this.callback);
-			});
-
-			return describe("for a small update that will not expire", function() {
-				it("should insert the update into mongo", function() {
-					return this.db.docHistory.save.calledWithMatch({
-						pack: this.newUpdates,
-						project_id: ObjectId(this.project_id),
-						doc_id: ObjectId(this.doc_id),
-						n: this.newUpdates.length,
-						v: this.newUpdates[0].v,
-						v_end: this.newUpdates[this.newUpdates.length-1].v
-					}).should.equal(true);
-				});
-
-				it("should not set any expiry time", function() {
-					return this.db.docHistory.save.neverCalledWithMatch(sinon.match.has("expiresAt")).should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-		});
-
-		return describe("when there is an old previous update in mongo", function() {
-			beforeEach(function() {
-				this.lastUpdate = {
-					_id: "12345",
-					pack: [
-						{ op: "op-1", meta: "meta-1", v: 1},
-						{ op: "op-2", meta: "meta-2", v: 2}
-					],
-					n : 2,
-					sz : 100,
-					meta: {start_ts: Date.now() - (30 * 24 * 3600 * 1000)},
-					expiresAt: new Date(Date.now() - (30 * 24 * 3600 * 1000))
-				};
-
-				return this.PackManager.flushCompressedUpdates(this.project_id, this.doc_id, this.lastUpdate, this.newUpdates, true, this.callback);
-			});
-
-			return describe("for a small update that will expire", function() {
-				it("should insert the update into mongo", function() {
-					return this.db.docHistory.save.calledWithMatch({
-						pack: this.newUpdates,
-						project_id: ObjectId(this.project_id),
-						doc_id: ObjectId(this.doc_id),
-						n: this.newUpdates.length,
-						v: this.newUpdates[0].v,
-						v_end: this.newUpdates[this.newUpdates.length-1].v
-					}).should.equal(true);
-				});
-
-				it("should set an expiry time in the future", function() {
-					return this.db.docHistory.save.calledWithMatch({
-						expiresAt: new Date(Date.now() + (7 * 24 * 3600 * 1000))
-					}).should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-		});
-	});
-
-
-	describe("getOpsByVersionRange", function() {});
-
-	describe("loadPacksByVersionRange", function() {});
-
-	describe("fetchPacksIfNeeded", function() {});
-
-	describe("makeProjectIterator", function() {});
-
-	describe("getPackById", function() {});
-
-	describe("increaseTTL", function() {});
-
-	describe("getIndex", function() {});
-
-	describe("getPackFromIndex", function() {});
-// getLastPackFromIndex:
-// getIndexWithKeys
-// initialiseIndex
-// updateIndex
-// findCompletedPacks
-// findUnindexedPacks
-// insertPacksIntoIndexWithLock
-// _insertPacksIntoIndex
-// archivePack
-// checkArchivedPack
-// processOldPack
-// 	updateIndexIfNeeded
-// 	findUnarchivedPacks
-
-	return describe("checkArchiveNotInProgress", function() {
-
-		describe("when an archive is in progress", function() {
-			beforeEach(function() {
-				this.db.docHistoryIndex =
-					{findOne: sinon.stub().callsArgWith(2, null, {inS3:false})};
-				return this.PackManager.checkArchiveNotInProgress(this.project_id, this.doc_id, this.pack_id, this.callback);
-			});
-			it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-			return it("should return an error", function() {
-				return this.callback.calledWith(sinon.match.has('message')).should.equal(true);
-			});
-		});
-
-		describe("when an archive is completed", function() {
-			beforeEach(function() {
-				this.db.docHistoryIndex =
-					{findOne: sinon.stub().callsArgWith(2, null, {inS3:true})};
-				return this.PackManager.checkArchiveNotInProgress(this.project_id, this.doc_id, this.pack_id, this.callback);
-			});
-			it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-			return it("should return an error", function() {
-				return this.callback.calledWith(sinon.match.has('message')).should.equal(true);
-			});
-		});
-
-		return describe("when the archive has not started or completed", function() {
-			beforeEach(function() {
-				this.db.docHistoryIndex =
-					{findOne: sinon.stub().callsArgWith(2, null, {})};
-				return this.PackManager.checkArchiveNotInProgress(this.project_id, this.doc_id, this.pack_id, this.callback);
-			});
-			it("should call the callback with no error", function() {
-				return this.callback.called.should.equal(true);
-			});
-			return it("should return with no error", function() {
-				return (typeof this.callback.lastCall.args[0]).should.equal('undefined');
-			});
-		});
-	});
-});
-
-	// describe "setTTLOnArchivedPack", ->
-	// 	beforeEach ->
-	// 		@pack_id = "somepackid"
-	// 		@onedayinms = 86400000
-	// 		@db.docHistory =
-	// 			findAndModify : sinon.stub().callsArgWith(1)
-
-	// 	it "should set expires to 1 day", (done)->
-	// 		#@PackManager._getOneDayInFutureWithRandomDelay = sinon.stub().returns(@onedayinms)
-	// 		@PackManager.setTTLOnArchivedPack @project_id, @doc_id, @pack_id, =>
-	// 			args = @db.docHistory.findAndModify.args[0][0]
-	// 			args.query._id.should.equal @pack_id
-	// 			args.update['$set'].expiresAt.should.equal @onedayinms
-	// 			done()
-
-
-	// describe "_getOneDayInFutureWithRandomDelay", ->
-	// 	beforeEach ->
-	// 		@onedayinms = 86400000
-	// 		@thirtyMins = 1000 * 60 * 30
-
-	// 	it "should give 1 day + 30 mins random time", (done)->
-	// 		loops = 10000
-	// 		while --loops > 0
-	// 			randomDelay = @PackManager._getOneDayInFutureWithRandomDelay() - new Date(Date.now() + @onedayinms)
-	// 			randomDelay.should.be.above(0)
-	// 			randomDelay.should.be.below(@thirtyMins + 1)
-	// 		done()
-
+const sinon = require('sinon')
+const chai = require('chai')
+const { assert } = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/PackManager.js'
+const SandboxedModule = require('sandboxed-module')
+const { ObjectId } = require('mongojs')
+const bson = require('bson')
+const BSON = new bson.BSONPure()
+const _ = require('underscore')
+
+const tk = require('timekeeper')
+
+describe('PackManager', function() {
+  beforeEach(function() {
+    tk.freeze(new Date())
+    this.PackManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './mongojs': { db: (this.db = {}), ObjectId, BSON },
+        './LockManager': {},
+        './MongoAWS': {},
+        'logger-sharelatex': { log: sinon.stub(), error: sinon.stub() },
+        'metrics-sharelatex': { inc() {} },
+        './ProjectIterator': require('../../../../app/js/ProjectIterator.js'), // Cache for speed
+        'settings-sharelatex': {
+          redis: { lock: { key_schema: {} } }
+        }
+      }
+    })
+    this.callback = sinon.stub()
+    this.doc_id = ObjectId().toString()
+    this.project_id = ObjectId().toString()
+    return (this.PackManager.MAX_COUNT = 512)
+  })
+
+  afterEach(function() {
+    return tk.reset()
+  })
+
+  describe('insertCompressedUpdates', function() {
+    beforeEach(function() {
+      this.lastUpdate = {
+        _id: '12345',
+        pack: [
+          { op: 'op-1', meta: 'meta-1', v: 1 },
+          { op: 'op-2', meta: 'meta-2', v: 2 }
+        ],
+        n: 2,
+        sz: 100
+      }
+      this.newUpdates = [
+        { op: 'op-3', meta: 'meta-3', v: 3 },
+        { op: 'op-4', meta: 'meta-4', v: 4 }
+      ]
+      return (this.db.docHistory = {
+        save: sinon.stub().callsArg(1),
+        insert: sinon.stub().callsArg(1),
+        findAndModify: sinon.stub().callsArg(1)
+      })
+    })
+
+    describe('with no last update', function() {
+      beforeEach(function() {
+        this.PackManager.insertUpdatesIntoNewPack = sinon.stub().callsArg(4)
+        return this.PackManager.insertCompressedUpdates(
+          this.project_id,
+          this.doc_id,
+          null,
+          this.newUpdates,
+          true,
+          this.callback
+        )
+      })
+
+      describe('for a small update', function() {
+        it('should insert the update into a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(this.project_id, this.doc_id, this.newUpdates, true)
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+
+      return describe('for many small updates', function() {
+        beforeEach(function() {
+          this.newUpdates = __range__(0, 2048, true).map(i => ({
+            op: `op-${i}`,
+            meta: `meta-${i}`,
+            v: i
+          }))
+          return this.PackManager.insertCompressedUpdates(
+            this.project_id,
+            this.doc_id,
+            null,
+            this.newUpdates,
+            false,
+            this.callback
+          )
+        })
+
+        it('should append the initial updates to the existing pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(0, 512),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the first set remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(512, 1024),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the second set of remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(1024, 1536),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the third set of remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(1536, 2048),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the final set of remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(2048, 2049),
+              false
+            )
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+    })
+
+    describe('with an existing pack as the last update', function() {
+      beforeEach(function() {
+        this.PackManager.appendUpdatesToExistingPack = sinon.stub().callsArg(5)
+        this.PackManager.insertUpdatesIntoNewPack = sinon.stub().callsArg(4)
+        return this.PackManager.insertCompressedUpdates(
+          this.project_id,
+          this.doc_id,
+          this.lastUpdate,
+          this.newUpdates,
+          false,
+          this.callback
+        )
+      })
+
+      describe('for a small update', function() {
+        it('should append the update to the existing pack', function() {
+          return this.PackManager.appendUpdatesToExistingPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.lastUpdate,
+              this.newUpdates,
+              false
+            )
+            .should.equal(true)
+        })
+        it('should not insert any new packs', function() {
+          return this.PackManager.insertUpdatesIntoNewPack.called.should.equal(
+            false
+          )
+        })
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+
+      describe('for many small updates', function() {
+        beforeEach(function() {
+          this.newUpdates = __range__(0, 2048, true).map(i => ({
+            op: `op-${i}`,
+            meta: `meta-${i}`,
+            v: i
+          }))
+          return this.PackManager.insertCompressedUpdates(
+            this.project_id,
+            this.doc_id,
+            this.lastUpdate,
+            this.newUpdates,
+            false,
+            this.callback
+          )
+        })
+
+        it('should append the initial updates to the existing pack', function() {
+          return this.PackManager.appendUpdatesToExistingPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.lastUpdate,
+              this.newUpdates.slice(0, 510),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the first set remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(510, 1022),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the second set of remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(1022, 1534),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the third set of remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(1534, 2046),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the final set of remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(2046, 2049),
+              false
+            )
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+
+      return describe('for many big updates', function() {
+        beforeEach(function() {
+          const longString = __range__(
+            0,
+            0.75 * this.PackManager.MAX_SIZE,
+            true
+          )
+            .map(j => 'a')
+            .join('')
+          this.newUpdates = [0, 1, 2, 3, 4].map(i => ({
+            op: `op-${i}-${longString}`,
+            meta: `meta-${i}`,
+            v: i
+          }))
+          return this.PackManager.insertCompressedUpdates(
+            this.project_id,
+            this.doc_id,
+            this.lastUpdate,
+            this.newUpdates,
+            false,
+            this.callback
+          )
+        })
+
+        it('should append the initial updates to the existing pack', function() {
+          return this.PackManager.appendUpdatesToExistingPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.lastUpdate,
+              this.newUpdates.slice(0, 1),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the first set remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(1, 2),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the second set of remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(2, 3),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the third set of remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(3, 4),
+              false
+            )
+            .should.equal(true)
+        })
+
+        it('should insert the final set of remaining updates as a new pack', function() {
+          return this.PackManager.insertUpdatesIntoNewPack
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.newUpdates.slice(4, 5),
+              false
+            )
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+    })
+
+    describe('flushCompressedUpdates', function() {
+      return describe('when there is no previous update', function() {
+        beforeEach(function() {
+          return this.PackManager.flushCompressedUpdates(
+            this.project_id,
+            this.doc_id,
+            null,
+            this.newUpdates,
+            true,
+            this.callback
+          )
+        })
+
+        return describe('for a small update  that will expire', function() {
+          it('should insert the update into mongo', function() {
+            return this.db.docHistory.save
+              .calledWithMatch({
+                pack: this.newUpdates,
+                project_id: ObjectId(this.project_id),
+                doc_id: ObjectId(this.doc_id),
+                n: this.newUpdates.length,
+                v: this.newUpdates[0].v,
+                v_end: this.newUpdates[this.newUpdates.length - 1].v
+              })
+              .should.equal(true)
+          })
+
+          it('should set an expiry time in the future', function() {
+            return this.db.docHistory.save
+              .calledWithMatch({
+                expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000)
+              })
+              .should.equal(true)
+          })
+
+          return it('should call the callback', function() {
+            return this.callback.called.should.equal(true)
+          })
+        })
+      })
+    })
+
+    describe('when there is a recent previous update in mongo that expires', function() {
+      beforeEach(function() {
+        this.lastUpdate = {
+          _id: '12345',
+          pack: [
+            { op: 'op-1', meta: 'meta-1', v: 1 },
+            { op: 'op-2', meta: 'meta-2', v: 2 }
+          ],
+          n: 2,
+          sz: 100,
+          meta: { start_ts: Date.now() - 6 * 3600 * 1000 },
+          expiresAt: new Date(Date.now())
+        }
+
+        return this.PackManager.flushCompressedUpdates(
+          this.project_id,
+          this.doc_id,
+          this.lastUpdate,
+          this.newUpdates,
+          true,
+          this.callback
+        )
+      })
+
+      return describe('for a small update that will expire', function() {
+        it('should append the update in mongo', function() {
+          return this.db.docHistory.findAndModify
+            .calledWithMatch({
+              query: { _id: this.lastUpdate._id },
+              update: {
+                $push: { pack: { $each: this.newUpdates } },
+                $set: { v_end: this.newUpdates[this.newUpdates.length - 1].v }
+              }
+            })
+            .should.equal(true)
+        })
+
+        it('should set an expiry time in the future', function() {
+          return this.db.docHistory.findAndModify
+            .calledWithMatch({
+              update: {
+                $set: { expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000) }
+              }
+            })
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+    })
+
+    describe('when there is a recent previous update in mongo that expires', function() {
+      beforeEach(function() {
+        this.PackManager.updateIndex = sinon.stub().callsArg(2)
+
+        this.lastUpdate = {
+          _id: '12345',
+          pack: [
+            { op: 'op-1', meta: 'meta-1', v: 1 },
+            { op: 'op-2', meta: 'meta-2', v: 2 }
+          ],
+          n: 2,
+          sz: 100,
+          meta: { start_ts: Date.now() - 6 * 3600 * 1000 },
+          expiresAt: new Date(Date.now())
+        }
+
+        return this.PackManager.flushCompressedUpdates(
+          this.project_id,
+          this.doc_id,
+          this.lastUpdate,
+          this.newUpdates,
+          false,
+          this.callback
+        )
+      })
+
+      return describe('for a small update that will not expire', function() {
+        it('should insert the update into mongo', function() {
+          return this.db.docHistory.save
+            .calledWithMatch({
+              pack: this.newUpdates,
+              project_id: ObjectId(this.project_id),
+              doc_id: ObjectId(this.doc_id),
+              n: this.newUpdates.length,
+              v: this.newUpdates[0].v,
+              v_end: this.newUpdates[this.newUpdates.length - 1].v
+            })
+            .should.equal(true)
+        })
+
+        it('should not set any expiry time', function() {
+          return this.db.docHistory.save
+            .neverCalledWithMatch(sinon.match.has('expiresAt'))
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+    })
+
+    return describe('when there is an old previous update in mongo', function() {
+      beforeEach(function() {
+        this.lastUpdate = {
+          _id: '12345',
+          pack: [
+            { op: 'op-1', meta: 'meta-1', v: 1 },
+            { op: 'op-2', meta: 'meta-2', v: 2 }
+          ],
+          n: 2,
+          sz: 100,
+          meta: { start_ts: Date.now() - 30 * 24 * 3600 * 1000 },
+          expiresAt: new Date(Date.now() - 30 * 24 * 3600 * 1000)
+        }
+
+        return this.PackManager.flushCompressedUpdates(
+          this.project_id,
+          this.doc_id,
+          this.lastUpdate,
+          this.newUpdates,
+          true,
+          this.callback
+        )
+      })
+
+      return describe('for a small update that will expire', function() {
+        it('should insert the update into mongo', function() {
+          return this.db.docHistory.save
+            .calledWithMatch({
+              pack: this.newUpdates,
+              project_id: ObjectId(this.project_id),
+              doc_id: ObjectId(this.doc_id),
+              n: this.newUpdates.length,
+              v: this.newUpdates[0].v,
+              v_end: this.newUpdates[this.newUpdates.length - 1].v
+            })
+            .should.equal(true)
+        })
+
+        it('should set an expiry time in the future', function() {
+          return this.db.docHistory.save
+            .calledWithMatch({
+              expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000)
+            })
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+    })
+  })
+
+  describe('getOpsByVersionRange', function() {})
+
+  describe('loadPacksByVersionRange', function() {})
+
+  describe('fetchPacksIfNeeded', function() {})
+
+  describe('makeProjectIterator', function() {})
+
+  describe('getPackById', function() {})
+
+  describe('increaseTTL', function() {})
+
+  describe('getIndex', function() {})
+
+  describe('getPackFromIndex', function() {})
+  // getLastPackFromIndex:
+  // getIndexWithKeys
+  // initialiseIndex
+  // updateIndex
+  // findCompletedPacks
+  // findUnindexedPacks
+  // insertPacksIntoIndexWithLock
+  // _insertPacksIntoIndex
+  // archivePack
+  // checkArchivedPack
+  // processOldPack
+  // 	updateIndexIfNeeded
+  // 	findUnarchivedPacks
+
+  return describe('checkArchiveNotInProgress', function() {
+    describe('when an archive is in progress', function() {
+      beforeEach(function() {
+        this.db.docHistoryIndex = {
+          findOne: sinon.stub().callsArgWith(2, null, { inS3: false })
+        }
+        return this.PackManager.checkArchiveNotInProgress(
+          this.project_id,
+          this.doc_id,
+          this.pack_id,
+          this.callback
+        )
+      })
+      it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+      return it('should return an error', function() {
+        return this.callback
+          .calledWith(sinon.match.has('message'))
+          .should.equal(true)
+      })
+    })
+
+    describe('when an archive is completed', function() {
+      beforeEach(function() {
+        this.db.docHistoryIndex = {
+          findOne: sinon.stub().callsArgWith(2, null, { inS3: true })
+        }
+        return this.PackManager.checkArchiveNotInProgress(
+          this.project_id,
+          this.doc_id,
+          this.pack_id,
+          this.callback
+        )
+      })
+      it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+      return it('should return an error', function() {
+        return this.callback
+          .calledWith(sinon.match.has('message'))
+          .should.equal(true)
+      })
+    })
+
+    return describe('when the archive has not started or completed', function() {
+      beforeEach(function() {
+        this.db.docHistoryIndex = {
+          findOne: sinon.stub().callsArgWith(2, null, {})
+        }
+        return this.PackManager.checkArchiveNotInProgress(
+          this.project_id,
+          this.doc_id,
+          this.pack_id,
+          this.callback
+        )
+      })
+      it('should call the callback with no error', function() {
+        return this.callback.called.should.equal(true)
+      })
+      return it('should return with no error', function() {
+        return (typeof this.callback.lastCall.args[0]).should.equal('undefined')
+      })
+    })
+  })
+})
+
+// describe "setTTLOnArchivedPack", ->
+// 	beforeEach ->
+// 		@pack_id = "somepackid"
+// 		@onedayinms = 86400000
+// 		@db.docHistory =
+// 			findAndModify : sinon.stub().callsArgWith(1)
+
+// 	it "should set expires to 1 day", (done)->
+// 		#@PackManager._getOneDayInFutureWithRandomDelay = sinon.stub().returns(@onedayinms)
+// 		@PackManager.setTTLOnArchivedPack @project_id, @doc_id, @pack_id, =>
+// 			args = @db.docHistory.findAndModify.args[0][0]
+// 			args.query._id.should.equal @pack_id
+// 			args.update['$set'].expiresAt.should.equal @onedayinms
+// 			done()
+
+// describe "_getOneDayInFutureWithRandomDelay", ->
+// 	beforeEach ->
+// 		@onedayinms = 86400000
+// 		@thirtyMins = 1000 * 60 * 30
+
+// 	it "should give 1 day + 30 mins random time", (done)->
+// 		loops = 10000
+// 		while --loops > 0
+// 			randomDelay = @PackManager._getOneDayInFutureWithRandomDelay() - new Date(Date.now() + @onedayinms)
+// 			randomDelay.should.be.above(0)
+// 			randomDelay.should.be.below(@thirtyMins + 1)
+// 		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
+}

+ 153 - 115
services/track-changes/test/unit/js/RedisManager/RedisManagerTests.js

@@ -11,118 +11,156 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/RedisManager.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe("RedisManager", function() {
-	beforeEach(function() {
-		this.RedisManager = SandboxedModule.require(modulePath, { requires: {
-			"redis-sharelatex" : { 
-				createClient: () => { return this.rclient = {
-					auth: sinon.stub(),
-					multi: () => this.rclient
-				}; }
-			},
-			"settings-sharelatex": {
-				redis: {
-					history: {
-						key_schema: {
-							uncompressedHistoryOps({doc_id}) { return `UncompressedHistoryOps:${doc_id}`; },
-							docsWithHistoryOps({project_id}) { return `DocsWithHistoryOps:${project_id}`; }
-						}
-					}
-				}
-			}
-		}
-	}
-		);
-		this.doc_id = "doc-id-123";
-		this.project_id = "project-id-123";
-		this.batchSize = 100;
-		return this.callback = sinon.stub();
-	});
-
-	describe("getOldestDocUpdates", function() {
-		beforeEach(function() {
-			this.rawUpdates = [ {v: 42, op: "mock-op-42"}, { v: 45, op: "mock-op-45" }];
-			this.jsonUpdates = (Array.from(this.rawUpdates).map((update) => JSON.stringify(update)));
-			this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonUpdates);
-			return this.RedisManager.getOldestDocUpdates(this.doc_id, this.batchSize, this.callback);
-		});
-
-		it("should read the updates from redis", function() {
-			return this.rclient.lrange
-				.calledWith(`UncompressedHistoryOps:${this.doc_id}`, 0, this.batchSize - 1)
-				.should.equal(true);
-		});
-
-		it("should call the callback with the unparsed ops", function() {
-			return this.callback.calledWith(null, this.jsonUpdates).should.equal(true);
-		});
-
-
-		describe("expandDocUpdates", function() {
-			beforeEach(function() {
-				return this.RedisManager.expandDocUpdates(this.jsonUpdates, this.callback);
-			});
-
-			return it("should call the callback with the parsed ops", function() {
-				return this.callback.calledWith(null, this.rawUpdates).should.equal(true);
-			});
-		});
-
-
-		return describe("deleteAppliedDocUpdates", function() {
-			beforeEach(function() {
-				this.rclient.lrem = sinon.stub();
-				this.rclient.srem = sinon.stub();
-				this.rclient.exec = sinon.stub().callsArgWith(0);
-				return this.RedisManager.deleteAppliedDocUpdates(this.project_id, this.doc_id, this.jsonUpdates, this.callback);
-			});
-
-			it("should delete the first update from redis", function() {
-				return this.rclient.lrem
-					.calledWith(`UncompressedHistoryOps:${this.doc_id}`, 1, this.jsonUpdates[0])
-					.should.equal(true);
-			});
-
-			it("should delete the second update from redis", function() {
-				return this.rclient.lrem
-					.calledWith(`UncompressedHistoryOps:${this.doc_id}`, 1, this.jsonUpdates[1])
-					.should.equal(true);
-			});
-
-			it("should delete the doc from the set of docs with history ops", function() {
-				return this.rclient.srem
-					.calledWith(`DocsWithHistoryOps:${this.project_id}`, this.doc_id)
-					.should.equal(true);
-			});
-
-			return it("should call the callback ", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-	});
-
-	return describe("getDocIdsWithHistoryOps", function() {
-		beforeEach(function() {
-			this.doc_ids = ["mock-id-1", "mock-id-2"];
-			this.rclient.smembers = sinon.stub().callsArgWith(1, null, this.doc_ids);
-			return this.RedisManager.getDocIdsWithHistoryOps(this.project_id, this.callback);
-		});
-
-		it("should read the doc_ids from redis", function() {
-			return this.rclient.smembers
-				.calledWith(`DocsWithHistoryOps:${this.project_id}`)
-				.should.equal(true);
-		});
-
-		return it("should call the callback with the doc_ids", function() {
-			return this.callback.calledWith(null, this.doc_ids).should.equal(true);
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/RedisManager.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('RedisManager', function() {
+  beforeEach(function() {
+    this.RedisManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'redis-sharelatex': {
+          createClient: () => {
+            return (this.rclient = {
+              auth: sinon.stub(),
+              multi: () => this.rclient
+            })
+          }
+        },
+        'settings-sharelatex': {
+          redis: {
+            history: {
+              key_schema: {
+                uncompressedHistoryOps({ doc_id }) {
+                  return `UncompressedHistoryOps:${doc_id}`
+                },
+                docsWithHistoryOps({ project_id }) {
+                  return `DocsWithHistoryOps:${project_id}`
+                }
+              }
+            }
+          }
+        }
+      }
+    })
+    this.doc_id = 'doc-id-123'
+    this.project_id = 'project-id-123'
+    this.batchSize = 100
+    return (this.callback = sinon.stub())
+  })
+
+  describe('getOldestDocUpdates', function() {
+    beforeEach(function() {
+      this.rawUpdates = [
+        { v: 42, op: 'mock-op-42' },
+        { v: 45, op: 'mock-op-45' }
+      ]
+      this.jsonUpdates = Array.from(this.rawUpdates).map(update =>
+        JSON.stringify(update)
+      )
+      this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonUpdates)
+      return this.RedisManager.getOldestDocUpdates(
+        this.doc_id,
+        this.batchSize,
+        this.callback
+      )
+    })
+
+    it('should read the updates from redis', function() {
+      return this.rclient.lrange
+        .calledWith(
+          `UncompressedHistoryOps:${this.doc_id}`,
+          0,
+          this.batchSize - 1
+        )
+        .should.equal(true)
+    })
+
+    it('should call the callback with the unparsed ops', function() {
+      return this.callback.calledWith(null, this.jsonUpdates).should.equal(true)
+    })
+
+    describe('expandDocUpdates', function() {
+      beforeEach(function() {
+        return this.RedisManager.expandDocUpdates(
+          this.jsonUpdates,
+          this.callback
+        )
+      })
+
+      return it('should call the callback with the parsed ops', function() {
+        return this.callback
+          .calledWith(null, this.rawUpdates)
+          .should.equal(true)
+      })
+    })
+
+    return describe('deleteAppliedDocUpdates', function() {
+      beforeEach(function() {
+        this.rclient.lrem = sinon.stub()
+        this.rclient.srem = sinon.stub()
+        this.rclient.exec = sinon.stub().callsArgWith(0)
+        return this.RedisManager.deleteAppliedDocUpdates(
+          this.project_id,
+          this.doc_id,
+          this.jsonUpdates,
+          this.callback
+        )
+      })
+
+      it('should delete the first update from redis', function() {
+        return this.rclient.lrem
+          .calledWith(
+            `UncompressedHistoryOps:${this.doc_id}`,
+            1,
+            this.jsonUpdates[0]
+          )
+          .should.equal(true)
+      })
+
+      it('should delete the second update from redis', function() {
+        return this.rclient.lrem
+          .calledWith(
+            `UncompressedHistoryOps:${this.doc_id}`,
+            1,
+            this.jsonUpdates[1]
+          )
+          .should.equal(true)
+      })
+
+      it('should delete the doc from the set of docs with history ops', function() {
+        return this.rclient.srem
+          .calledWith(`DocsWithHistoryOps:${this.project_id}`, this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('should call the callback ', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+  })
+
+  return describe('getDocIdsWithHistoryOps', function() {
+    beforeEach(function() {
+      this.doc_ids = ['mock-id-1', 'mock-id-2']
+      this.rclient.smembers = sinon.stub().callsArgWith(1, null, this.doc_ids)
+      return this.RedisManager.getDocIdsWithHistoryOps(
+        this.project_id,
+        this.callback
+      )
+    })
+
+    it('should read the doc_ids from redis', function() {
+      return this.rclient.smembers
+        .calledWith(`DocsWithHistoryOps:${this.project_id}`)
+        .should.equal(true)
+    })
+
+    return it('should call the callback with the doc_ids', function() {
+      return this.callback.calledWith(null, this.doc_ids).should.equal(true)
+    })
+  })
+})

+ 54 - 43
services/track-changes/test/unit/js/RestoreManager/RestoreManagerTests.js

@@ -9,51 +9,62 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/RestoreManager.js";
-const SandboxedModule = require('sandboxed-module');
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/RestoreManager.js'
+const SandboxedModule = require('sandboxed-module')
 
-describe("RestoreManager", function() {
-	beforeEach(function() {
-		this.RestoreManager = SandboxedModule.require(modulePath, { requires: {
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			"./DocumentUpdaterManager": (this.DocumentUpdaterManager = {}),
-			"./DiffManager": (this.DiffManager = {})
-		}
-	});
-		this.callback = sinon.stub();
-		this.project_id = "mock-project-id";
-		this.doc_id = "mock-doc-id";
-		this.user_id = "mock-user-id";
-		return this.version = 42;
-	});
+describe('RestoreManager', function() {
+  beforeEach(function() {
+    this.RestoreManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        './DocumentUpdaterManager': (this.DocumentUpdaterManager = {}),
+        './DiffManager': (this.DiffManager = {})
+      }
+    })
+    this.callback = sinon.stub()
+    this.project_id = 'mock-project-id'
+    this.doc_id = 'mock-doc-id'
+    this.user_id = 'mock-user-id'
+    return (this.version = 42)
+  })
 
-	return describe("restoreToBeforeVersion", function() {
-		beforeEach(function() {
-			this.content = "mock content";
-			this.DocumentUpdaterManager.setDocument = sinon.stub().callsArg(4);
-			this.DiffManager.getDocumentBeforeVersion = sinon.stub().callsArgWith(3, null, this.content);
-			return this.RestoreManager.restoreToBeforeVersion(this.project_id, this.doc_id, this.version, this.user_id, this.callback);
-		});
+  return describe('restoreToBeforeVersion', function() {
+    beforeEach(function() {
+      this.content = 'mock content'
+      this.DocumentUpdaterManager.setDocument = sinon.stub().callsArg(4)
+      this.DiffManager.getDocumentBeforeVersion = sinon
+        .stub()
+        .callsArgWith(3, null, this.content)
+      return this.RestoreManager.restoreToBeforeVersion(
+        this.project_id,
+        this.doc_id,
+        this.version,
+        this.user_id,
+        this.callback
+      )
+    })
 
-		it("should get the content before the requested version", function() {
-			return this.DiffManager.getDocumentBeforeVersion
-				.calledWith(this.project_id, this.doc_id, this.version)
-				.should.equal(true);
-		});
+    it('should get the content before the requested version', function() {
+      return this.DiffManager.getDocumentBeforeVersion
+        .calledWith(this.project_id, this.doc_id, this.version)
+        .should.equal(true)
+    })
 
-		it("should set the document in the document updater", function() {
-			return this.DocumentUpdaterManager.setDocument
-				.calledWith(this.project_id, this.doc_id, this.content, this.user_id)
-				.should.equal(true);
-		});
+    it('should set the document in the document updater', function() {
+      return this.DocumentUpdaterManager.setDocument
+        .calledWith(this.project_id, this.doc_id, this.content, this.user_id)
+        .should.equal(true)
+    })
 
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-});
-			
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+})

+ 814 - 556
services/track-changes/test/unit/js/UpdateCompressor/UpdateCompressorTests.js

@@ -9,584 +9,842 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/UpdateCompressor.js";
-const SandboxedModule = require('sandboxed-module');
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/UpdateCompressor.js'
+const SandboxedModule = require('sandboxed-module')
 
-const bigstring = (__range__(0, 2*1024*1024, true).map((i) => "a")).join("");
-const mediumstring = (__range__(0, 1024*1024, true).map((j) => "a")).join("");
+const bigstring = __range__(0, 2 * 1024 * 1024, true)
+  .map(i => 'a')
+  .join('')
+const mediumstring = __range__(0, 1024 * 1024, true)
+  .map(j => 'a')
+  .join('')
 
-describe("UpdateCompressor", function() {
-	beforeEach(function() {
-		this.UpdateCompressor = SandboxedModule.require(modulePath, { requires: {
-			"../lib/diff_match_patch": require("../../../../app/lib/diff_match_patch")
-		}
-	}
-		);
-		this.user_id = "user-id-1";
-		this.other_user_id = "user-id-2";
-		this.ts1 = Date.now();
-		return this.ts2 = Date.now() + 1000;
-	});
+describe('UpdateCompressor', function() {
+  beforeEach(function() {
+    this.UpdateCompressor = SandboxedModule.require(modulePath, {
+      requires: {
+        '../lib/diff_match_patch': require('../../../../app/lib/diff_match_patch')
+      }
+    })
+    this.user_id = 'user-id-1'
+    this.other_user_id = 'user-id-2'
+    this.ts1 = Date.now()
+    return (this.ts2 = Date.now() + 1000)
+  })
 
-	describe("convertToSingleOpUpdates", function() {
-		it("should split grouped updates into individual updates", function() {
-			return expect(this.UpdateCompressor.convertToSingleOpUpdates([{
-				op:   [ (this.op1 = { p: 0, i: "Foo" }), (this.op2 = { p: 6, i: "bar"}) ],
-				meta: { ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}, {
-				op:   [ (this.op3 = { p: 10, i: "baz" }) ],
-				meta: { ts: this.ts2, user_id: this.other_user_id },
-				v: 43
-			}]))
-			.to.deep.equal([{
-				op: this.op1,
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}, {
-				op: this.op2,
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}, {
-				op: this.op3,
-				meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.other_user_id },
-				v: 43
-			}]);
-	});
+  describe('convertToSingleOpUpdates', function() {
+    it('should split grouped updates into individual updates', function() {
+      return expect(
+        this.UpdateCompressor.convertToSingleOpUpdates([
+          {
+            op: [
+              (this.op1 = { p: 0, i: 'Foo' }),
+              (this.op2 = { p: 6, i: 'bar' })
+            ],
+            meta: { ts: this.ts1, user_id: this.user_id },
+            v: 42
+          },
+          {
+            op: [(this.op3 = { p: 10, i: 'baz' })],
+            meta: { ts: this.ts2, user_id: this.other_user_id },
+            v: 43
+          }
+        ])
+      ).to.deep.equal([
+        {
+          op: this.op1,
+          meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
+          v: 42
+        },
+        {
+          op: this.op2,
+          meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
+          v: 42
+        },
+        {
+          op: this.op3,
+          meta: {
+            start_ts: this.ts2,
+            end_ts: this.ts2,
+            user_id: this.other_user_id
+          },
+          v: 43
+        }
+      ])
+    })
 
-		it("should return no-op updates when the op list is empty", function() {
-			return expect(this.UpdateCompressor.convertToSingleOpUpdates([{
-				op:   [],
-				meta: { ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}]))
-			.to.deep.equal([{
-				op: this.UpdateCompressor.NOOP,
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}]);
-	});
+    it('should return no-op updates when the op list is empty', function() {
+      return expect(
+        this.UpdateCompressor.convertToSingleOpUpdates([
+          {
+            op: [],
+            meta: { ts: this.ts1, user_id: this.user_id },
+            v: 42
+          }
+        ])
+      ).to.deep.equal([
+        {
+          op: this.UpdateCompressor.NOOP,
+          meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
+          v: 42
+        }
+      ])
+    })
 
-		return it("should ignore comment ops", function() {
-			return expect(this.UpdateCompressor.convertToSingleOpUpdates([{
-				op:   [ (this.op1 = { p: 0, i: "Foo" }), (this.op2 = { p: 9, c: "baz"}), (this.op3 = { p: 6, i: "bar"}) ],
-				meta: { ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}]))
-			.to.deep.equal([{
-				op: this.op1,
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}, {
-				op: this.op3,
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}]);
-	});
-});
+    return it('should ignore comment ops', function() {
+      return expect(
+        this.UpdateCompressor.convertToSingleOpUpdates([
+          {
+            op: [
+              (this.op1 = { p: 0, i: 'Foo' }),
+              (this.op2 = { p: 9, c: 'baz' }),
+              (this.op3 = { p: 6, i: 'bar' })
+            ],
+            meta: { ts: this.ts1, user_id: this.user_id },
+            v: 42
+          }
+        ])
+      ).to.deep.equal([
+        {
+          op: this.op1,
+          meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
+          v: 42
+        },
+        {
+          op: this.op3,
+          meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
+          v: 42
+        }
+      ])
+    })
+  })
 
-	describe("concatUpdatesWithSameVersion", function() {
-		it("should concat updates with the same version", function() {
-			return expect(this.UpdateCompressor.concatUpdatesWithSameVersion([{
-				op:   (this.op1 = { p: 0, i: "Foo" }),
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}, {
-				op:   (this.op2 = { p: 6, i: "bar" }),
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}, {
-				op:   (this.op3 = { p: 10, i: "baz" }),
-				meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.other_user_id },
-				v: 43
-			}]))
-			.to.deep.equal([{
-				op:   [ this.op1, this.op2 ],
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}, {
-				op:   [ this.op3 ],
-				meta: {  start_ts: this.ts2, end_ts: this.ts2, user_id: this.other_user_id },
-				v: 43
-			}]);
-	});
+  describe('concatUpdatesWithSameVersion', function() {
+    it('should concat updates with the same version', function() {
+      return expect(
+        this.UpdateCompressor.concatUpdatesWithSameVersion([
+          {
+            op: (this.op1 = { p: 0, i: 'Foo' }),
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: (this.op2 = { p: 6, i: 'bar' }),
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: (this.op3 = { p: 10, i: 'baz' }),
+            meta: {
+              start_ts: this.ts2,
+              end_ts: this.ts2,
+              user_id: this.other_user_id
+            },
+            v: 43
+          }
+        ])
+      ).to.deep.equal([
+        {
+          op: [this.op1, this.op2],
+          meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
+          v: 42
+        },
+        {
+          op: [this.op3],
+          meta: {
+            start_ts: this.ts2,
+            end_ts: this.ts2,
+            user_id: this.other_user_id
+          },
+          v: 43
+        }
+      ])
+    })
 
-		return it("should turn a noop into an empty op", function() {
-			return expect(this.UpdateCompressor.concatUpdatesWithSameVersion([{
-				op:   this.UpdateCompressor.NOOP,
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}]))
-			.to.deep.equal([{
-				op:   [],
-				meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
-				v: 42
-			}]);
-	});
-});
+    return it('should turn a noop into an empty op', function() {
+      return expect(
+        this.UpdateCompressor.concatUpdatesWithSameVersion([
+          {
+            op: this.UpdateCompressor.NOOP,
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          }
+        ])
+      ).to.deep.equal([
+        {
+          op: [],
+          meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id },
+          v: 42
+        }
+      ])
+    })
+  })
 
-	describe("compress", function() {
-		describe("insert - insert", function() {
-			it("should append one insert to the other", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 6, i: "bar" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "foobar" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
+  describe('compress', function() {
+    describe('insert - insert', function() {
+      it('should append one insert to the other', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 6, i: 'bar' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: 'foobar' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-			it("should insert one insert inside the other", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 5, i: "bar" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "fobaro" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
+      it('should insert one insert inside the other', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 5, i: 'bar' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: 'fobaro' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-			it("should not append separated inserts", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 9, i: "bar" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "foo" },
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 9, i: "bar" },
-					meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
+      it('should not append separated inserts', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 9, i: 'bar' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: 'foo' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: { p: 9, i: 'bar' },
+            meta: {
+              start_ts: this.ts2,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-			it("should not append inserts that are too big (second op)", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 6, i: bigstring },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "foo" },
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 6, i: bigstring },
-					meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
+      it('should not append inserts that are too big (second op)', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 6, i: bigstring },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: 'foo' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: { p: 6, i: bigstring },
+            meta: {
+              start_ts: this.ts2,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-			it("should not append inserts that are too big (first op)", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: bigstring },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 3 + bigstring.length, i: "bar" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: bigstring },
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 3 + bigstring.length, i: "bar" },
-					meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
+      it('should not append inserts that are too big (first op)', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: bigstring },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 3 + bigstring.length, i: 'bar' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: bigstring },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: { p: 3 + bigstring.length, i: 'bar' },
+            meta: {
+              start_ts: this.ts2,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-			return it("should not append inserts that are too big (first and second op)", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: mediumstring },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 3 + mediumstring.length, i: mediumstring },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: mediumstring },
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 3 + mediumstring.length, i: mediumstring },
-					meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
-	});
+      return it('should not append inserts that are too big (first and second op)', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: mediumstring },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 3 + mediumstring.length, i: mediumstring },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: mediumstring },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: { p: 3 + mediumstring.length, i: mediumstring },
+            meta: {
+              start_ts: this.ts2,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
+    })
 
+    describe('delete - delete', function() {
+      it('should append one delete to the other', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, d: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 3, d: 'bar' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, d: 'foobar' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-		describe("delete - delete", function() {
-			it("should append one delete to the other", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, d: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 3, d: "bar" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, d: "foobar" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
-				
-			it("should insert one delete inside the other", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, d: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 1, d: "bar" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 1, d: "bafoor" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
-				
-			return it("should not append separated deletes", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, d: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 9, d: "bar" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, d: "foo" },
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 9, d: "bar" },
-					meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
-	});
+      it('should insert one delete inside the other', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, d: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 1, d: 'bar' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 1, d: 'bafoor' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-		describe("insert - delete", function() {
-			it("should undo a previous insert", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 5, d: "o" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "fo" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
-				
-			it("should remove part of an insert from the middle", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: "fobaro" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 5, d: "bar" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "foo" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
+      return it('should not append separated deletes', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, d: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 9, d: 'bar' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, d: 'foo' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: { p: 9, d: 'bar' },
+            meta: {
+              start_ts: this.ts2,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
+    })
 
-			it("should cancel out two opposite updates", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 3, d: "foo" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}
-				]);
-		});
-				
-			it("should not combine separated updates", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: "foo" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 9, d: "bar" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "foo" },
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 9, d: "bar" },
-					meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
+    describe('insert - delete', function() {
+      it('should undo a previous insert', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 5, d: 'o' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: 'fo' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-			return it("should not combine updates with overlap beyond the end", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, i: "foobar" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 6, d: "bardle" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "foobar" },
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 6, d: "bardle" },
-					meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
-	});
-		
-		describe("delete - insert", function() {
-			it("should do a diff of the content", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, d: "one two three four five six seven eight" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 3, i: "one 2 three four five six seven eight" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 7, d: "two" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}, {
-					op: { p: 7, i: "2" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
-			
-			return it("should return a no-op if the delete and insert are the same", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: { p: 3, d: "one two three four five six seven eight" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 3, i: "one two three four five six seven eight" },
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: { p: 3, i: "" },
-					meta: { start_ts: this.ts1, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		});
-	});
+      it('should remove part of an insert from the middle', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: 'fobaro' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 5, d: 'bar' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: 'foo' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-		describe("noop - insert", function() { return it("should leave them untouched", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: this.UpdateCompressor.NOOP,
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 6, i: "bar" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: this.UpdateCompressor.NOOP,
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 6, i: "bar" },
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		}); }
-	);
+      it('should cancel out two opposite updates', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 3, d: 'foo' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: '' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-		return describe("noop - delete", function() { return it("should leave them untouched", function() {
-				return expect(this.UpdateCompressor.compressUpdates([{
-					op: this.UpdateCompressor.NOOP,
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 6, d: "bar" },
-					meta: { ts: this.ts1, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: this.UpdateCompressor.NOOP,
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, {
-					op: { p: 6, d: "bar" },
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		}); }
-	);
-});
+      it('should not combine separated updates', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: 'foo' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 9, d: 'bar' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: 'foo' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: { p: 9, d: 'bar' },
+            meta: {
+              start_ts: this.ts2,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
 
-	return describe("compressRawUpdates", function() { return describe("merging in-place with an array op", function() { return it("should not change the existing last updates", function() {
-				return expect(this.UpdateCompressor.compressRawUpdates({
-					op: [ {"p":1000,"d":"hello"}, {"p":1000,"i":"HELLO()"} ],
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				}, [{
-					op: [{ p: 1006, i: "WORLD" }],
-					meta: { ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]))
-				.to.deep.equal([{
-					op: [{"p":1000,"d":"hello"}, {"p":1000,"i":"HELLO()"} ],
-					meta: { start_ts: this.ts1, end_ts: this.ts1, user_id: this.user_id
-				},
-					v: 42
-				},{
-					op: [{"p":1006,"i":"WORLD"}],
-					meta: { start_ts: this.ts2, end_ts: this.ts2, user_id: this.user_id
-				},
-					v: 43
-				}]);
-		}); }
-	); }
-);
-});
+      return it('should not combine updates with overlap beyond the end', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, i: 'foobar' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 6, d: 'bardle' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: 'foobar' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: { p: 6, d: 'bardle' },
+            meta: {
+              start_ts: this.ts2,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
+    })
+
+    describe('delete - insert', function() {
+      it('should do a diff of the content', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, d: 'one two three four five six seven eight' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 3, i: 'one 2 three four five six seven eight' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 7, d: 'two' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          },
+          {
+            op: { p: 7, i: '2' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
+
+      return it('should return a no-op if the delete and insert are the same', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: { p: 3, d: 'one two three four five six seven eight' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 3, i: 'one two three four five six seven eight' },
+              meta: { ts: this.ts2, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: { p: 3, i: '' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
+    })
+
+    describe('noop - insert', function() {
+      return it('should leave them untouched', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: this.UpdateCompressor.NOOP,
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 6, i: 'bar' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: this.UpdateCompressor.NOOP,
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: { p: 6, i: 'bar' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
+    })
+
+    return describe('noop - delete', function() {
+      return it('should leave them untouched', function() {
+        return expect(
+          this.UpdateCompressor.compressUpdates([
+            {
+              op: this.UpdateCompressor.NOOP,
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 42
+            },
+            {
+              op: { p: 6, d: 'bar' },
+              meta: { ts: this.ts1, user_id: this.user_id },
+              v: 43
+            }
+          ])
+        ).to.deep.equal([
+          {
+            op: this.UpdateCompressor.NOOP,
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: { p: 6, d: 'bar' },
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
+    })
+  })
+
+  return describe('compressRawUpdates', function() {
+    return describe('merging in-place with an array op', function() {
+      return it('should not change the existing last updates', function() {
+        return expect(
+          this.UpdateCompressor.compressRawUpdates(
+            {
+              op: [
+                { p: 1000, d: 'hello' },
+                { p: 1000, i: 'HELLO()' }
+              ],
+              meta: {
+                start_ts: this.ts1,
+                end_ts: this.ts1,
+                user_id: this.user_id
+              },
+              v: 42
+            },
+            [
+              {
+                op: [{ p: 1006, i: 'WORLD' }],
+                meta: { ts: this.ts2, user_id: this.user_id },
+                v: 43
+              }
+            ]
+          )
+        ).to.deep.equal([
+          {
+            op: [
+              { p: 1000, d: 'hello' },
+              { p: 1000, i: 'HELLO()' }
+            ],
+            meta: {
+              start_ts: this.ts1,
+              end_ts: this.ts1,
+              user_id: this.user_id
+            },
+            v: 42
+          },
+          {
+            op: [{ p: 1006, i: 'WORLD' }],
+            meta: {
+              start_ts: this.ts2,
+              end_ts: this.ts2,
+              user_id: this.user_id
+            },
+            v: 43
+          }
+        ])
+      })
+    })
+  })
+})
 
 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
+}

+ 177 - 154
services/track-changes/test/unit/js/UpdateTrimmer/UpdateTrimmerTests.js

@@ -9,157 +9,180 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/UpdateTrimmer.js";
-const SandboxedModule = require('sandboxed-module');
-const tk = require("timekeeper");
-
-describe("UpdateTrimmer", function() {
-	beforeEach(function() {
-		this.now = new Date();
-		tk.freeze(this.now);
-
-		this.UpdateTrimmer = SandboxedModule.require(modulePath, { requires: {
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			"./WebApiManager": (this.WebApiManager = {}),
-			"./MongoManager": (this.MongoManager = {})
-		}
-	});
-
-		this.callback = sinon.stub();
-		return this.project_id = "mock-project-id";
-	});
-
-	afterEach(function() { return tk.reset(); });
-
-	return describe("shouldTrimUpdates", function() {
-		beforeEach(function() {
-			this.metadata = {};
-			this.details =
-				{features: {}};
-			this.MongoManager.getProjectMetaData = sinon.stub().callsArgWith(1, null, this.metadata);
-			this.MongoManager.setProjectMetaData = sinon.stub().callsArgWith(2);
-			this.MongoManager.upgradeHistory = sinon.stub().callsArgWith(1);
-			return this.WebApiManager.getProjectDetails = sinon.stub().callsArgWith(1, null, this.details);
-		});
-
-		describe("with preserveHistory set in the project meta data", function() {
-			beforeEach(function() {
-				this.metadata.preserveHistory = true;
-				return this.UpdateTrimmer.shouldTrimUpdates(this.project_id, this.callback);
-			});
-
-			it("should look up the meta data", function() {
-				return this.MongoManager.getProjectMetaData
-					.calledWith(this.project_id)
-					.should.equal(true);
-			});
-
-			it("should not look up the project details", function() {
-				return this.WebApiManager.getProjectDetails
-					.called
-					.should.equal(false);
-			});
-
-			return it("should return false", function() {
-				return this.callback.calledWith(null, false).should.equal(true);
-			});
-		});
-
-		describe("without preserveHistory set in the project meta data", function() {
-			beforeEach(function() {
-				return this.metadata.preserveHistory = false;
-			});
-
-			describe("when the project has the versioning feature", function() {
-				beforeEach(function() {
-					this.details.features.versioning = true;
-					return this.UpdateTrimmer.shouldTrimUpdates(this.project_id, this.callback);
-				});
-
-				it("should look up the meta data", function() {
-					return this.MongoManager.getProjectMetaData
-						.calledWith(this.project_id)
-						.should.equal(true);
-				});
-
-				it("should look up the project details", function() {
-					return this.WebApiManager.getProjectDetails
-						.calledWith(this.project_id)
-						.should.equal(true);
-				});
-
-				it("should insert preserveHistory into the metadata", function() {
-					return this.MongoManager.setProjectMetaData
-						.calledWith(this.project_id, {preserveHistory: true})
-						.should.equal(true);
-				});
-
-				it("should upgrade any existing history", function() {
-					return this.MongoManager.upgradeHistory
-						.calledWith(this.project_id)
-						.should.equal(true);
-				});
-
-				return it("should return false", function() {
-					return this.callback.calledWith(null, false).should.equal(true);
-				});
-			});
-
-			return describe("when the project does not have the versioning feature", function() {
-				beforeEach(function() {
-					this.details.features.versioning = false;
-					return this.UpdateTrimmer.shouldTrimUpdates(this.project_id, this.callback);
-				});
-
-				return it("should return true", function() {
-					return this.callback.calledWith(null, true).should.equal(true);
-				});
-			});
-		});
-
-		return describe("without any meta data", function() {
-			beforeEach(function() {
-				return this.MongoManager.getProjectMetaData = sinon.stub().callsArgWith(1, null, null);
-			});
-
-			describe("when the project has the versioning feature", function() {
-				beforeEach(function() {
-					this.details.features.versioning = true;
-					return this.UpdateTrimmer.shouldTrimUpdates(this.project_id, this.callback);
-				});
-
-				it("should insert preserveHistory into the metadata", function() {
-					return this.MongoManager.setProjectMetaData
-						.calledWith(this.project_id, {preserveHistory: true})
-						.should.equal(true);
-				});
-
-				it("should upgrade any existing history", function() {
-					return this.MongoManager.upgradeHistory
-						.calledWith(this.project_id)
-						.should.equal(true);
-				});
-
-				return it("should return false", function() {
-					return this.callback.calledWith(null, false).should.equal(true);
-				});
-			});
-
-			return describe("when the project does not have the versioning feature", function() {
-				beforeEach(function() {
-					this.details.features.versioning = false;
-					return this.UpdateTrimmer.shouldTrimUpdates(this.project_id, this.callback);
-				});
-
-				return it("should return true", function() {
-					return this.callback.calledWith(null, true).should.equal(true);
-				});
-			});
-		});
-	});
-});
-
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/UpdateTrimmer.js'
+const SandboxedModule = require('sandboxed-module')
+const tk = require('timekeeper')
+
+describe('UpdateTrimmer', function() {
+  beforeEach(function() {
+    this.now = new Date()
+    tk.freeze(this.now)
+
+    this.UpdateTrimmer = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        './WebApiManager': (this.WebApiManager = {}),
+        './MongoManager': (this.MongoManager = {})
+      }
+    })
+
+    this.callback = sinon.stub()
+    return (this.project_id = 'mock-project-id')
+  })
+
+  afterEach(function() {
+    return tk.reset()
+  })
+
+  return describe('shouldTrimUpdates', function() {
+    beforeEach(function() {
+      this.metadata = {}
+      this.details = { features: {} }
+      this.MongoManager.getProjectMetaData = sinon
+        .stub()
+        .callsArgWith(1, null, this.metadata)
+      this.MongoManager.setProjectMetaData = sinon.stub().callsArgWith(2)
+      this.MongoManager.upgradeHistory = sinon.stub().callsArgWith(1)
+      return (this.WebApiManager.getProjectDetails = sinon
+        .stub()
+        .callsArgWith(1, null, this.details))
+    })
+
+    describe('with preserveHistory set in the project meta data', function() {
+      beforeEach(function() {
+        this.metadata.preserveHistory = true
+        return this.UpdateTrimmer.shouldTrimUpdates(
+          this.project_id,
+          this.callback
+        )
+      })
+
+      it('should look up the meta data', function() {
+        return this.MongoManager.getProjectMetaData
+          .calledWith(this.project_id)
+          .should.equal(true)
+      })
+
+      it('should not look up the project details', function() {
+        return this.WebApiManager.getProjectDetails.called.should.equal(false)
+      })
+
+      return it('should return false', function() {
+        return this.callback.calledWith(null, false).should.equal(true)
+      })
+    })
+
+    describe('without preserveHistory set in the project meta data', function() {
+      beforeEach(function() {
+        return (this.metadata.preserveHistory = false)
+      })
+
+      describe('when the project has the versioning feature', function() {
+        beforeEach(function() {
+          this.details.features.versioning = true
+          return this.UpdateTrimmer.shouldTrimUpdates(
+            this.project_id,
+            this.callback
+          )
+        })
+
+        it('should look up the meta data', function() {
+          return this.MongoManager.getProjectMetaData
+            .calledWith(this.project_id)
+            .should.equal(true)
+        })
+
+        it('should look up the project details', function() {
+          return this.WebApiManager.getProjectDetails
+            .calledWith(this.project_id)
+            .should.equal(true)
+        })
+
+        it('should insert preserveHistory into the metadata', function() {
+          return this.MongoManager.setProjectMetaData
+            .calledWith(this.project_id, { preserveHistory: true })
+            .should.equal(true)
+        })
+
+        it('should upgrade any existing history', function() {
+          return this.MongoManager.upgradeHistory
+            .calledWith(this.project_id)
+            .should.equal(true)
+        })
+
+        return it('should return false', function() {
+          return this.callback.calledWith(null, false).should.equal(true)
+        })
+      })
+
+      return describe('when the project does not have the versioning feature', function() {
+        beforeEach(function() {
+          this.details.features.versioning = false
+          return this.UpdateTrimmer.shouldTrimUpdates(
+            this.project_id,
+            this.callback
+          )
+        })
+
+        return it('should return true', function() {
+          return this.callback.calledWith(null, true).should.equal(true)
+        })
+      })
+    })
+
+    return describe('without any meta data', function() {
+      beforeEach(function() {
+        return (this.MongoManager.getProjectMetaData = sinon
+          .stub()
+          .callsArgWith(1, null, null))
+      })
+
+      describe('when the project has the versioning feature', function() {
+        beforeEach(function() {
+          this.details.features.versioning = true
+          return this.UpdateTrimmer.shouldTrimUpdates(
+            this.project_id,
+            this.callback
+          )
+        })
+
+        it('should insert preserveHistory into the metadata', function() {
+          return this.MongoManager.setProjectMetaData
+            .calledWith(this.project_id, { preserveHistory: true })
+            .should.equal(true)
+        })
+
+        it('should upgrade any existing history', function() {
+          return this.MongoManager.upgradeHistory
+            .calledWith(this.project_id)
+            .should.equal(true)
+        })
+
+        return it('should return false', function() {
+          return this.callback.calledWith(null, false).should.equal(true)
+        })
+      })
+
+      return describe('when the project does not have the versioning feature', function() {
+        beforeEach(function() {
+          this.details.features.versioning = false
+          return this.UpdateTrimmer.shouldTrimUpdates(
+            this.project_id,
+            this.callback
+          )
+        })
+
+        return it('should return true', function() {
+          return this.callback.calledWith(null, true).should.equal(true)
+        })
+      })
+    })
+  })
+})

+ 1320 - 1006
services/track-changes/test/unit/js/UpdatesManager/UpdatesManagerTests.js

@@ -13,1009 +13,1323 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/UpdatesManager.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe("UpdatesManager", function() {
-	beforeEach(function() {
-		this.UpdatesManager = SandboxedModule.require(modulePath, { requires: {
-			"./UpdateCompressor": (this.UpdateCompressor = {}),
-			"./MongoManager" : (this.MongoManager = {}),
-			"./PackManager" : (this.PackManager = {}),
-			"./RedisManager" : (this.RedisManager = {}),
-			"./LockManager"  : (this.LockManager = {}),
-			"./WebApiManager": (this.WebApiManager = {}),
-			"./UpdateTrimmer": (this.UpdateTrimmer = {}),
-			"./DocArchiveManager": (this.DocArchiveManager = {}),
-			"logger-sharelatex": { log: sinon.stub(), error: sinon.stub() },
-			"settings-sharelatex": {
-				redis: { lock: { key_schema: {
-					historyLock({doc_id}) { return `HistoryLock:${doc_id}`; }
-				}
-			}
-			}
-			}
-		}
-	}
-		);
-		this.doc_id = "doc-id-123";
-		this.project_id = "project-id-123";
-		this.callback = sinon.stub();
-		return this.temporary = "temp-mock";
-	});
-
-	describe("compressAndSaveRawUpdates", function() {
-		describe("when there are no raw ops", function() {
-			beforeEach(function() {
-				this.MongoManager.peekLastCompressedUpdate = sinon.stub();
-				return this.UpdatesManager.compressAndSaveRawUpdates(this.project_id, this.doc_id, [], this.temporary, this.callback);
-			});
-
-			it("should not need to access the database", function() {
-				return this.MongoManager.peekLastCompressedUpdate.called.should.equal(false);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		describe("when there is no compressed history to begin with", function() {
-			beforeEach(function() {
-				this.rawUpdates = [{ v: 12, op: "mock-op-12" }, { v: 13, op: "mock-op-13" }];
-				this.compressedUpdates = [ { v: 13, op: "compressed-op-12" } ];
-
-				this.MongoManager.peekLastCompressedUpdate = sinon.stub().callsArgWith(1, null, null);
-				this.PackManager.insertCompressedUpdates = sinon.stub().callsArg(5);
-				this.UpdateCompressor.compressRawUpdates = sinon.stub().returns(this.compressedUpdates);
-				return this.UpdatesManager.compressAndSaveRawUpdates(this.project_id, this.doc_id, this.rawUpdates, this.temporary, this.callback);
-			});
-
-			it("should look at the last compressed op", function() {
-				return this.MongoManager.peekLastCompressedUpdate
-					.calledWith(this.doc_id)
-					.should.equal(true);
-			});
-			
-			it("should save the compressed ops as a pack", function() {
-				return this.PackManager.insertCompressedUpdates
-					.calledWith(this.project_id, this.doc_id, null, this.compressedUpdates, this.temporary)
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		describe("when the raw ops need appending to existing history", function() {
-			beforeEach(function() {
-				this.lastCompressedUpdate = { v: 11, op: "compressed-op-11" };
-				this.compressedUpdates = [ { v: 12, op: "compressed-op-11+12" }, { v: 13, op: "compressed-op-12" } ];
-
-				this.MongoManager.peekLastCompressedUpdate = sinon.stub().callsArgWith(1, null, this.lastCompressedUpdate, this.lastCompressedUpdate.v);
-				this.PackManager.insertCompressedUpdates = sinon.stub().callsArg(5);
-				return this.UpdateCompressor.compressRawUpdates = sinon.stub().returns(this.compressedUpdates);
-			});
-
-			describe("when the raw ops start where the existing history ends", function() {
-				beforeEach(function() {
-					this.rawUpdates = [{ v: 12, op: "mock-op-12" }, { v: 13, op: "mock-op-13" }];
-					return this.UpdatesManager.compressAndSaveRawUpdates(this.project_id, this.doc_id, this.rawUpdates, this.temporary, this.callback);
-				});
-
-				it("should look at the last compressed op", function() {
-					return this.MongoManager.peekLastCompressedUpdate
-						.calledWith(this.doc_id)
-						.should.equal(true);
-				});
-				
-				it("should compress the raw ops", function() {
-					return this.UpdateCompressor.compressRawUpdates
-						.calledWith(null, this.rawUpdates)
-						.should.equal(true);
-				});
-
-				it("should save the new compressed ops into a pack", function() {
-					return this.PackManager.insertCompressedUpdates
-						.calledWith(this.project_id, this.doc_id, this.lastCompressedUpdate, this.compressedUpdates, this.temporary)
-						.should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-
-			describe("when the raw ops start where the existing history ends and the history is in a pack", function() {
-				beforeEach(function() {
-					this.lastCompressedUpdate = {pack: [{ v: 11, op: "compressed-op-11" }], v:11};
-					this.rawUpdates = [{ v: 12, op: "mock-op-12" }, { v: 13, op: "mock-op-13" }];
-					this.MongoManager.peekLastCompressedUpdate = sinon.stub().callsArgWith(1, null, this.lastCompressedUpdate, this.lastCompressedUpdate.v);
-					return this.UpdatesManager.compressAndSaveRawUpdates(this.project_id, this.doc_id, this.rawUpdates, this.temporary, this.callback);
-				});
-
-				it("should look at the last compressed op", function() {
-					return this.MongoManager.peekLastCompressedUpdate
-						.calledWith(this.doc_id)
-						.should.equal(true);
-				});
-
-				it("should compress the raw ops", function() {
-					return this.UpdateCompressor.compressRawUpdates
-						.calledWith(null, this.rawUpdates)
-						.should.equal(true);
-				});
-
-				it("should save the new compressed ops into a pack", function() {
-					return this.PackManager.insertCompressedUpdates
-						.calledWith(this.project_id, this.doc_id, this.lastCompressedUpdate, this.compressedUpdates, this.temporary)
-						.should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-
-			describe("when some raw ops are passed that have already been compressed", function() {
-				beforeEach(function() {
-					this.rawUpdates = [{ v: 10, op: "mock-op-10" }, { v: 11, op: "mock-op-11"}, { v: 12, op: "mock-op-12" }, { v: 13, op: "mock-op-13" }];
-
-					return this.UpdatesManager.compressAndSaveRawUpdates(this.project_id, this.doc_id, this.rawUpdates, this.temporary, this.callback);
-				});
-
-				return it("should only compress the more recent raw ops", function() {
-					return this.UpdateCompressor.compressRawUpdates
-						.calledWith(null, this.rawUpdates.slice(-2))
-						.should.equal(true);
-				});
-			});
-
-			describe("when the raw ops do not follow from the last compressed op version", function() {
-				beforeEach(function() {
-					this.rawUpdates = [{ v: 13, op: "mock-op-13" }];
-					return this.UpdatesManager.compressAndSaveRawUpdates(this.project_id, this.doc_id, this.rawUpdates, this.temporary, this.callback);
-				});
-
-				it("should call the callback with an error", function() {
-					return this.callback
-						.calledWith(sinon.match.has('message', "Tried to apply raw op at version 13 to last compressed update with version 11 from unknown time"))
-						.should.equal(true);
-				});
-
-				return it("should not insert any update into mongo", function() {
-					return this.PackManager.insertCompressedUpdates.called.should.equal(false);
-				});
-			});
-
-			return describe("when the raw ops are out of order", function() {
-				beforeEach(function() {
-					this.rawUpdates = [{ v: 13, op: "mock-op-13" }, { v: 12, op: "mock-op-12" }];
-					return this.UpdatesManager.compressAndSaveRawUpdates(this.project_id, this.doc_id, this.rawUpdates, this.temporary, this.callback);
-				});
-
-				it("should call the callback with an error", function() {
-					return this.callback
-						.calledWith(sinon.match.has('message'))
-						.should.equal(true);
-				});
-
-				return it("should not insert any update into mongo", function() {
-					return this.PackManager.insertCompressedUpdates.called.should.equal(false);
-				});
-			});
-		});
-
-
-		return describe("when the raw ops need appending to existing history which is in S3", function() {
-			beforeEach(function() {
-				this.lastCompressedUpdate = null;
-				this.lastVersion = 11;
-				this.compressedUpdates = [ { v: 13, op: "compressed-op-12" } ];
-
-				this.MongoManager.peekLastCompressedUpdate = sinon.stub().callsArgWith(1, null, null, this.lastVersion);
-				this.PackManager.insertCompressedUpdates = sinon.stub().callsArg(5);
-				return this.UpdateCompressor.compressRawUpdates = sinon.stub().returns(this.compressedUpdates);
-			});
-
-			return describe("when the raw ops start where the existing history ends", function() {
-				beforeEach(function() {
-					this.rawUpdates = [{ v: 12, op: "mock-op-12" }, { v: 13, op: "mock-op-13" }];
-					return this.UpdatesManager.compressAndSaveRawUpdates(this.project_id, this.doc_id, this.rawUpdates, this.temporary, this.callback);
-				});
-
-				it("should try to look at the last compressed op", function() {
-					return this.MongoManager.peekLastCompressedUpdate
-						.calledWith(this.doc_id)
-						.should.equal(true);
-				});
-				
-				it("should compress the last compressed op and the raw ops", function() {
-					return this.UpdateCompressor.compressRawUpdates
-						.calledWith(this.lastCompressedUpdate, this.rawUpdates)
-						.should.equal(true);
-				});
-				
-				it("should save the compressed ops", function() {
-					return this.PackManager.insertCompressedUpdates
-						.calledWith(this.project_id, this.doc_id, null, this.compressedUpdates, this.temporary)
-						.should.equal(true);
-				});
-
-				return it("should call the callback", function() {
-					return this.callback.called.should.equal(true);
-				});
-			});
-		});
-	});
-
-	describe("processUncompressedUpdates", function() {
-		beforeEach(function() {
-			this.UpdatesManager.compressAndSaveRawUpdates = sinon.stub().callsArgWith(4);
-			this.RedisManager.deleteAppliedDocUpdates = sinon.stub().callsArg(3);
-			this.MongoManager.backportProjectId = sinon.stub().callsArg(2);
-			return this.UpdateTrimmer.shouldTrimUpdates = sinon.stub().callsArgWith(1, null, (this.temporary = "temp mock"));
-		});
-
-		describe("when there is fewer than one batch to send", function() {
-			beforeEach(function() {
-				this.updates = ["mock-update"];
-				this.RedisManager.getOldestDocUpdates = sinon.stub().callsArgWith(2, null, this.updates);
-				this.RedisManager.expandDocUpdates = sinon.stub().callsArgWith(1, null, this.updates);
-				return this.UpdatesManager.processUncompressedUpdates(this.project_id, this.doc_id, this.temporary, this.callback);
-			});
-
-			it("should get the oldest updates", function() {
-				return this.RedisManager.getOldestDocUpdates
-					.calledWith(this.doc_id, this.UpdatesManager.REDIS_READ_BATCH_SIZE)
-					.should.equal(true);
-			});
-
-			it("should compress and save the updates", function() {
-				return this.UpdatesManager.compressAndSaveRawUpdates
-					.calledWith(this.project_id, this.doc_id, this.updates, this.temporary)
-					.should.equal(true);
-			});
-
-			it("should delete the batch of uncompressed updates that was just processed", function() {
-				return this.RedisManager.deleteAppliedDocUpdates
-					.calledWith(this.project_id, this.doc_id, this.updates)
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		return describe("when there are multiple batches to send", function() {
-			beforeEach(function(done) {
-				this.UpdatesManager.REDIS_READ_BATCH_SIZE = 2;
-				this.updates = ["mock-update-0", "mock-update-1", "mock-update-2", "mock-update-3", "mock-update-4"];
-				this.redisArray = this.updates.slice();
-				this.RedisManager.getOldestDocUpdates = (doc_id, batchSize, callback) => {
-					if (callback == null) { callback = function(error, updates) {}; }
-					const updates = this.redisArray.slice(0, batchSize);
-					this.redisArray = this.redisArray.slice(batchSize);
-					return callback(null, updates);
-				};
-				sinon.spy(this.RedisManager, "getOldestDocUpdates");
-				this.RedisManager.expandDocUpdates = (jsonUpdates, callback) => {
-					return callback(null, jsonUpdates);
-				};
-				sinon.spy(this.RedisManager, "expandDocUpdates");
-				return this.UpdatesManager.processUncompressedUpdates(this.project_id, this.doc_id, this.temporary, (...args) => {
-					this.callback(...Array.from(args || []));
-					return done();
-				});
-			});
-
-			it("should get the oldest updates in three batches ", function() {
-				return this.RedisManager.getOldestDocUpdates.callCount.should.equal(3);
-			});
-
-			it("should compress and save the updates in batches", function() {
-				this.UpdatesManager.compressAndSaveRawUpdates
-					.calledWith(this.project_id, this.doc_id, this.updates.slice(0,2), this.temporary)
-					.should.equal(true);
-				this.UpdatesManager.compressAndSaveRawUpdates
-					.calledWith(this.project_id, this.doc_id, this.updates.slice(2,4), this.temporary)
-					.should.equal(true);
-				return this.UpdatesManager.compressAndSaveRawUpdates
-					.calledWith(this.project_id, this.doc_id, this.updates.slice(4,5), this.temporary)
-					.should.equal(true);
-			});
-
-			it("should delete the batches of uncompressed updates", function() {
-				return this.RedisManager.deleteAppliedDocUpdates.callCount.should.equal(3);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-	});
-
-	describe("processCompressedUpdatesWithLock", function() {
-		beforeEach(function() {
-			this.UpdateTrimmer.shouldTrimUpdates = sinon.stub().callsArgWith(1, null, (this.temporary = "temp mock"));
-			this.MongoManager.backportProjectId = sinon.stub().callsArg(2);
-			this.UpdatesManager._processUncompressedUpdates = sinon.stub().callsArg(3);
-			this.LockManager.runWithLock = sinon.stub().callsArg(2);
-			return this.UpdatesManager.processUncompressedUpdatesWithLock(this.project_id, this.doc_id, this.callback);
-		});
-
-		it("should check if the updates are temporary", function() {
-			return this.UpdateTrimmer.shouldTrimUpdates
-				.calledWith(this.project_id)
-				.should.equal(true);
-		});
-
-		it("should backport the project id", function() {
-			return this.MongoManager.backportProjectId
-				.calledWith(this.project_id, this.doc_id)
-				.should.equal(true);
-		});
-
-		it("should run processUncompressedUpdates with the lock", function() {
-			return this.LockManager.runWithLock
-				.calledWith(
-					`HistoryLock:${this.doc_id}`
-				)
-				.should.equal(true);
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-
-	describe("getDocUpdates", function() {
-		beforeEach(function() {
-			this.updates = ["mock-updates"];
-			this.options = { to: "mock-to", limit: "mock-limit" };
-			this.PackManager.getOpsByVersionRange = sinon.stub().callsArgWith(4, null, this.updates);
-			this.UpdatesManager.processUncompressedUpdatesWithLock = sinon.stub().callsArg(2);
-			return this.UpdatesManager.getDocUpdates(this.project_id, this.doc_id, this.options, this.callback);
-		});
-
-		it("should process outstanding updates", function() {
-			return this.UpdatesManager.processUncompressedUpdatesWithLock
-				.calledWith(this.project_id, this.doc_id)
-				.should.equal(true);
-		});
-
-		it("should get the updates from the database", function() {
-			return this.PackManager.getOpsByVersionRange
-				.calledWith(this.project_id, this.doc_id, this.options.from, this.options.to)
-				.should.equal(true);
-		});
-
-		return it("should return the updates", function() {
-			return this.callback
-				.calledWith(null, this.updates)
-				.should.equal(true);
-		});
-	});
-
-	describe("getDocUpdatesWithUserInfo", function() {
-		beforeEach(function() {
-			this.updates = ["mock-updates"];
-			this.options = { to: "mock-to", limit: "mock-limit" };
-			this.updatesWithUserInfo = ["updates-with-user-info"];
-			this.UpdatesManager.getDocUpdates = sinon.stub().callsArgWith(3, null, this.updates);
-			this.UpdatesManager.fillUserInfo = sinon.stub().callsArgWith(1, null, this.updatesWithUserInfo);
-			return this.UpdatesManager.getDocUpdatesWithUserInfo(this.project_id, this.doc_id, this.options, this.callback);
-		});
-
-		it("should get the updates", function() {
-			return this.UpdatesManager.getDocUpdates
-				.calledWith(this.project_id, this.doc_id, this.options)
-				.should.equal(true);
-		});
-
-		it("should file the updates with the user info", function() {
-			return this.UpdatesManager.fillUserInfo
-				.calledWith(this.updates)
-				.should.equal(true);
-		});
-
-		return it("should return the updates with the filled details", function() {
-			return this.callback.calledWith(null, this.updatesWithUserInfo).should.equal(true);
-		});
-	});
-
-	describe("processUncompressedUpdatesForProject", function() {
-		beforeEach(function(done) {
-			this.doc_ids = ["mock-id-1", "mock-id-2"];
-			this.UpdateTrimmer.shouldTrimUpdates = sinon.stub().callsArgWith(1, null, (this.temporary = "temp mock"));
-			this.MongoManager.backportProjectId = sinon.stub().callsArg(2);
-			this.UpdatesManager._processUncompressedUpdatesForDocWithLock = sinon.stub().callsArg(3);
-			this.RedisManager.getDocIdsWithHistoryOps = sinon.stub().callsArgWith(1, null, this.doc_ids);
-			return this.UpdatesManager.processUncompressedUpdatesForProject(this.project_id, () => {
-				this.callback();
-				return done();
-			});
-		});
-
-		it("should get all the docs with history ops", function() {
-			return this.RedisManager.getDocIdsWithHistoryOps
-				.calledWith(this.project_id)
-				.should.equal(true);
-		});
-
-		it("should process the doc ops for the each doc_id", function() {
-			return Array.from(this.doc_ids).map((doc_id) =>
-				this.UpdatesManager._processUncompressedUpdatesForDocWithLock
-					.calledWith(this.project_id, doc_id, this.temporary)
-					.should.equal(true));
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-
-	describe("getSummarizedProjectUpdates", function() {
-		beforeEach(function() {
-			this.updates = [{doc_id: 123, v:456, op: "mock-updates", meta: {user_id: 123, start_ts: 1233, end_ts:1234}}];
-			this.options = { before: "mock-before", limit: "mock-limit" };
-			this.summarizedUpdates = [
-				{meta: {user_ids: [123], start_ts: 1233, end_ts:1234},docs:{"123":{fromV:456,toV:456}}}
-			];
-			this.updatesWithUserInfo = ["updates-with-user-info"];
-			this.done_state = false;
-			this.iterator = {
-				next: cb => {
-					this.done_state = true;
-					return cb(null, this.updates);
-				},
-				done: () => {
-					return this.done_state;
-				}
-			};
-			this.PackManager.makeProjectIterator = sinon.stub().callsArgWith(2, null, this.iterator);
-			this.UpdatesManager.processUncompressedUpdatesForProject = sinon.stub().callsArg(1);
-			this.UpdatesManager.fillSummarizedUserInfo = sinon.stub().callsArgWith(1, null, this.updatesWithUserInfo);
-			return this.UpdatesManager.getSummarizedProjectUpdates(this.project_id, this.options, this.callback);
-		});
-
-		it("should process any outstanding updates", function() {
-			return this.UpdatesManager.processUncompressedUpdatesForProject
-				.calledWith(this.project_id)
-				.should.equal(true);
-		});
-
-		it("should get the updates", function() {
-			return this.PackManager.makeProjectIterator
-				.calledWith(this.project_id, this.options.before)
-				.should.equal(true);
-		});
-
-		it("should fill the updates with the user info", function() {
-			return this.UpdatesManager.fillSummarizedUserInfo
-				.calledWith(this.summarizedUpdates)
-				.should.equal(true);
-		});
-
-		return it("should return the updates with the filled details", function() {
-			return this.callback.calledWith(null, this.updatesWithUserInfo).should.equal(true);
-		});
-	});
-
-	// describe "_extendBatchOfSummarizedUpdates", ->
-	// 	beforeEach ->
-	// 		@before = Date.now()
-	// 		@min_count = 2
-	// 		@existingSummarizedUpdates = ["summarized-updates-3"]
-	// 		@summarizedUpdates = ["summarized-updates-3", "summarized-update-2", "summarized-update-1"]
-
-	// 	describe "when there are updates to get", ->
-	// 		beforeEach ->
-	// 			@updates = [
-	// 				{op: "mock-op-1", meta: end_ts: @before - 10},
-	// 				{op: "mock-op-1", meta: end_ts: @nextBeforeTimestamp = @before - 20}
-	// 			]
-	// 			@existingSummarizedUpdates = ["summarized-updates-3"]
-	// 			@summarizedUpdates = ["summarized-updates-3", "summarized-update-2", "summarized-update-1"]
-	// 			@UpdatesManager._summarizeUpdates = sinon.stub().returns(@summarizedUpdates)
-	// 			@UpdatesManager.getProjectUpdatesWithUserInfo = sinon.stub().callsArgWith(2, null, @updates)
-	// 			@UpdatesManager._extendBatchOfSummarizedUpdates @project_id, @existingSummarizedUpdates, @before, @min_count, @callback
-
-	// 		it "should get the updates", ->
-	// 			@UpdatesManager.getProjectUpdatesWithUserInfo
-	// 				.calledWith(@project_id, { before: @before, limit: 3 * @min_count })
-	// 				.should.equal true
-
-	// 		it "should summarize the updates", ->
-	// 			@UpdatesManager._summarizeUpdates
-	// 				.calledWith(@updates, @existingSummarizedUpdates)
-	// 				.should.equal true
-
-	// 		it "should call the callback with the summarized updates and the next before timestamp", ->
-	// 			@callback.calledWith(null, @summarizedUpdates, @nextBeforeTimestamp).should.equal true
-
-	// 	describe "when there are no more updates", ->
-	// 		beforeEach ->
-	// 			@updates = []
-	// 			@UpdatesManager._summarizeUpdates = sinon.stub().returns(@summarizedUpdates)
-	// 			@UpdatesManager.getProjectUpdatesWithUserInfo = sinon.stub().callsArgWith(2, null, @updates)
-	// 			@UpdatesManager._extendBatchOfSummarizedUpdates @project_id, @existingSummarizedUpdates, @before, @min_count, @callback
-
-	// 		it "should call the callback with the summarized updates and null for nextBeforeTimestamp", ->
-	// 			@callback.calledWith(null, @summarizedUpdates, null).should.equal true
-
-	// describe "getSummarizedProjectUpdates", ->
-	// 	describe "when one batch of updates is enough to meet the limit", ->
-	// 		beforeEach ->
-	// 			@before = Date.now()
-	// 			@min_count = 2
-	// 			@updates = ["summarized-updates-3", "summarized-updates-2"]
-	// 			@nextBeforeTimestamp = @before - 100
-	// 			@UpdatesManager._extendBatchOfSummarizedUpdates = sinon.stub().callsArgWith(4, null, @updates, @nextBeforeTimestamp)
-	// 			@UpdatesManager.getSummarizedProjectUpdates @project_id, { before: @before, min_count: @min_count }, @callback
-
-	// 		it "should get the batch of summarized updates", ->
-	// 			@UpdatesManager._extendBatchOfSummarizedUpdates
-	// 				.calledWith(@project_id, [], @before, @min_count)
-	// 				.should.equal true
-
-	// 		it "should call the callback with the updates", ->
-	// 			@callback.calledWith(null, @updates, @nextBeforeTimestamp).should.equal true
-
-	// 	describe "when multiple batches are needed to meet the limit", ->
-	// 		beforeEach ->
-	// 			@before = Date.now()
-	// 			@min_count = 4
-	// 			@firstBatch =  [{ toV: 6, fromV: 6 }, { toV: 5, fromV: 5 }]
-	// 			@nextBeforeTimestamp = @before - 100
-	// 			@secondBatch = [{ toV: 4, fromV: 4 }, { toV: 3, fromV: 3 }]
-	// 			@nextNextBeforeTimestamp = @before - 200
-	// 			@UpdatesManager._extendBatchOfSummarizedUpdates = (project_id, existingUpdates, before, desiredLength, callback) =>
-	// 				if existingUpdates.length == 0
-	// 					callback null, @firstBatch, @nextBeforeTimestamp
-	// 				else
-	// 					callback null, @firstBatch.concat(@secondBatch), @nextNextBeforeTimestamp
-	// 			sinon.spy @UpdatesManager, "_extendBatchOfSummarizedUpdates"
-	// 			@UpdatesManager.getSummarizedProjectUpdates @project_id, { before: @before, min_count: @min_count }, @callback
-
-	// 		it "should get the first batch of summarized updates", ->
-	// 			@UpdatesManager._extendBatchOfSummarizedUpdates
-	// 				.calledWith(@project_id, [], @before, @min_count)
-	// 				.should.equal true
-
-	// 		it "should get the second batch of summarized updates", ->
-	// 			@UpdatesManager._extendBatchOfSummarizedUpdates
-	// 				.calledWith(@project_id, @firstBatch, @nextBeforeTimestamp, @min_count)
-	// 				.should.equal true
-
-	// 		it "should call the callback with all the updates", ->
-	// 			@callback.calledWith(null, @firstBatch.concat(@secondBatch), @nextNextBeforeTimestamp).should.equal true
-
-	// 	describe "when the end of the database is hit", ->
-	// 		beforeEach ->
-	// 			@before = Date.now()
-	// 			@min_count = 4
-	// 			@updates =  [{ toV: 6, fromV: 6 }, { toV: 5, fromV: 5 }]
-	// 			@UpdatesManager._extendBatchOfSummarizedUpdates = sinon.stub().callsArgWith(4, null, @updates, null)
-	// 			@UpdatesManager.getSummarizedProjectUpdates @project_id, { before: @before, min_count: @min_count }, @callback
-
-	// 		it "should get the batch of summarized updates", ->
-	// 			@UpdatesManager._extendBatchOfSummarizedUpdates
-	// 				.calledWith(@project_id, [], @before, @min_count)
-	// 				.should.equal true
-
-	// 		it "should call the callback with the updates", ->
-	// 			@callback.calledWith(null, @updates, null).should.equal true
-
-	describe("fillUserInfo", function() {
-		describe("with valid users", function() {
-			beforeEach(function(done) {
-				const {ObjectId} = require("mongojs");
-				this.user_id_1 = ObjectId().toString();
-				this.user_id_2 = ObjectId().toString();
-				this.updates = [{
-					meta: {
-						user_id: this.user_id_1
-					},
-					op: "mock-op-1"
-				}, {
-					meta: {
-						user_id: this.user_id_1
-					},
-					op: "mock-op-2"
-				}, {
-					meta: {
-						user_id: this.user_id_2
-					},
-					op: "mock-op-3"
-				}];
-				this.user_info = {};
-				this.user_info[this.user_id_1] = {email: "user1@sharelatex.com"};
-				this.user_info[this.user_id_2] = {email: "user2@sharelatex.com"};
-				
-				this.WebApiManager.getUserInfo = (user_id, callback) => {
-					if (callback == null) { callback = function(error, userInfo) {}; }
-					return callback(null, this.user_info[user_id]);
-				};
-				sinon.spy(this.WebApiManager, "getUserInfo");
-
-				return this.UpdatesManager.fillUserInfo(this.updates, (error, results) => {
-					this.results = results;
-					return done();
-				});
-			});
-
-			it("should only call getUserInfo once for each user_id", function() {
-				this.WebApiManager.getUserInfo.calledTwice.should.equal(true);
-				this.WebApiManager.getUserInfo
-					.calledWith(this.user_id_1)
-					.should.equal(true);
-				return this.WebApiManager.getUserInfo
-					.calledWith(this.user_id_2)
-					.should.equal(true);
-			});
-
-			return it("should return the updates with the user info filled", function() {
-				return expect(this.results).to.deep.equal([{
-					meta: {
-						user: {
-							email: "user1@sharelatex.com"
-						}
-					},
-					op: "mock-op-1"
-				}, {
-					meta: {
-						user: {
-							email: "user1@sharelatex.com"
-						}
-					},
-					op: "mock-op-2"
-				}, {
-					meta: {
-						user: {
-							email: "user2@sharelatex.com"
-						}
-					},
-					op: "mock-op-3"
-				}]);
-		});
-	});
-
-
-		return describe("with invalid user ids", function() {
-			beforeEach(function(done) {
-				this.updates = [{
-					meta: {
-						user_id: null
-					},
-					op: "mock-op-1"
-				}, {
-					meta: {
-						user_id: "anonymous-user"
-					},
-					op: "mock-op-2"
-				}];
-				this.WebApiManager.getUserInfo = (user_id, callback) => {
-					if (callback == null) { callback = function(error, userInfo) {}; }
-					return callback(null, this.user_info[user_id]);
-				};
-				sinon.spy(this.WebApiManager, "getUserInfo");
-
-				return this.UpdatesManager.fillUserInfo(this.updates, (error, results) => {
-					this.results = results;
-					return done();
-				});
-			});
-
-			it("should not call getUserInfo", function() {
-				return this.WebApiManager.getUserInfo.called.should.equal(false);
-			});
-
-			return it("should return the updates without the user info filled", function() {
-				return expect(this.results).to.deep.equal([{
-					meta: {},
-					op: "mock-op-1"
-				}, {
-					meta: {},
-					op: "mock-op-2"
-				}]);
-		});
-	});
-});
-
-	return describe("_summarizeUpdates", function() {
-		beforeEach(function() {
-			this.now = Date.now();
-			this.user_1 = { id: "mock-user-1" };
-			return this.user_2 = { id: "mock-user-2" };});
-
-		it("should concat updates that are close in time", function() {
-			const result = this.UpdatesManager._summarizeUpdates([{
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: this.user_1.id,
-					start_ts: this.now + 20,
-					end_ts:   this.now + 30
-				},
-				v: 5
-			}, {
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: this.user_2.id,
-					start_ts: this.now,
-					end_ts:   this.now + 10
-				},
-				v: 4
-			}]);
-
-			return expect(result).to.deep.equal([{
-				docs: {
-					"doc-id-1": {
-						fromV: 4,
-						toV: 5
-					}
-				},
-				meta: {
-					user_ids: [this.user_1.id, this.user_2.id],
-					start_ts: this.now,
-					end_ts:   this.now + 30
-				}
-			}]);
-	});
-
-		it("should leave updates that are far apart in time", function() {
-			const oneDay = 1000 * 60 * 60 * 24;
-			const result = this.UpdatesManager._summarizeUpdates([{
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: this.user_2.id,
-					start_ts: this.now + oneDay,
-					end_ts:   this.now + oneDay + 10
-				},
-				v: 5
-			}, {
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: this.user_1.id,
-					start_ts: this.now,
-					end_ts:   this.now + 10
-				},
-				v: 4
-			}]);
-			return expect(result).to.deep.equal([{
-				docs: {
-					"doc-id-1": {
-						fromV: 5,
-						toV: 5
-					}
-				},
-				meta: {
-					user_ids: [this.user_2.id],
-					start_ts: this.now + oneDay,
-					end_ts:   this.now + oneDay + 10
-				}
-			}, {
-				docs: {
-					"doc-id-1": {
-						fromV: 4,
-						toV: 4
-					}
-				},
-				meta: {
-					user_ids: [this.user_1.id],
-					start_ts: this.now,
-					end_ts:   this.now + 10
-				}
-			}]);
-	});
-
-		it("should concat onto existing summarized updates", function() {
-			const result = this.UpdatesManager._summarizeUpdates([{
-				doc_id: "doc-id-2",
-				meta: {
-					user_id: this.user_1.id,
-					start_ts: this.now + 20,
-					end_ts:   this.now + 30
-				},
-				v: 5
-			}, {
-				doc_id: "doc-id-2",
-				meta: {
-					user_id: this.user_2.id,
-					start_ts: this.now,
-					end_ts:   this.now + 10
-				},
-				v: 4
-			}], [{
-				docs: { 
-					"doc-id-1": { 
-						fromV: 6,
-						toV: 8
-					}
-				},
-				meta: {
-					user_ids: [this.user_1.id],
-					start_ts: this.now + 40,
-					end_ts:   this.now + 50
-				}
-			}]);
-			return expect(result).to.deep.equal([{
-				docs: {
-					"doc-id-1": {
-						toV: 8,
-						fromV: 6
-					},
-					"doc-id-2": {
-						toV: 5,
-						fromV: 4
-					}
-				},
-				meta: {
-					user_ids: [this.user_1.id, this.user_2.id],
-					start_ts: this.now,
-					end_ts:   this.now + 50
-				}
-			}]);
-	});
-
-		it("should include null user values", function() {
-			const result = this.UpdatesManager._summarizeUpdates([{
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: this.user_1.id,
-					start_ts: this.now + 20,
-					end_ts:   this.now + 30
-				},
-				v: 5
-			}, {
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: null,
-					start_ts: this.now,
-					end_ts:   this.now + 10
-				},
-				v: 4
-			}]);
-			return expect(result).to.deep.equal([{
-				docs: {
-					"doc-id-1": {
-						fromV: 4,
-						toV: 5
-					}
-				},
-				meta: {
-					user_ids: [this.user_1.id, null],
-					start_ts: this.now,
-					end_ts:   this.now + 30
-				}
-			}]);
-	});
-
-		it("should include null user values, when the null is earlier in the updates list", function() {
-			const result = this.UpdatesManager._summarizeUpdates([{
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: null,
-					start_ts: this.now,
-					end_ts:   this.now + 10
-				},
-				v: 4
-			}, {
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: this.user_1.id,
-					start_ts: this.now + 20,
-					end_ts:   this.now + 30
-				},
-				v: 5
-			}]);
-			return expect(result).to.deep.equal([{
-				docs: {
-					"doc-id-1": {
-						fromV: 4,
-						toV: 5
-					}
-				},
-				meta: {
-					user_ids: [null, this.user_1.id],
-					start_ts: this.now,
-					end_ts:   this.now + 30
-				}
-			}]);
-	});
-
-		it("should roll several null user values into one", function() {
-			const result = this.UpdatesManager._summarizeUpdates([{
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: this.user_1.id,
-					start_ts: this.now + 20,
-					end_ts:   this.now + 30
-				},
-				v: 5
-			}, {
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: null,
-					start_ts: this.now,
-					end_ts:   this.now + 10
-				},
-				v: 4
-			}, {
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: null,
-					start_ts: this.now + 2,
-					end_ts:   this.now + 4
-				},
-				v: 4
-			}]);
-			return expect(result).to.deep.equal([{
-				docs: {
-					"doc-id-1": {
-						fromV: 4,
-						toV: 5
-					}
-				},
-				meta: {
-					user_ids: [this.user_1.id, null],
-					start_ts: this.now,
-					end_ts:   this.now + 30
-				}
-			}]);
-	});
-
-		return it("should split updates before a big delete", function() {
-			const result = this.UpdatesManager._summarizeUpdates([{
-				doc_id: "doc-id-1",
-				op: [{ d: "this is a long long long long long delete", p: 34 }],
-				meta: {
-					user_id: this.user_1.id,
-					start_ts: this.now + 20,
-					end_ts:   this.now + 30
-				},
-				v: 5
-			}, {
-				doc_id: "doc-id-1",
-				meta: {
-					user_id: this.user_2.id,
-					start_ts: this.now,
-					end_ts:   this.now + 10
-				},
-				v: 4
-			}]);
-
-			return expect(result).to.deep.equal([{
-				docs: {
-					"doc-id-1": {
-						fromV: 5,
-						toV: 5
-					}
-				},
-				meta: {
-					user_ids: [this.user_1.id],
-					start_ts: this.now + 20,
-					end_ts:   this.now + 30
-				}
-			}, {
-				docs: {
-					"doc-id-1": {
-						fromV: 4,
-						toV: 4
-					}
-				},
-				meta: {
-					user_ids: [this.user_2.id],
-					start_ts: this.now,
-					end_ts:   this.now + 10
-				}
-			}]);
-	});
-});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/UpdatesManager.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('UpdatesManager', function() {
+  beforeEach(function() {
+    this.UpdatesManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './UpdateCompressor': (this.UpdateCompressor = {}),
+        './MongoManager': (this.MongoManager = {}),
+        './PackManager': (this.PackManager = {}),
+        './RedisManager': (this.RedisManager = {}),
+        './LockManager': (this.LockManager = {}),
+        './WebApiManager': (this.WebApiManager = {}),
+        './UpdateTrimmer': (this.UpdateTrimmer = {}),
+        './DocArchiveManager': (this.DocArchiveManager = {}),
+        'logger-sharelatex': { log: sinon.stub(), error: sinon.stub() },
+        'settings-sharelatex': {
+          redis: {
+            lock: {
+              key_schema: {
+                historyLock({ doc_id }) {
+                  return `HistoryLock:${doc_id}`
+                }
+              }
+            }
+          }
+        }
+      }
+    })
+    this.doc_id = 'doc-id-123'
+    this.project_id = 'project-id-123'
+    this.callback = sinon.stub()
+    return (this.temporary = 'temp-mock')
+  })
+
+  describe('compressAndSaveRawUpdates', function() {
+    describe('when there are no raw ops', function() {
+      beforeEach(function() {
+        this.MongoManager.peekLastCompressedUpdate = sinon.stub()
+        return this.UpdatesManager.compressAndSaveRawUpdates(
+          this.project_id,
+          this.doc_id,
+          [],
+          this.temporary,
+          this.callback
+        )
+      })
+
+      it('should not need to access the database', function() {
+        return this.MongoManager.peekLastCompressedUpdate.called.should.equal(
+          false
+        )
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    describe('when there is no compressed history to begin with', function() {
+      beforeEach(function() {
+        this.rawUpdates = [
+          { v: 12, op: 'mock-op-12' },
+          { v: 13, op: 'mock-op-13' }
+        ]
+        this.compressedUpdates = [{ v: 13, op: 'compressed-op-12' }]
+
+        this.MongoManager.peekLastCompressedUpdate = sinon
+          .stub()
+          .callsArgWith(1, null, null)
+        this.PackManager.insertCompressedUpdates = sinon.stub().callsArg(5)
+        this.UpdateCompressor.compressRawUpdates = sinon
+          .stub()
+          .returns(this.compressedUpdates)
+        return this.UpdatesManager.compressAndSaveRawUpdates(
+          this.project_id,
+          this.doc_id,
+          this.rawUpdates,
+          this.temporary,
+          this.callback
+        )
+      })
+
+      it('should look at the last compressed op', function() {
+        return this.MongoManager.peekLastCompressedUpdate
+          .calledWith(this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should save the compressed ops as a pack', function() {
+        return this.PackManager.insertCompressedUpdates
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            null,
+            this.compressedUpdates,
+            this.temporary
+          )
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    describe('when the raw ops need appending to existing history', function() {
+      beforeEach(function() {
+        this.lastCompressedUpdate = { v: 11, op: 'compressed-op-11' }
+        this.compressedUpdates = [
+          { v: 12, op: 'compressed-op-11+12' },
+          { v: 13, op: 'compressed-op-12' }
+        ]
+
+        this.MongoManager.peekLastCompressedUpdate = sinon
+          .stub()
+          .callsArgWith(
+            1,
+            null,
+            this.lastCompressedUpdate,
+            this.lastCompressedUpdate.v
+          )
+        this.PackManager.insertCompressedUpdates = sinon.stub().callsArg(5)
+        return (this.UpdateCompressor.compressRawUpdates = sinon
+          .stub()
+          .returns(this.compressedUpdates))
+      })
+
+      describe('when the raw ops start where the existing history ends', function() {
+        beforeEach(function() {
+          this.rawUpdates = [
+            { v: 12, op: 'mock-op-12' },
+            { v: 13, op: 'mock-op-13' }
+          ]
+          return this.UpdatesManager.compressAndSaveRawUpdates(
+            this.project_id,
+            this.doc_id,
+            this.rawUpdates,
+            this.temporary,
+            this.callback
+          )
+        })
+
+        it('should look at the last compressed op', function() {
+          return this.MongoManager.peekLastCompressedUpdate
+            .calledWith(this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should compress the raw ops', function() {
+          return this.UpdateCompressor.compressRawUpdates
+            .calledWith(null, this.rawUpdates)
+            .should.equal(true)
+        })
+
+        it('should save the new compressed ops into a pack', function() {
+          return this.PackManager.insertCompressedUpdates
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.lastCompressedUpdate,
+              this.compressedUpdates,
+              this.temporary
+            )
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+
+      describe('when the raw ops start where the existing history ends and the history is in a pack', function() {
+        beforeEach(function() {
+          this.lastCompressedUpdate = {
+            pack: [{ v: 11, op: 'compressed-op-11' }],
+            v: 11
+          }
+          this.rawUpdates = [
+            { v: 12, op: 'mock-op-12' },
+            { v: 13, op: 'mock-op-13' }
+          ]
+          this.MongoManager.peekLastCompressedUpdate = sinon
+            .stub()
+            .callsArgWith(
+              1,
+              null,
+              this.lastCompressedUpdate,
+              this.lastCompressedUpdate.v
+            )
+          return this.UpdatesManager.compressAndSaveRawUpdates(
+            this.project_id,
+            this.doc_id,
+            this.rawUpdates,
+            this.temporary,
+            this.callback
+          )
+        })
+
+        it('should look at the last compressed op', function() {
+          return this.MongoManager.peekLastCompressedUpdate
+            .calledWith(this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should compress the raw ops', function() {
+          return this.UpdateCompressor.compressRawUpdates
+            .calledWith(null, this.rawUpdates)
+            .should.equal(true)
+        })
+
+        it('should save the new compressed ops into a pack', function() {
+          return this.PackManager.insertCompressedUpdates
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              this.lastCompressedUpdate,
+              this.compressedUpdates,
+              this.temporary
+            )
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+
+      describe('when some raw ops are passed that have already been compressed', function() {
+        beforeEach(function() {
+          this.rawUpdates = [
+            { v: 10, op: 'mock-op-10' },
+            { v: 11, op: 'mock-op-11' },
+            { v: 12, op: 'mock-op-12' },
+            { v: 13, op: 'mock-op-13' }
+          ]
+
+          return this.UpdatesManager.compressAndSaveRawUpdates(
+            this.project_id,
+            this.doc_id,
+            this.rawUpdates,
+            this.temporary,
+            this.callback
+          )
+        })
+
+        return it('should only compress the more recent raw ops', function() {
+          return this.UpdateCompressor.compressRawUpdates
+            .calledWith(null, this.rawUpdates.slice(-2))
+            .should.equal(true)
+        })
+      })
+
+      describe('when the raw ops do not follow from the last compressed op version', function() {
+        beforeEach(function() {
+          this.rawUpdates = [{ v: 13, op: 'mock-op-13' }]
+          return this.UpdatesManager.compressAndSaveRawUpdates(
+            this.project_id,
+            this.doc_id,
+            this.rawUpdates,
+            this.temporary,
+            this.callback
+          )
+        })
+
+        it('should call the callback with an error', function() {
+          return this.callback
+            .calledWith(
+              sinon.match.has(
+                'message',
+                'Tried to apply raw op at version 13 to last compressed update with version 11 from unknown time'
+              )
+            )
+            .should.equal(true)
+        })
+
+        return it('should not insert any update into mongo', function() {
+          return this.PackManager.insertCompressedUpdates.called.should.equal(
+            false
+          )
+        })
+      })
+
+      return describe('when the raw ops are out of order', function() {
+        beforeEach(function() {
+          this.rawUpdates = [
+            { v: 13, op: 'mock-op-13' },
+            { v: 12, op: 'mock-op-12' }
+          ]
+          return this.UpdatesManager.compressAndSaveRawUpdates(
+            this.project_id,
+            this.doc_id,
+            this.rawUpdates,
+            this.temporary,
+            this.callback
+          )
+        })
+
+        it('should call the callback with an error', function() {
+          return this.callback
+            .calledWith(sinon.match.has('message'))
+            .should.equal(true)
+        })
+
+        return it('should not insert any update into mongo', function() {
+          return this.PackManager.insertCompressedUpdates.called.should.equal(
+            false
+          )
+        })
+      })
+    })
+
+    return describe('when the raw ops need appending to existing history which is in S3', function() {
+      beforeEach(function() {
+        this.lastCompressedUpdate = null
+        this.lastVersion = 11
+        this.compressedUpdates = [{ v: 13, op: 'compressed-op-12' }]
+
+        this.MongoManager.peekLastCompressedUpdate = sinon
+          .stub()
+          .callsArgWith(1, null, null, this.lastVersion)
+        this.PackManager.insertCompressedUpdates = sinon.stub().callsArg(5)
+        return (this.UpdateCompressor.compressRawUpdates = sinon
+          .stub()
+          .returns(this.compressedUpdates))
+      })
+
+      return describe('when the raw ops start where the existing history ends', function() {
+        beforeEach(function() {
+          this.rawUpdates = [
+            { v: 12, op: 'mock-op-12' },
+            { v: 13, op: 'mock-op-13' }
+          ]
+          return this.UpdatesManager.compressAndSaveRawUpdates(
+            this.project_id,
+            this.doc_id,
+            this.rawUpdates,
+            this.temporary,
+            this.callback
+          )
+        })
+
+        it('should try to look at the last compressed op', function() {
+          return this.MongoManager.peekLastCompressedUpdate
+            .calledWith(this.doc_id)
+            .should.equal(true)
+        })
+
+        it('should compress the last compressed op and the raw ops', function() {
+          return this.UpdateCompressor.compressRawUpdates
+            .calledWith(this.lastCompressedUpdate, this.rawUpdates)
+            .should.equal(true)
+        })
+
+        it('should save the compressed ops', function() {
+          return this.PackManager.insertCompressedUpdates
+            .calledWith(
+              this.project_id,
+              this.doc_id,
+              null,
+              this.compressedUpdates,
+              this.temporary
+            )
+            .should.equal(true)
+        })
+
+        return it('should call the callback', function() {
+          return this.callback.called.should.equal(true)
+        })
+      })
+    })
+  })
+
+  describe('processUncompressedUpdates', function() {
+    beforeEach(function() {
+      this.UpdatesManager.compressAndSaveRawUpdates = sinon
+        .stub()
+        .callsArgWith(4)
+      this.RedisManager.deleteAppliedDocUpdates = sinon.stub().callsArg(3)
+      this.MongoManager.backportProjectId = sinon.stub().callsArg(2)
+      return (this.UpdateTrimmer.shouldTrimUpdates = sinon
+        .stub()
+        .callsArgWith(1, null, (this.temporary = 'temp mock')))
+    })
+
+    describe('when there is fewer than one batch to send', function() {
+      beforeEach(function() {
+        this.updates = ['mock-update']
+        this.RedisManager.getOldestDocUpdates = sinon
+          .stub()
+          .callsArgWith(2, null, this.updates)
+        this.RedisManager.expandDocUpdates = sinon
+          .stub()
+          .callsArgWith(1, null, this.updates)
+        return this.UpdatesManager.processUncompressedUpdates(
+          this.project_id,
+          this.doc_id,
+          this.temporary,
+          this.callback
+        )
+      })
+
+      it('should get the oldest updates', function() {
+        return this.RedisManager.getOldestDocUpdates
+          .calledWith(this.doc_id, this.UpdatesManager.REDIS_READ_BATCH_SIZE)
+          .should.equal(true)
+      })
+
+      it('should compress and save the updates', function() {
+        return this.UpdatesManager.compressAndSaveRawUpdates
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.updates,
+            this.temporary
+          )
+          .should.equal(true)
+      })
+
+      it('should delete the batch of uncompressed updates that was just processed', function() {
+        return this.RedisManager.deleteAppliedDocUpdates
+          .calledWith(this.project_id, this.doc_id, this.updates)
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    return describe('when there are multiple batches to send', function() {
+      beforeEach(function(done) {
+        this.UpdatesManager.REDIS_READ_BATCH_SIZE = 2
+        this.updates = [
+          'mock-update-0',
+          'mock-update-1',
+          'mock-update-2',
+          'mock-update-3',
+          'mock-update-4'
+        ]
+        this.redisArray = this.updates.slice()
+        this.RedisManager.getOldestDocUpdates = (
+          doc_id,
+          batchSize,
+          callback
+        ) => {
+          if (callback == null) {
+            callback = function(error, updates) {}
+          }
+          const updates = this.redisArray.slice(0, batchSize)
+          this.redisArray = this.redisArray.slice(batchSize)
+          return callback(null, updates)
+        }
+        sinon.spy(this.RedisManager, 'getOldestDocUpdates')
+        this.RedisManager.expandDocUpdates = (jsonUpdates, callback) => {
+          return callback(null, jsonUpdates)
+        }
+        sinon.spy(this.RedisManager, 'expandDocUpdates')
+        return this.UpdatesManager.processUncompressedUpdates(
+          this.project_id,
+          this.doc_id,
+          this.temporary,
+          (...args) => {
+            this.callback(...Array.from(args || []))
+            return done()
+          }
+        )
+      })
+
+      it('should get the oldest updates in three batches ', function() {
+        return this.RedisManager.getOldestDocUpdates.callCount.should.equal(3)
+      })
+
+      it('should compress and save the updates in batches', function() {
+        this.UpdatesManager.compressAndSaveRawUpdates
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.updates.slice(0, 2),
+            this.temporary
+          )
+          .should.equal(true)
+        this.UpdatesManager.compressAndSaveRawUpdates
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.updates.slice(2, 4),
+            this.temporary
+          )
+          .should.equal(true)
+        return this.UpdatesManager.compressAndSaveRawUpdates
+          .calledWith(
+            this.project_id,
+            this.doc_id,
+            this.updates.slice(4, 5),
+            this.temporary
+          )
+          .should.equal(true)
+      })
+
+      it('should delete the batches of uncompressed updates', function() {
+        return this.RedisManager.deleteAppliedDocUpdates.callCount.should.equal(
+          3
+        )
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+  })
+
+  describe('processCompressedUpdatesWithLock', function() {
+    beforeEach(function() {
+      this.UpdateTrimmer.shouldTrimUpdates = sinon
+        .stub()
+        .callsArgWith(1, null, (this.temporary = 'temp mock'))
+      this.MongoManager.backportProjectId = sinon.stub().callsArg(2)
+      this.UpdatesManager._processUncompressedUpdates = sinon.stub().callsArg(3)
+      this.LockManager.runWithLock = sinon.stub().callsArg(2)
+      return this.UpdatesManager.processUncompressedUpdatesWithLock(
+        this.project_id,
+        this.doc_id,
+        this.callback
+      )
+    })
+
+    it('should check if the updates are temporary', function() {
+      return this.UpdateTrimmer.shouldTrimUpdates
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
+
+    it('should backport the project id', function() {
+      return this.MongoManager.backportProjectId
+        .calledWith(this.project_id, this.doc_id)
+        .should.equal(true)
+    })
+
+    it('should run processUncompressedUpdates with the lock', function() {
+      return this.LockManager.runWithLock
+        .calledWith(`HistoryLock:${this.doc_id}`)
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+
+  describe('getDocUpdates', function() {
+    beforeEach(function() {
+      this.updates = ['mock-updates']
+      this.options = { to: 'mock-to', limit: 'mock-limit' }
+      this.PackManager.getOpsByVersionRange = sinon
+        .stub()
+        .callsArgWith(4, null, this.updates)
+      this.UpdatesManager.processUncompressedUpdatesWithLock = sinon
+        .stub()
+        .callsArg(2)
+      return this.UpdatesManager.getDocUpdates(
+        this.project_id,
+        this.doc_id,
+        this.options,
+        this.callback
+      )
+    })
+
+    it('should process outstanding updates', function() {
+      return this.UpdatesManager.processUncompressedUpdatesWithLock
+        .calledWith(this.project_id, this.doc_id)
+        .should.equal(true)
+    })
+
+    it('should get the updates from the database', function() {
+      return this.PackManager.getOpsByVersionRange
+        .calledWith(
+          this.project_id,
+          this.doc_id,
+          this.options.from,
+          this.options.to
+        )
+        .should.equal(true)
+    })
+
+    return it('should return the updates', function() {
+      return this.callback.calledWith(null, this.updates).should.equal(true)
+    })
+  })
+
+  describe('getDocUpdatesWithUserInfo', function() {
+    beforeEach(function() {
+      this.updates = ['mock-updates']
+      this.options = { to: 'mock-to', limit: 'mock-limit' }
+      this.updatesWithUserInfo = ['updates-with-user-info']
+      this.UpdatesManager.getDocUpdates = sinon
+        .stub()
+        .callsArgWith(3, null, this.updates)
+      this.UpdatesManager.fillUserInfo = sinon
+        .stub()
+        .callsArgWith(1, null, this.updatesWithUserInfo)
+      return this.UpdatesManager.getDocUpdatesWithUserInfo(
+        this.project_id,
+        this.doc_id,
+        this.options,
+        this.callback
+      )
+    })
+
+    it('should get the updates', function() {
+      return this.UpdatesManager.getDocUpdates
+        .calledWith(this.project_id, this.doc_id, this.options)
+        .should.equal(true)
+    })
+
+    it('should file the updates with the user info', function() {
+      return this.UpdatesManager.fillUserInfo
+        .calledWith(this.updates)
+        .should.equal(true)
+    })
+
+    return it('should return the updates with the filled details', function() {
+      return this.callback
+        .calledWith(null, this.updatesWithUserInfo)
+        .should.equal(true)
+    })
+  })
+
+  describe('processUncompressedUpdatesForProject', function() {
+    beforeEach(function(done) {
+      this.doc_ids = ['mock-id-1', 'mock-id-2']
+      this.UpdateTrimmer.shouldTrimUpdates = sinon
+        .stub()
+        .callsArgWith(1, null, (this.temporary = 'temp mock'))
+      this.MongoManager.backportProjectId = sinon.stub().callsArg(2)
+      this.UpdatesManager._processUncompressedUpdatesForDocWithLock = sinon
+        .stub()
+        .callsArg(3)
+      this.RedisManager.getDocIdsWithHistoryOps = sinon
+        .stub()
+        .callsArgWith(1, null, this.doc_ids)
+      return this.UpdatesManager.processUncompressedUpdatesForProject(
+        this.project_id,
+        () => {
+          this.callback()
+          return done()
+        }
+      )
+    })
+
+    it('should get all the docs with history ops', function() {
+      return this.RedisManager.getDocIdsWithHistoryOps
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
+
+    it('should process the doc ops for the each doc_id', function() {
+      return Array.from(this.doc_ids).map(doc_id =>
+        this.UpdatesManager._processUncompressedUpdatesForDocWithLock
+          .calledWith(this.project_id, doc_id, this.temporary)
+          .should.equal(true)
+      )
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+
+  describe('getSummarizedProjectUpdates', function() {
+    beforeEach(function() {
+      this.updates = [
+        {
+          doc_id: 123,
+          v: 456,
+          op: 'mock-updates',
+          meta: { user_id: 123, start_ts: 1233, end_ts: 1234 }
+        }
+      ]
+      this.options = { before: 'mock-before', limit: 'mock-limit' }
+      this.summarizedUpdates = [
+        {
+          meta: { user_ids: [123], start_ts: 1233, end_ts: 1234 },
+          docs: { '123': { fromV: 456, toV: 456 } }
+        }
+      ]
+      this.updatesWithUserInfo = ['updates-with-user-info']
+      this.done_state = false
+      this.iterator = {
+        next: cb => {
+          this.done_state = true
+          return cb(null, this.updates)
+        },
+        done: () => {
+          return this.done_state
+        }
+      }
+      this.PackManager.makeProjectIterator = sinon
+        .stub()
+        .callsArgWith(2, null, this.iterator)
+      this.UpdatesManager.processUncompressedUpdatesForProject = sinon
+        .stub()
+        .callsArg(1)
+      this.UpdatesManager.fillSummarizedUserInfo = sinon
+        .stub()
+        .callsArgWith(1, null, this.updatesWithUserInfo)
+      return this.UpdatesManager.getSummarizedProjectUpdates(
+        this.project_id,
+        this.options,
+        this.callback
+      )
+    })
+
+    it('should process any outstanding updates', function() {
+      return this.UpdatesManager.processUncompressedUpdatesForProject
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
+
+    it('should get the updates', function() {
+      return this.PackManager.makeProjectIterator
+        .calledWith(this.project_id, this.options.before)
+        .should.equal(true)
+    })
+
+    it('should fill the updates with the user info', function() {
+      return this.UpdatesManager.fillSummarizedUserInfo
+        .calledWith(this.summarizedUpdates)
+        .should.equal(true)
+    })
+
+    return it('should return the updates with the filled details', function() {
+      return this.callback
+        .calledWith(null, this.updatesWithUserInfo)
+        .should.equal(true)
+    })
+  })
+
+  // describe "_extendBatchOfSummarizedUpdates", ->
+  // 	beforeEach ->
+  // 		@before = Date.now()
+  // 		@min_count = 2
+  // 		@existingSummarizedUpdates = ["summarized-updates-3"]
+  // 		@summarizedUpdates = ["summarized-updates-3", "summarized-update-2", "summarized-update-1"]
+
+  // 	describe "when there are updates to get", ->
+  // 		beforeEach ->
+  // 			@updates = [
+  // 				{op: "mock-op-1", meta: end_ts: @before - 10},
+  // 				{op: "mock-op-1", meta: end_ts: @nextBeforeTimestamp = @before - 20}
+  // 			]
+  // 			@existingSummarizedUpdates = ["summarized-updates-3"]
+  // 			@summarizedUpdates = ["summarized-updates-3", "summarized-update-2", "summarized-update-1"]
+  // 			@UpdatesManager._summarizeUpdates = sinon.stub().returns(@summarizedUpdates)
+  // 			@UpdatesManager.getProjectUpdatesWithUserInfo = sinon.stub().callsArgWith(2, null, @updates)
+  // 			@UpdatesManager._extendBatchOfSummarizedUpdates @project_id, @existingSummarizedUpdates, @before, @min_count, @callback
+
+  // 		it "should get the updates", ->
+  // 			@UpdatesManager.getProjectUpdatesWithUserInfo
+  // 				.calledWith(@project_id, { before: @before, limit: 3 * @min_count })
+  // 				.should.equal true
+
+  // 		it "should summarize the updates", ->
+  // 			@UpdatesManager._summarizeUpdates
+  // 				.calledWith(@updates, @existingSummarizedUpdates)
+  // 				.should.equal true
+
+  // 		it "should call the callback with the summarized updates and the next before timestamp", ->
+  // 			@callback.calledWith(null, @summarizedUpdates, @nextBeforeTimestamp).should.equal true
+
+  // 	describe "when there are no more updates", ->
+  // 		beforeEach ->
+  // 			@updates = []
+  // 			@UpdatesManager._summarizeUpdates = sinon.stub().returns(@summarizedUpdates)
+  // 			@UpdatesManager.getProjectUpdatesWithUserInfo = sinon.stub().callsArgWith(2, null, @updates)
+  // 			@UpdatesManager._extendBatchOfSummarizedUpdates @project_id, @existingSummarizedUpdates, @before, @min_count, @callback
+
+  // 		it "should call the callback with the summarized updates and null for nextBeforeTimestamp", ->
+  // 			@callback.calledWith(null, @summarizedUpdates, null).should.equal true
+
+  // describe "getSummarizedProjectUpdates", ->
+  // 	describe "when one batch of updates is enough to meet the limit", ->
+  // 		beforeEach ->
+  // 			@before = Date.now()
+  // 			@min_count = 2
+  // 			@updates = ["summarized-updates-3", "summarized-updates-2"]
+  // 			@nextBeforeTimestamp = @before - 100
+  // 			@UpdatesManager._extendBatchOfSummarizedUpdates = sinon.stub().callsArgWith(4, null, @updates, @nextBeforeTimestamp)
+  // 			@UpdatesManager.getSummarizedProjectUpdates @project_id, { before: @before, min_count: @min_count }, @callback
+
+  // 		it "should get the batch of summarized updates", ->
+  // 			@UpdatesManager._extendBatchOfSummarizedUpdates
+  // 				.calledWith(@project_id, [], @before, @min_count)
+  // 				.should.equal true
+
+  // 		it "should call the callback with the updates", ->
+  // 			@callback.calledWith(null, @updates, @nextBeforeTimestamp).should.equal true
+
+  // 	describe "when multiple batches are needed to meet the limit", ->
+  // 		beforeEach ->
+  // 			@before = Date.now()
+  // 			@min_count = 4
+  // 			@firstBatch =  [{ toV: 6, fromV: 6 }, { toV: 5, fromV: 5 }]
+  // 			@nextBeforeTimestamp = @before - 100
+  // 			@secondBatch = [{ toV: 4, fromV: 4 }, { toV: 3, fromV: 3 }]
+  // 			@nextNextBeforeTimestamp = @before - 200
+  // 			@UpdatesManager._extendBatchOfSummarizedUpdates = (project_id, existingUpdates, before, desiredLength, callback) =>
+  // 				if existingUpdates.length == 0
+  // 					callback null, @firstBatch, @nextBeforeTimestamp
+  // 				else
+  // 					callback null, @firstBatch.concat(@secondBatch), @nextNextBeforeTimestamp
+  // 			sinon.spy @UpdatesManager, "_extendBatchOfSummarizedUpdates"
+  // 			@UpdatesManager.getSummarizedProjectUpdates @project_id, { before: @before, min_count: @min_count }, @callback
+
+  // 		it "should get the first batch of summarized updates", ->
+  // 			@UpdatesManager._extendBatchOfSummarizedUpdates
+  // 				.calledWith(@project_id, [], @before, @min_count)
+  // 				.should.equal true
+
+  // 		it "should get the second batch of summarized updates", ->
+  // 			@UpdatesManager._extendBatchOfSummarizedUpdates
+  // 				.calledWith(@project_id, @firstBatch, @nextBeforeTimestamp, @min_count)
+  // 				.should.equal true
+
+  // 		it "should call the callback with all the updates", ->
+  // 			@callback.calledWith(null, @firstBatch.concat(@secondBatch), @nextNextBeforeTimestamp).should.equal true
+
+  // 	describe "when the end of the database is hit", ->
+  // 		beforeEach ->
+  // 			@before = Date.now()
+  // 			@min_count = 4
+  // 			@updates =  [{ toV: 6, fromV: 6 }, { toV: 5, fromV: 5 }]
+  // 			@UpdatesManager._extendBatchOfSummarizedUpdates = sinon.stub().callsArgWith(4, null, @updates, null)
+  // 			@UpdatesManager.getSummarizedProjectUpdates @project_id, { before: @before, min_count: @min_count }, @callback
+
+  // 		it "should get the batch of summarized updates", ->
+  // 			@UpdatesManager._extendBatchOfSummarizedUpdates
+  // 				.calledWith(@project_id, [], @before, @min_count)
+  // 				.should.equal true
+
+  // 		it "should call the callback with the updates", ->
+  // 			@callback.calledWith(null, @updates, null).should.equal true
+
+  describe('fillUserInfo', function() {
+    describe('with valid users', function() {
+      beforeEach(function(done) {
+        const { ObjectId } = require('mongojs')
+        this.user_id_1 = ObjectId().toString()
+        this.user_id_2 = ObjectId().toString()
+        this.updates = [
+          {
+            meta: {
+              user_id: this.user_id_1
+            },
+            op: 'mock-op-1'
+          },
+          {
+            meta: {
+              user_id: this.user_id_1
+            },
+            op: 'mock-op-2'
+          },
+          {
+            meta: {
+              user_id: this.user_id_2
+            },
+            op: 'mock-op-3'
+          }
+        ]
+        this.user_info = {}
+        this.user_info[this.user_id_1] = { email: 'user1@sharelatex.com' }
+        this.user_info[this.user_id_2] = { email: 'user2@sharelatex.com' }
+
+        this.WebApiManager.getUserInfo = (user_id, callback) => {
+          if (callback == null) {
+            callback = function(error, userInfo) {}
+          }
+          return callback(null, this.user_info[user_id])
+        }
+        sinon.spy(this.WebApiManager, 'getUserInfo')
+
+        return this.UpdatesManager.fillUserInfo(
+          this.updates,
+          (error, results) => {
+            this.results = results
+            return done()
+          }
+        )
+      })
+
+      it('should only call getUserInfo once for each user_id', function() {
+        this.WebApiManager.getUserInfo.calledTwice.should.equal(true)
+        this.WebApiManager.getUserInfo
+          .calledWith(this.user_id_1)
+          .should.equal(true)
+        return this.WebApiManager.getUserInfo
+          .calledWith(this.user_id_2)
+          .should.equal(true)
+      })
+
+      return it('should return the updates with the user info filled', function() {
+        return expect(this.results).to.deep.equal([
+          {
+            meta: {
+              user: {
+                email: 'user1@sharelatex.com'
+              }
+            },
+            op: 'mock-op-1'
+          },
+          {
+            meta: {
+              user: {
+                email: 'user1@sharelatex.com'
+              }
+            },
+            op: 'mock-op-2'
+          },
+          {
+            meta: {
+              user: {
+                email: 'user2@sharelatex.com'
+              }
+            },
+            op: 'mock-op-3'
+          }
+        ])
+      })
+    })
+
+    return describe('with invalid user ids', function() {
+      beforeEach(function(done) {
+        this.updates = [
+          {
+            meta: {
+              user_id: null
+            },
+            op: 'mock-op-1'
+          },
+          {
+            meta: {
+              user_id: 'anonymous-user'
+            },
+            op: 'mock-op-2'
+          }
+        ]
+        this.WebApiManager.getUserInfo = (user_id, callback) => {
+          if (callback == null) {
+            callback = function(error, userInfo) {}
+          }
+          return callback(null, this.user_info[user_id])
+        }
+        sinon.spy(this.WebApiManager, 'getUserInfo')
+
+        return this.UpdatesManager.fillUserInfo(
+          this.updates,
+          (error, results) => {
+            this.results = results
+            return done()
+          }
+        )
+      })
+
+      it('should not call getUserInfo', function() {
+        return this.WebApiManager.getUserInfo.called.should.equal(false)
+      })
+
+      return it('should return the updates without the user info filled', function() {
+        return expect(this.results).to.deep.equal([
+          {
+            meta: {},
+            op: 'mock-op-1'
+          },
+          {
+            meta: {},
+            op: 'mock-op-2'
+          }
+        ])
+      })
+    })
+  })
+
+  return describe('_summarizeUpdates', function() {
+    beforeEach(function() {
+      this.now = Date.now()
+      this.user_1 = { id: 'mock-user-1' }
+      return (this.user_2 = { id: 'mock-user-2' })
+    })
+
+    it('should concat updates that are close in time', function() {
+      const result = this.UpdatesManager._summarizeUpdates([
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: this.user_1.id,
+            start_ts: this.now + 20,
+            end_ts: this.now + 30
+          },
+          v: 5
+        },
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: this.user_2.id,
+            start_ts: this.now,
+            end_ts: this.now + 10
+          },
+          v: 4
+        }
+      ])
+
+      return expect(result).to.deep.equal([
+        {
+          docs: {
+            'doc-id-1': {
+              fromV: 4,
+              toV: 5
+            }
+          },
+          meta: {
+            user_ids: [this.user_1.id, this.user_2.id],
+            start_ts: this.now,
+            end_ts: this.now + 30
+          }
+        }
+      ])
+    })
+
+    it('should leave updates that are far apart in time', function() {
+      const oneDay = 1000 * 60 * 60 * 24
+      const result = this.UpdatesManager._summarizeUpdates([
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: this.user_2.id,
+            start_ts: this.now + oneDay,
+            end_ts: this.now + oneDay + 10
+          },
+          v: 5
+        },
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: this.user_1.id,
+            start_ts: this.now,
+            end_ts: this.now + 10
+          },
+          v: 4
+        }
+      ])
+      return expect(result).to.deep.equal([
+        {
+          docs: {
+            'doc-id-1': {
+              fromV: 5,
+              toV: 5
+            }
+          },
+          meta: {
+            user_ids: [this.user_2.id],
+            start_ts: this.now + oneDay,
+            end_ts: this.now + oneDay + 10
+          }
+        },
+        {
+          docs: {
+            'doc-id-1': {
+              fromV: 4,
+              toV: 4
+            }
+          },
+          meta: {
+            user_ids: [this.user_1.id],
+            start_ts: this.now,
+            end_ts: this.now + 10
+          }
+        }
+      ])
+    })
+
+    it('should concat onto existing summarized updates', function() {
+      const result = this.UpdatesManager._summarizeUpdates(
+        [
+          {
+            doc_id: 'doc-id-2',
+            meta: {
+              user_id: this.user_1.id,
+              start_ts: this.now + 20,
+              end_ts: this.now + 30
+            },
+            v: 5
+          },
+          {
+            doc_id: 'doc-id-2',
+            meta: {
+              user_id: this.user_2.id,
+              start_ts: this.now,
+              end_ts: this.now + 10
+            },
+            v: 4
+          }
+        ],
+        [
+          {
+            docs: {
+              'doc-id-1': {
+                fromV: 6,
+                toV: 8
+              }
+            },
+            meta: {
+              user_ids: [this.user_1.id],
+              start_ts: this.now + 40,
+              end_ts: this.now + 50
+            }
+          }
+        ]
+      )
+      return expect(result).to.deep.equal([
+        {
+          docs: {
+            'doc-id-1': {
+              toV: 8,
+              fromV: 6
+            },
+            'doc-id-2': {
+              toV: 5,
+              fromV: 4
+            }
+          },
+          meta: {
+            user_ids: [this.user_1.id, this.user_2.id],
+            start_ts: this.now,
+            end_ts: this.now + 50
+          }
+        }
+      ])
+    })
+
+    it('should include null user values', function() {
+      const result = this.UpdatesManager._summarizeUpdates([
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: this.user_1.id,
+            start_ts: this.now + 20,
+            end_ts: this.now + 30
+          },
+          v: 5
+        },
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: null,
+            start_ts: this.now,
+            end_ts: this.now + 10
+          },
+          v: 4
+        }
+      ])
+      return expect(result).to.deep.equal([
+        {
+          docs: {
+            'doc-id-1': {
+              fromV: 4,
+              toV: 5
+            }
+          },
+          meta: {
+            user_ids: [this.user_1.id, null],
+            start_ts: this.now,
+            end_ts: this.now + 30
+          }
+        }
+      ])
+    })
+
+    it('should include null user values, when the null is earlier in the updates list', function() {
+      const result = this.UpdatesManager._summarizeUpdates([
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: null,
+            start_ts: this.now,
+            end_ts: this.now + 10
+          },
+          v: 4
+        },
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: this.user_1.id,
+            start_ts: this.now + 20,
+            end_ts: this.now + 30
+          },
+          v: 5
+        }
+      ])
+      return expect(result).to.deep.equal([
+        {
+          docs: {
+            'doc-id-1': {
+              fromV: 4,
+              toV: 5
+            }
+          },
+          meta: {
+            user_ids: [null, this.user_1.id],
+            start_ts: this.now,
+            end_ts: this.now + 30
+          }
+        }
+      ])
+    })
+
+    it('should roll several null user values into one', function() {
+      const result = this.UpdatesManager._summarizeUpdates([
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: this.user_1.id,
+            start_ts: this.now + 20,
+            end_ts: this.now + 30
+          },
+          v: 5
+        },
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: null,
+            start_ts: this.now,
+            end_ts: this.now + 10
+          },
+          v: 4
+        },
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: null,
+            start_ts: this.now + 2,
+            end_ts: this.now + 4
+          },
+          v: 4
+        }
+      ])
+      return expect(result).to.deep.equal([
+        {
+          docs: {
+            'doc-id-1': {
+              fromV: 4,
+              toV: 5
+            }
+          },
+          meta: {
+            user_ids: [this.user_1.id, null],
+            start_ts: this.now,
+            end_ts: this.now + 30
+          }
+        }
+      ])
+    })
+
+    return it('should split updates before a big delete', function() {
+      const result = this.UpdatesManager._summarizeUpdates([
+        {
+          doc_id: 'doc-id-1',
+          op: [{ d: 'this is a long long long long long delete', p: 34 }],
+          meta: {
+            user_id: this.user_1.id,
+            start_ts: this.now + 20,
+            end_ts: this.now + 30
+          },
+          v: 5
+        },
+        {
+          doc_id: 'doc-id-1',
+          meta: {
+            user_id: this.user_2.id,
+            start_ts: this.now,
+            end_ts: this.now + 10
+          },
+          v: 4
+        }
+      ])
+
+      return expect(result).to.deep.equal([
+        {
+          docs: {
+            'doc-id-1': {
+              fromV: 5,
+              toV: 5
+            }
+          },
+          meta: {
+            user_ids: [this.user_1.id],
+            start_ts: this.now + 20,
+            end_ts: this.now + 30
+          }
+        },
+        {
+          docs: {
+            'doc-id-1': {
+              fromV: 4,
+              toV: 4
+            }
+          },
+          meta: {
+            user_ids: [this.user_2.id],
+            start_ts: this.now,
+            end_ts: this.now + 10
+          }
+        }
+      ])
+    })
+  })
+})

+ 203 - 159
services/track-changes/test/unit/js/WebApiManager/WebApiManagerTests.js

@@ -9,162 +9,206 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon');
-const chai = require('chai');
-const should = chai.should();
-const { expect } = chai;
-const modulePath = "../../../../app/js/WebApiManager.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe("WebApiManager", function() {
-	beforeEach(function() {
-		this.WebApiManager = SandboxedModule.require(modulePath, { requires: {
-			"requestretry": (this.request = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			'settings-sharelatex': (this.settings = {
-				apis: {
-					web: {
-						url: "http://example.com",
-						user: "sharelatex",
-						pass: "password"
-					}
-				}
-			})
-		}
-	}
-		);
-		this.callback = sinon.stub();
-		this.user_id = "mock-user-id";
-		this.project_id = "mock-project-id";
-		this.user_info = {
-			email: "leo@sharelatex.com",
-			id: this.user_id,
-			first_name: "Leo",
-			last_nane: "Lion",
-			extra_param: "blah"
-		};
-		return this.project =
-			{features: "mock-features"};
-	});
-
-	describe("getUserInfo", function() {
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.body = JSON.stringify(this.user_info);
-				this.request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, this.body);
-				return this.WebApiManager.getUserInfo(this.user_id, this.callback);
-			});
-
-			it('should get the user from the web api', function() {
-				return this.request.get
-					.calledWithMatch({
-						url: `${this.settings.apis.web.url}/user/${this.user_id}/personal_info`,
-						auth: {
-							user: this.settings.apis.web.user,
-							pass: this.settings.apis.web.pass,
-							sendImmediately: true
-						}
-					})
-					.should.equal(true);
-			});
-
-			return it("should call the callback with only the email, id and names", function() {
-				return this.callback.calledWith(null, {
-					id:         this.user_id,
-					email:      this.user_info.email,
-					first_name: this.user_info.first_name,
-					last_name:  this.user_info.last_name
-				}).should.equal(true);
-			});
-		});
-
-		describe("when the web API returns an error", function() {
-			beforeEach(function() {
-				this.request.get = sinon.stub().callsArgWith(1, (this.error = new Error("something went wrong")), null, null);
-				return this.WebApiManager.getUserInfo(this.user_id, this.callback);
-			});
-
-			return it("should return an error to the callback", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		describe("when the web returns a failure error code", function() {
-			beforeEach(function() {
-				this.request.get = sinon.stub().callsArgWith(1, null, { statusCode: 500, attempts: 42}, "");
-				return this.WebApiManager.getUserInfo(this.user_id, this.callback);
-			});
-
-			return it("should return the callback with an error", function() {
-				return this.callback
-					.calledWith(sinon.match.has('message', "web returned a non-success status code: 500 (attempts: 42)"))
-					.should.equal(true);
-			});
-		});
-
-		return describe("when the user cannot be found", function() {
-			beforeEach(function() {
-				this.request.get = sinon.stub().callsArgWith(1, null, {statusCode: 404}, "nothing");
-				return this.WebApiManager.getUserInfo(this.user_id, this.callback);
-			});
-
-			return it("should return a null value", function() {
-				return this.callback
-					.calledWith(null, null)
-					.should.equal(true);
-			});
-		});
-	});
-
-
-	return describe("getProjectDetails", function() {
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.body = JSON.stringify(this.project);
-				this.request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, this.body);
-				return this.WebApiManager.getProjectDetails(this.project_id, this.callback);
-			});
-
-			it('should get the project from the web api', function() {
-				return this.request.get
-					.calledWithMatch({
-						url: `${this.settings.apis.web.url}/project/${this.project_id}/details`,
-						auth: {
-							user: this.settings.apis.web.user,
-							pass: this.settings.apis.web.pass,
-							sendImmediately: true
-						}
-					})
-					.should.equal(true);
-			});
-
-			return it("should call the callback with the project", function() {
-				return this.callback.calledWith(null, this.project).should.equal(true);
-			});
-		});
-
-		describe("when the web API returns an error", function() {
-			beforeEach(function() {
-				this.request.get = sinon.stub().callsArgWith(1, (this.error = new Error("something went wrong")), null, null);
-				return this.WebApiManager.getProjectDetails(this.project_id, this.callback);
-			});
-
-			return it("should return an error to the callback", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		return describe("when the web returns a failure error code", function() {
-			beforeEach(function() {
-				this.request.get = sinon.stub().callsArgWith(1, null, { statusCode: 500, attempts: 42 }, "");
-				return this.WebApiManager.getProjectDetails(this.project_id, this.callback);
-			});
-
-			return it("should return the callback with an error", function() {
-				return this.callback
-					.calledWith(sinon.match.has('message', "web returned a non-success status code: 500 (attempts: 42)"))
-					.should.equal(true);
-			});
-		});
-	});
-});
+const sinon = require('sinon')
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const modulePath = '../../../../app/js/WebApiManager.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('WebApiManager', function() {
+  beforeEach(function() {
+    this.WebApiManager = SandboxedModule.require(modulePath, {
+      requires: {
+        requestretry: (this.request = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        'settings-sharelatex': (this.settings = {
+          apis: {
+            web: {
+              url: 'http://example.com',
+              user: 'sharelatex',
+              pass: 'password'
+            }
+          }
+        })
+      }
+    })
+    this.callback = sinon.stub()
+    this.user_id = 'mock-user-id'
+    this.project_id = 'mock-project-id'
+    this.user_info = {
+      email: 'leo@sharelatex.com',
+      id: this.user_id,
+      first_name: 'Leo',
+      last_nane: 'Lion',
+      extra_param: 'blah'
+    }
+    return (this.project = { features: 'mock-features' })
+  })
+
+  describe('getUserInfo', function() {
+    describe('successfully', function() {
+      beforeEach(function() {
+        this.body = JSON.stringify(this.user_info)
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 }, this.body)
+        return this.WebApiManager.getUserInfo(this.user_id, this.callback)
+      })
+
+      it('should get the user from the web api', function() {
+        return this.request.get
+          .calledWithMatch({
+            url: `${this.settings.apis.web.url}/user/${this.user_id}/personal_info`,
+            auth: {
+              user: this.settings.apis.web.user,
+              pass: this.settings.apis.web.pass,
+              sendImmediately: true
+            }
+          })
+          .should.equal(true)
+      })
+
+      return it('should call the callback with only the email, id and names', function() {
+        return this.callback
+          .calledWith(null, {
+            id: this.user_id,
+            email: this.user_info.email,
+            first_name: this.user_info.first_name,
+            last_name: this.user_info.last_name
+          })
+          .should.equal(true)
+      })
+    })
+
+    describe('when the web API returns an error', function() {
+      beforeEach(function() {
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(
+            1,
+            (this.error = new Error('something went wrong')),
+            null,
+            null
+          )
+        return this.WebApiManager.getUserInfo(this.user_id, this.callback)
+      })
+
+      return it('should return an error to the callback', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    describe('when the web returns a failure error code', function() {
+      beforeEach(function() {
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 500, attempts: 42 }, '')
+        return this.WebApiManager.getUserInfo(this.user_id, this.callback)
+      })
+
+      return it('should return the callback with an error', function() {
+        return this.callback
+          .calledWith(
+            sinon.match.has(
+              'message',
+              'web returned a non-success status code: 500 (attempts: 42)'
+            )
+          )
+          .should.equal(true)
+      })
+    })
+
+    return describe('when the user cannot be found', function() {
+      beforeEach(function() {
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 404 }, 'nothing')
+        return this.WebApiManager.getUserInfo(this.user_id, this.callback)
+      })
+
+      return it('should return a null value', function() {
+        return this.callback.calledWith(null, null).should.equal(true)
+      })
+    })
+  })
+
+  return describe('getProjectDetails', function() {
+    describe('successfully', function() {
+      beforeEach(function() {
+        this.body = JSON.stringify(this.project)
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 }, this.body)
+        return this.WebApiManager.getProjectDetails(
+          this.project_id,
+          this.callback
+        )
+      })
+
+      it('should get the project from the web api', function() {
+        return this.request.get
+          .calledWithMatch({
+            url: `${this.settings.apis.web.url}/project/${this.project_id}/details`,
+            auth: {
+              user: this.settings.apis.web.user,
+              pass: this.settings.apis.web.pass,
+              sendImmediately: true
+            }
+          })
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the project', function() {
+        return this.callback.calledWith(null, this.project).should.equal(true)
+      })
+    })
+
+    describe('when the web API returns an error', function() {
+      beforeEach(function() {
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(
+            1,
+            (this.error = new Error('something went wrong')),
+            null,
+            null
+          )
+        return this.WebApiManager.getProjectDetails(
+          this.project_id,
+          this.callback
+        )
+      })
+
+      return it('should return an error to the callback', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    return describe('when the web returns a failure error code', function() {
+      beforeEach(function() {
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 500, attempts: 42 }, '')
+        return this.WebApiManager.getProjectDetails(
+          this.project_id,
+          this.callback
+        )
+      })
+
+      return it('should return the callback with an error', function() {
+        return this.callback
+          .calledWith(
+            sinon.match.has(
+              'message',
+              'web returned a non-success status code: 500 (attempts: 42)'
+            )
+          )
+          .should.equal(true)
+      })
+    })
+  })
+})