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

prettier: convert test/unit decaffeinated files to Prettier format

decaffeinate 6 лет назад
Родитель
Сommit
3eceb8a5f6

+ 309 - 211
services/real-time/test/unit/js/AuthorizationManagerTests.js

@@ -9,214 +9,312 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-const chai = require("chai");
-chai.should();
-const {
-    expect
-} = chai;
-const sinon = require("sinon");
-const SandboxedModule = require('sandboxed-module');
-const path = require("path");
-const modulePath = '../../../app/js/AuthorizationManager';
-
-describe('AuthorizationManager', function() {
-	beforeEach(function() {
-		this.client =
-			{ol_context: {}};
-
-		return this.AuthorizationManager = SandboxedModule.require(modulePath, {requires: {}});});
-
-	describe("assertClientCanViewProject", function() {
-		it("should allow the readOnly privilegeLevel", function(done) {
-			this.client.ol_context.privilege_level = "readOnly";
-			return this.AuthorizationManager.assertClientCanViewProject(this.client, (error) => {
-				expect(error).to.be.null;
-				return done();
-			});
-		});
-
-		it("should allow the readAndWrite privilegeLevel", function(done) {
-			this.client.ol_context.privilege_level = "readAndWrite";
-			return this.AuthorizationManager.assertClientCanViewProject(this.client, (error) => {
-				expect(error).to.be.null;
-				return done();
-			});
-		});
-
-		it("should allow the owner privilegeLevel", function(done) {
-			this.client.ol_context.privilege_level = "owner";
-			return this.AuthorizationManager.assertClientCanViewProject(this.client, (error) => {
-				expect(error).to.be.null;
-				return done();
-			});
-		});
-
-		return it("should return an error with any other privilegeLevel", function(done) {
-			this.client.ol_context.privilege_level = "unknown";
-			return this.AuthorizationManager.assertClientCanViewProject(this.client, (error) => {
-				error.message.should.equal("not authorized");
-				return done();
-			});
-		});
-	});
-
-	describe("assertClientCanEditProject", function() {
-		it("should not allow the readOnly privilegeLevel", function(done) {
-			this.client.ol_context.privilege_level = "readOnly";
-			return this.AuthorizationManager.assertClientCanEditProject(this.client, (error) => {
-				error.message.should.equal("not authorized");
-				return done();
-			});
-		});
-
-		it("should allow the readAndWrite privilegeLevel", function(done) {
-			this.client.ol_context.privilege_level = "readAndWrite";
-			return this.AuthorizationManager.assertClientCanEditProject(this.client, (error) => {
-				expect(error).to.be.null;
-				return done();
-			});
-		});
-
-		it("should allow the owner privilegeLevel", function(done) {
-			this.client.ol_context.privilege_level = "owner";
-			return this.AuthorizationManager.assertClientCanEditProject(this.client, (error) => {
-				expect(error).to.be.null;
-				return done();
-			});
-		});
-
-		return it("should return an error with any other privilegeLevel", function(done) {
-			this.client.ol_context.privilege_level = "unknown";
-			return this.AuthorizationManager.assertClientCanEditProject(this.client, (error) => {
-				error.message.should.equal("not authorized");
-				return done();
-			});
-		});
-	});
-
-	// check doc access for project
-
-	describe("assertClientCanViewProjectAndDoc", function() {
-		beforeEach(function() {
-			this.doc_id = "12345";
-			this.callback = sinon.stub();
-			return this.client.ol_context = {};});
-
-		describe("when not authorised at the project level", function() {
-			beforeEach(function() {
-				return this.client.ol_context.privilege_level = "unknown";
-			});
-
-			it("should not allow access", function() {
-				return this.AuthorizationManager.assertClientCanViewProjectAndDoc(this.client, this.doc_id, err => err.message.should.equal("not authorized"));
-			});
-
-			return describe("even when authorised at the doc level", function() {
-				beforeEach(function(done) {
-					return this.AuthorizationManager.addAccessToDoc(this.client, this.doc_id, done);
-				});
-
-				return it("should not allow access", function() {
-					return this.AuthorizationManager.assertClientCanViewProjectAndDoc(this.client, this.doc_id, err => err.message.should.equal("not authorized"));
-				});
-			});
-		});
-
-		return describe("when authorised at the project level", function() {
-			beforeEach(function() {
-				return this.client.ol_context.privilege_level = "readOnly";
-			});
-
-			describe("and not authorised at the document level", function() { return it("should not allow access", function() {
-                return this.AuthorizationManager.assertClientCanViewProjectAndDoc(this.client, this.doc_id, err => err.message.should.equal("not authorized"));
-            }); });
-
-			describe("and authorised at the document level", function() {
-				beforeEach(function(done) {
-					return this.AuthorizationManager.addAccessToDoc(this.client, this.doc_id, done);
-				});
-
-				return it("should allow access", function() {
-					this.AuthorizationManager.assertClientCanViewProjectAndDoc(this.client, this.doc_id, this.callback);
-					return this.callback
-						.calledWith(null)
-						.should.equal(true);
-				});
-			});
-
-			return describe("when document authorisation is added and then removed", function() {
-				beforeEach(function(done) {
-					return this.AuthorizationManager.addAccessToDoc(this.client, this.doc_id, () => {
-						return this.AuthorizationManager.removeAccessToDoc(this.client, this.doc_id, done);
-					});
-				});
-
-				return it("should deny access", function() {
-					return this.AuthorizationManager.assertClientCanViewProjectAndDoc(this.client, this.doc_id, err => err.message.should.equal("not authorized"));
-				});
-			});
-		});
-	});
-
-	return describe("assertClientCanEditProjectAndDoc", function() {
-		beforeEach(function() {
-			this.doc_id = "12345";
-			this.callback = sinon.stub();
-			return this.client.ol_context = {};});
-
-		describe("when not authorised at the project level", function() {
-			beforeEach(function() {
-				return this.client.ol_context.privilege_level = "readOnly";
-			});
-
-			it("should not allow access", function() {
-				return this.AuthorizationManager.assertClientCanEditProjectAndDoc(this.client, this.doc_id, err => err.message.should.equal("not authorized"));
-			});
-
-			return describe("even when authorised at the doc level", function() {
-				beforeEach(function(done) {
-					return this.AuthorizationManager.addAccessToDoc(this.client, this.doc_id, done);
-				});
-
-				return it("should not allow access", function() {
-					return this.AuthorizationManager.assertClientCanEditProjectAndDoc(this.client, this.doc_id, err => err.message.should.equal("not authorized"));
-				});
-			});
-		});
-
-		return describe("when authorised at the project level", function() {
-			beforeEach(function() {
-				return this.client.ol_context.privilege_level = "readAndWrite";
-			});
-
-			describe("and not authorised at the document level", function() { return it("should not allow access", function() {
-                return this.AuthorizationManager.assertClientCanEditProjectAndDoc(this.client, this.doc_id, err => err.message.should.equal("not authorized"));
-            }); });
-
-			describe("and authorised at the document level", function() {
-				beforeEach(function(done) {
-					return this.AuthorizationManager.addAccessToDoc(this.client, this.doc_id, done);
-				});
-
-				return it("should allow access", function() {
-					this.AuthorizationManager.assertClientCanEditProjectAndDoc(this.client, this.doc_id, this.callback);
-					return this.callback
-						.calledWith(null)
-						.should.equal(true);
-				});
-			});
-
-			return describe("when document authorisation is added and then removed", function() {
-				beforeEach(function(done) {
-					return this.AuthorizationManager.addAccessToDoc(this.client, this.doc_id, () => {
-						return this.AuthorizationManager.removeAccessToDoc(this.client, this.doc_id, done);
-					});
-				});
-
-				return it("should deny access", function() {
-					return this.AuthorizationManager.assertClientCanEditProjectAndDoc(this.client, this.doc_id, err => err.message.should.equal("not authorized"));
-				});
-			});
-		});
-	});
-});
+const chai = require('chai')
+chai.should()
+const { expect } = chai
+const sinon = require('sinon')
+const SandboxedModule = require('sandboxed-module')
+const path = require('path')
+const modulePath = '../../../app/js/AuthorizationManager'
+
+describe('AuthorizationManager', function () {
+  beforeEach(function () {
+    this.client = { ol_context: {} }
+
+    return (this.AuthorizationManager = SandboxedModule.require(modulePath, {
+      requires: {}
+    }))
+  })
+
+  describe('assertClientCanViewProject', function () {
+    it('should allow the readOnly privilegeLevel', function (done) {
+      this.client.ol_context.privilege_level = 'readOnly'
+      return this.AuthorizationManager.assertClientCanViewProject(
+        this.client,
+        (error) => {
+          expect(error).to.be.null
+          return done()
+        }
+      )
+    })
+
+    it('should allow the readAndWrite privilegeLevel', function (done) {
+      this.client.ol_context.privilege_level = 'readAndWrite'
+      return this.AuthorizationManager.assertClientCanViewProject(
+        this.client,
+        (error) => {
+          expect(error).to.be.null
+          return done()
+        }
+      )
+    })
+
+    it('should allow the owner privilegeLevel', function (done) {
+      this.client.ol_context.privilege_level = 'owner'
+      return this.AuthorizationManager.assertClientCanViewProject(
+        this.client,
+        (error) => {
+          expect(error).to.be.null
+          return done()
+        }
+      )
+    })
+
+    return it('should return an error with any other privilegeLevel', function (done) {
+      this.client.ol_context.privilege_level = 'unknown'
+      return this.AuthorizationManager.assertClientCanViewProject(
+        this.client,
+        (error) => {
+          error.message.should.equal('not authorized')
+          return done()
+        }
+      )
+    })
+  })
+
+  describe('assertClientCanEditProject', function () {
+    it('should not allow the readOnly privilegeLevel', function (done) {
+      this.client.ol_context.privilege_level = 'readOnly'
+      return this.AuthorizationManager.assertClientCanEditProject(
+        this.client,
+        (error) => {
+          error.message.should.equal('not authorized')
+          return done()
+        }
+      )
+    })
+
+    it('should allow the readAndWrite privilegeLevel', function (done) {
+      this.client.ol_context.privilege_level = 'readAndWrite'
+      return this.AuthorizationManager.assertClientCanEditProject(
+        this.client,
+        (error) => {
+          expect(error).to.be.null
+          return done()
+        }
+      )
+    })
+
+    it('should allow the owner privilegeLevel', function (done) {
+      this.client.ol_context.privilege_level = 'owner'
+      return this.AuthorizationManager.assertClientCanEditProject(
+        this.client,
+        (error) => {
+          expect(error).to.be.null
+          return done()
+        }
+      )
+    })
+
+    return it('should return an error with any other privilegeLevel', function (done) {
+      this.client.ol_context.privilege_level = 'unknown'
+      return this.AuthorizationManager.assertClientCanEditProject(
+        this.client,
+        (error) => {
+          error.message.should.equal('not authorized')
+          return done()
+        }
+      )
+    })
+  })
+
+  // check doc access for project
+
+  describe('assertClientCanViewProjectAndDoc', function () {
+    beforeEach(function () {
+      this.doc_id = '12345'
+      this.callback = sinon.stub()
+      return (this.client.ol_context = {})
+    })
+
+    describe('when not authorised at the project level', function () {
+      beforeEach(function () {
+        return (this.client.ol_context.privilege_level = 'unknown')
+      })
+
+      it('should not allow access', function () {
+        return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
+          this.client,
+          this.doc_id,
+          (err) => err.message.should.equal('not authorized')
+        )
+      })
+
+      return describe('even when authorised at the doc level', function () {
+        beforeEach(function (done) {
+          return this.AuthorizationManager.addAccessToDoc(
+            this.client,
+            this.doc_id,
+            done
+          )
+        })
+
+        return it('should not allow access', function () {
+          return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
+            this.client,
+            this.doc_id,
+            (err) => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+
+    return describe('when authorised at the project level', function () {
+      beforeEach(function () {
+        return (this.client.ol_context.privilege_level = 'readOnly')
+      })
+
+      describe('and not authorised at the document level', function () {
+        return it('should not allow access', function () {
+          return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
+            this.client,
+            this.doc_id,
+            (err) => err.message.should.equal('not authorized')
+          )
+        })
+      })
+
+      describe('and authorised at the document level', function () {
+        beforeEach(function (done) {
+          return this.AuthorizationManager.addAccessToDoc(
+            this.client,
+            this.doc_id,
+            done
+          )
+        })
+
+        return it('should allow access', function () {
+          this.AuthorizationManager.assertClientCanViewProjectAndDoc(
+            this.client,
+            this.doc_id,
+            this.callback
+          )
+          return this.callback.calledWith(null).should.equal(true)
+        })
+      })
+
+      return describe('when document authorisation is added and then removed', function () {
+        beforeEach(function (done) {
+          return this.AuthorizationManager.addAccessToDoc(
+            this.client,
+            this.doc_id,
+            () => {
+              return this.AuthorizationManager.removeAccessToDoc(
+                this.client,
+                this.doc_id,
+                done
+              )
+            }
+          )
+        })
+
+        return it('should deny access', function () {
+          return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
+            this.client,
+            this.doc_id,
+            (err) => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+  })
+
+  return describe('assertClientCanEditProjectAndDoc', function () {
+    beforeEach(function () {
+      this.doc_id = '12345'
+      this.callback = sinon.stub()
+      return (this.client.ol_context = {})
+    })
+
+    describe('when not authorised at the project level', function () {
+      beforeEach(function () {
+        return (this.client.ol_context.privilege_level = 'readOnly')
+      })
+
+      it('should not allow access', function () {
+        return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
+          this.client,
+          this.doc_id,
+          (err) => err.message.should.equal('not authorized')
+        )
+      })
+
+      return describe('even when authorised at the doc level', function () {
+        beforeEach(function (done) {
+          return this.AuthorizationManager.addAccessToDoc(
+            this.client,
+            this.doc_id,
+            done
+          )
+        })
+
+        return it('should not allow access', function () {
+          return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
+            this.client,
+            this.doc_id,
+            (err) => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+
+    return describe('when authorised at the project level', function () {
+      beforeEach(function () {
+        return (this.client.ol_context.privilege_level = 'readAndWrite')
+      })
+
+      describe('and not authorised at the document level', function () {
+        return it('should not allow access', function () {
+          return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
+            this.client,
+            this.doc_id,
+            (err) => err.message.should.equal('not authorized')
+          )
+        })
+      })
+
+      describe('and authorised at the document level', function () {
+        beforeEach(function (done) {
+          return this.AuthorizationManager.addAccessToDoc(
+            this.client,
+            this.doc_id,
+            done
+          )
+        })
+
+        return it('should allow access', function () {
+          this.AuthorizationManager.assertClientCanEditProjectAndDoc(
+            this.client,
+            this.doc_id,
+            this.callback
+          )
+          return this.callback.calledWith(null).should.equal(true)
+        })
+      })
+
+      return describe('when document authorisation is added and then removed', function () {
+        beforeEach(function (done) {
+          return this.AuthorizationManager.addAccessToDoc(
+            this.client,
+            this.doc_id,
+            () => {
+              return this.AuthorizationManager.removeAccessToDoc(
+                this.client,
+                this.doc_id,
+                done
+              )
+            }
+          )
+        })
+
+        return it('should deny access', function () {
+          return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
+            this.client,
+            this.doc_id,
+            (err) => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+  })
+})

+ 427 - 269
services/real-time/test/unit/js/ChannelManagerTests.js

@@ -9,272 +9,430 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-const chai = require('chai');
-const should = chai.should();
-const {
-    expect
-} = chai;
-const sinon = require("sinon");
-const modulePath = "../../../app/js/ChannelManager.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe('ChannelManager', function() {
-	beforeEach(function() {
-		this.rclient = {};
-		this.other_rclient = {};
-		return this.ChannelManager = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex": (this.settings = {}),
-			"metrics-sharelatex": (this.metrics = {inc: sinon.stub(), summary: sinon.stub()}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), warn: sinon.stub(), error: sinon.stub() })
-		}
-	});});
-
-	describe("subscribe", function() {
-
-		describe("when there is no existing subscription for this redis client", function() {
-			beforeEach(function(done) {
-				this.rclient.subscribe = sinon.stub().resolves();
-				this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				return setTimeout(done);
-			});
-
-			return it("should subscribe to the redis channel", function() {
-				return this.rclient.subscribe.calledWithExactly("applied-ops:1234567890abcdef").should.equal(true);
-			});
-		});
-
-		describe("when there is an existing subscription for this redis client", function() {
-			beforeEach(function(done) {
-				this.rclient.subscribe = sinon.stub().resolves();
-				this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				return setTimeout(done);
-			});
-
-			return it("should subscribe to the redis channel again", function() {
-				return this.rclient.subscribe.callCount.should.equal(2);
-			});
-		});
-
-		describe("when subscribe errors", function() {
-			beforeEach(function(done) {
-				this.rclient.subscribe = sinon.stub()
-					.onFirstCall().rejects(new Error("some redis error"))
-					.onSecondCall().resolves();
-				const p = this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				p.then(() => done(new Error('should not subscribe but fail'))).catch(err => {
-					err.message.should.equal("some redis error");
-					this.ChannelManager.getClientMapEntry(this.rclient).has("applied-ops:1234567890abcdef").should.equal(false);
-					this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-					// subscribe is wrapped in Promise, delay other assertions
-					return setTimeout(done);
-				});
-				return null;
-			});
-
-			it("should have recorded the error", function() {
-				return expect(this.metrics.inc.calledWithExactly("subscribe.failed.applied-ops")).to.equal(true);
-			});
-
-			it("should subscribe again", function() {
-				return this.rclient.subscribe.callCount.should.equal(2);
-			});
-
-			return it("should cleanup", function() {
-				return this.ChannelManager.getClientMapEntry(this.rclient).has("applied-ops:1234567890abcdef").should.equal(false);
-			});
-		});
-
-		describe("when subscribe errors and the clientChannelMap entry was replaced", function() {
-			beforeEach(function(done) {
-				this.rclient.subscribe = sinon.stub()
-					.onFirstCall().rejects(new Error("some redis error"))
-					.onSecondCall().resolves();
-				this.first = this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				// ignore error
-				this.first.catch((() => {}));
-				expect(this.ChannelManager.getClientMapEntry(this.rclient).get("applied-ops:1234567890abcdef")).to.equal(this.first);
-
-				this.rclient.unsubscribe = sinon.stub().resolves();
-				this.ChannelManager.unsubscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				this.second = this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				// should get replaced immediately
-				expect(this.ChannelManager.getClientMapEntry(this.rclient).get("applied-ops:1234567890abcdef")).to.equal(this.second);
-
-				// let the first subscribe error -> unsubscribe -> subscribe
-				return setTimeout(done);
-			});
-
-			return it("should cleanup the second subscribePromise", function() {
-				return expect(this.ChannelManager.getClientMapEntry(this.rclient).has("applied-ops:1234567890abcdef")).to.equal(false);
-			});
-		});
-
-		return describe("when there is an existing subscription for another redis client but not this one", function() {
-			beforeEach(function(done) {
-				this.other_rclient.subscribe = sinon.stub().resolves();
-				this.ChannelManager.subscribe(this.other_rclient, "applied-ops", "1234567890abcdef");
-				this.rclient.subscribe = sinon.stub().resolves();  // discard the original stub
-				this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				return setTimeout(done);
-			});
-
-			return it("should subscribe to the redis channel on this redis client", function() {
-				return this.rclient.subscribe.calledWithExactly("applied-ops:1234567890abcdef").should.equal(true);
-			});
-		});
-	});
-
-	describe("unsubscribe", function() {
-
-		describe("when there is no existing subscription for this redis client", function() {
-			beforeEach(function(done) {
-				this.rclient.unsubscribe = sinon.stub().resolves();
-				this.ChannelManager.unsubscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				return setTimeout(done);
-			});
-
-			return it("should unsubscribe from the redis channel", function() {
-				return this.rclient.unsubscribe.called.should.equal(true);
-			});
-		});
-
-
-		describe("when there is an existing subscription for this another redis client but not this one", function() {
-			beforeEach(function(done) {
-				this.other_rclient.subscribe = sinon.stub().resolves();
-				this.rclient.unsubscribe = sinon.stub().resolves();
-				this.ChannelManager.subscribe(this.other_rclient, "applied-ops", "1234567890abcdef");
-				this.ChannelManager.unsubscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				return setTimeout(done);
-			});
-
-			return it("should still unsubscribe from the redis channel on this client", function() {
-				return this.rclient.unsubscribe.called.should.equal(true);
-			});
-		});
-
-		describe("when unsubscribe errors and completes", function() {
-			beforeEach(function(done) {
-				this.rclient.subscribe = sinon.stub().resolves();
-				this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				this.rclient.unsubscribe = sinon.stub().rejects(new Error("some redis error"));
-				this.ChannelManager.unsubscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				setTimeout(done);
-				return null;
-			});
-
-			it("should have cleaned up", function() {
-				return this.ChannelManager.getClientMapEntry(this.rclient).has("applied-ops:1234567890abcdef").should.equal(false);
-			});
-
-			return it("should not error out when subscribing again", function(done) {
-				const p = this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				p.then(() => done()).catch(done);
-				return null;
-			});
-		});
-
-		describe("when unsubscribe errors and another client subscribes at the same time", function() {
-			beforeEach(function(done) {
-				this.rclient.subscribe = sinon.stub().resolves();
-				this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				let rejectSubscribe;
-				this.rclient.unsubscribe = () => new Promise((resolve, reject) => rejectSubscribe = reject);
-				this.ChannelManager.unsubscribe(this.rclient, "applied-ops", "1234567890abcdef");
-
-				setTimeout(() => {
-					// delay, actualUnsubscribe should not see the new subscribe request
-					this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef")
-					.then(() => setTimeout(done)).catch(done);
-					return setTimeout(() => // delay, rejectSubscribe is not defined immediately
-                    rejectSubscribe(new Error("redis error")));
-				});
-				return null;
-			});
-
-			it("should have recorded the error", function() {
-				return expect(this.metrics.inc.calledWithExactly("unsubscribe.failed.applied-ops")).to.equal(true);
-			});
-
-			it("should have subscribed", function() {
-				return this.rclient.subscribe.called.should.equal(true);
-			});
-
-			return it("should have discarded the finished Promise", function() {
-				return this.ChannelManager.getClientMapEntry(this.rclient).has("applied-ops:1234567890abcdef").should.equal(false);
-			});
-		});
-
-		return describe("when there is an existing subscription for this redis client", function() {
-			beforeEach(function(done) {
-				this.rclient.subscribe = sinon.stub().resolves();
-				this.rclient.unsubscribe = sinon.stub().resolves();
-				this.ChannelManager.subscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				this.ChannelManager.unsubscribe(this.rclient, "applied-ops", "1234567890abcdef");
-				return setTimeout(done);
-			});
-
-			return it("should unsubscribe from the redis channel", function() {
-				return this.rclient.unsubscribe.calledWithExactly("applied-ops:1234567890abcdef").should.equal(true);
-			});
-		});
-	});
-
-	return describe("publish", function() {
-
-		describe("when the channel is 'all'", function() {
-			beforeEach(function() {
-				this.rclient.publish = sinon.stub();
-				return this.ChannelManager.publish(this.rclient, "applied-ops", "all", "random-message");
-			});
-
-			return it("should publish on the base channel", function() {
-				return this.rclient.publish.calledWithExactly("applied-ops", "random-message").should.equal(true);
-			});
-		});
-
-		describe("when the channel has an specific id", function() {
-
-			describe("when the individual channel setting is false", function() {
-				beforeEach(function() {
-					this.rclient.publish = sinon.stub();
-					this.settings.publishOnIndividualChannels = false;
-					return this.ChannelManager.publish(this.rclient, "applied-ops", "1234567890abcdef", "random-message");
-				});
-
-				return it("should publish on the per-id channel", function() {
-					this.rclient.publish.calledWithExactly("applied-ops", "random-message").should.equal(true);
-					return this.rclient.publish.calledOnce.should.equal(true);
-				});
-			});
-
-			return describe("when the individual channel setting is true", function() {
-				beforeEach(function() {
-					this.rclient.publish = sinon.stub();
-					this.settings.publishOnIndividualChannels = true;
-					return this.ChannelManager.publish(this.rclient, "applied-ops", "1234567890abcdef", "random-message");
-				});
-
-				return it("should publish on the per-id channel", function() {
-					this.rclient.publish.calledWithExactly("applied-ops:1234567890abcdef", "random-message").should.equal(true);
-					return this.rclient.publish.calledOnce.should.equal(true);
-				});
-			});
-		});
-
-		return describe("metrics", function() {
-			beforeEach(function() {
-				this.rclient.publish = sinon.stub();
-				return this.ChannelManager.publish(this.rclient, "applied-ops", "all", "random-message");
-			});
-
-			return it("should track the payload size", function() {
-				return this.metrics.summary.calledWithExactly(
-					"redis.publish.applied-ops",
-					"random-message".length
-				).should.equal(true);
-			});
-		});
-	});
-});
+const chai = require('chai')
+const should = chai.should()
+const { expect } = chai
+const sinon = require('sinon')
+const modulePath = '../../../app/js/ChannelManager.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('ChannelManager', function () {
+  beforeEach(function () {
+    this.rclient = {}
+    this.other_rclient = {}
+    return (this.ChannelManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': (this.settings = {}),
+        'metrics-sharelatex': (this.metrics = {
+          inc: sinon.stub(),
+          summary: sinon.stub()
+        }),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          warn: sinon.stub(),
+          error: sinon.stub()
+        })
+      }
+    }))
+  })
+
+  describe('subscribe', function () {
+    describe('when there is no existing subscription for this redis client', function () {
+      beforeEach(function (done) {
+        this.rclient.subscribe = sinon.stub().resolves()
+        this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        return setTimeout(done)
+      })
+
+      return it('should subscribe to the redis channel', function () {
+        return this.rclient.subscribe
+          .calledWithExactly('applied-ops:1234567890abcdef')
+          .should.equal(true)
+      })
+    })
+
+    describe('when there is an existing subscription for this redis client', function () {
+      beforeEach(function (done) {
+        this.rclient.subscribe = sinon.stub().resolves()
+        this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        return setTimeout(done)
+      })
+
+      return it('should subscribe to the redis channel again', function () {
+        return this.rclient.subscribe.callCount.should.equal(2)
+      })
+    })
+
+    describe('when subscribe errors', function () {
+      beforeEach(function (done) {
+        this.rclient.subscribe = sinon
+          .stub()
+          .onFirstCall()
+          .rejects(new Error('some redis error'))
+          .onSecondCall()
+          .resolves()
+        const p = this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        p.then(() => done(new Error('should not subscribe but fail'))).catch(
+          (err) => {
+            err.message.should.equal('some redis error')
+            this.ChannelManager.getClientMapEntry(this.rclient)
+              .has('applied-ops:1234567890abcdef')
+              .should.equal(false)
+            this.ChannelManager.subscribe(
+              this.rclient,
+              'applied-ops',
+              '1234567890abcdef'
+            )
+            // subscribe is wrapped in Promise, delay other assertions
+            return setTimeout(done)
+          }
+        )
+        return null
+      })
+
+      it('should have recorded the error', function () {
+        return expect(
+          this.metrics.inc.calledWithExactly('subscribe.failed.applied-ops')
+        ).to.equal(true)
+      })
+
+      it('should subscribe again', function () {
+        return this.rclient.subscribe.callCount.should.equal(2)
+      })
+
+      return it('should cleanup', function () {
+        return this.ChannelManager.getClientMapEntry(this.rclient)
+          .has('applied-ops:1234567890abcdef')
+          .should.equal(false)
+      })
+    })
+
+    describe('when subscribe errors and the clientChannelMap entry was replaced', function () {
+      beforeEach(function (done) {
+        this.rclient.subscribe = sinon
+          .stub()
+          .onFirstCall()
+          .rejects(new Error('some redis error'))
+          .onSecondCall()
+          .resolves()
+        this.first = this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        // ignore error
+        this.first.catch(() => {})
+        expect(
+          this.ChannelManager.getClientMapEntry(this.rclient).get(
+            'applied-ops:1234567890abcdef'
+          )
+        ).to.equal(this.first)
+
+        this.rclient.unsubscribe = sinon.stub().resolves()
+        this.ChannelManager.unsubscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        this.second = this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        // should get replaced immediately
+        expect(
+          this.ChannelManager.getClientMapEntry(this.rclient).get(
+            'applied-ops:1234567890abcdef'
+          )
+        ).to.equal(this.second)
+
+        // let the first subscribe error -> unsubscribe -> subscribe
+        return setTimeout(done)
+      })
+
+      return it('should cleanup the second subscribePromise', function () {
+        return expect(
+          this.ChannelManager.getClientMapEntry(this.rclient).has(
+            'applied-ops:1234567890abcdef'
+          )
+        ).to.equal(false)
+      })
+    })
+
+    return describe('when there is an existing subscription for another redis client but not this one', function () {
+      beforeEach(function (done) {
+        this.other_rclient.subscribe = sinon.stub().resolves()
+        this.ChannelManager.subscribe(
+          this.other_rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        this.rclient.subscribe = sinon.stub().resolves() // discard the original stub
+        this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        return setTimeout(done)
+      })
+
+      return it('should subscribe to the redis channel on this redis client', function () {
+        return this.rclient.subscribe
+          .calledWithExactly('applied-ops:1234567890abcdef')
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('unsubscribe', function () {
+    describe('when there is no existing subscription for this redis client', function () {
+      beforeEach(function (done) {
+        this.rclient.unsubscribe = sinon.stub().resolves()
+        this.ChannelManager.unsubscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        return setTimeout(done)
+      })
+
+      return it('should unsubscribe from the redis channel', function () {
+        return this.rclient.unsubscribe.called.should.equal(true)
+      })
+    })
+
+    describe('when there is an existing subscription for this another redis client but not this one', function () {
+      beforeEach(function (done) {
+        this.other_rclient.subscribe = sinon.stub().resolves()
+        this.rclient.unsubscribe = sinon.stub().resolves()
+        this.ChannelManager.subscribe(
+          this.other_rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        this.ChannelManager.unsubscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        return setTimeout(done)
+      })
+
+      return it('should still unsubscribe from the redis channel on this client', function () {
+        return this.rclient.unsubscribe.called.should.equal(true)
+      })
+    })
+
+    describe('when unsubscribe errors and completes', function () {
+      beforeEach(function (done) {
+        this.rclient.subscribe = sinon.stub().resolves()
+        this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        this.rclient.unsubscribe = sinon
+          .stub()
+          .rejects(new Error('some redis error'))
+        this.ChannelManager.unsubscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        setTimeout(done)
+        return null
+      })
+
+      it('should have cleaned up', function () {
+        return this.ChannelManager.getClientMapEntry(this.rclient)
+          .has('applied-ops:1234567890abcdef')
+          .should.equal(false)
+      })
+
+      return it('should not error out when subscribing again', function (done) {
+        const p = this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        p.then(() => done()).catch(done)
+        return null
+      })
+    })
+
+    describe('when unsubscribe errors and another client subscribes at the same time', function () {
+      beforeEach(function (done) {
+        this.rclient.subscribe = sinon.stub().resolves()
+        this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        let rejectSubscribe
+        this.rclient.unsubscribe = () =>
+          new Promise((resolve, reject) => (rejectSubscribe = reject))
+        this.ChannelManager.unsubscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+
+        setTimeout(() => {
+          // delay, actualUnsubscribe should not see the new subscribe request
+          this.ChannelManager.subscribe(
+            this.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+            .then(() => setTimeout(done))
+            .catch(done)
+          return setTimeout(() =>
+            // delay, rejectSubscribe is not defined immediately
+            rejectSubscribe(new Error('redis error'))
+          )
+        })
+        return null
+      })
+
+      it('should have recorded the error', function () {
+        return expect(
+          this.metrics.inc.calledWithExactly('unsubscribe.failed.applied-ops')
+        ).to.equal(true)
+      })
+
+      it('should have subscribed', function () {
+        return this.rclient.subscribe.called.should.equal(true)
+      })
+
+      return it('should have discarded the finished Promise', function () {
+        return this.ChannelManager.getClientMapEntry(this.rclient)
+          .has('applied-ops:1234567890abcdef')
+          .should.equal(false)
+      })
+    })
+
+    return describe('when there is an existing subscription for this redis client', function () {
+      beforeEach(function (done) {
+        this.rclient.subscribe = sinon.stub().resolves()
+        this.rclient.unsubscribe = sinon.stub().resolves()
+        this.ChannelManager.subscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        this.ChannelManager.unsubscribe(
+          this.rclient,
+          'applied-ops',
+          '1234567890abcdef'
+        )
+        return setTimeout(done)
+      })
+
+      return it('should unsubscribe from the redis channel', function () {
+        return this.rclient.unsubscribe
+          .calledWithExactly('applied-ops:1234567890abcdef')
+          .should.equal(true)
+      })
+    })
+  })
+
+  return describe('publish', function () {
+    describe("when the channel is 'all'", function () {
+      beforeEach(function () {
+        this.rclient.publish = sinon.stub()
+        return this.ChannelManager.publish(
+          this.rclient,
+          'applied-ops',
+          'all',
+          'random-message'
+        )
+      })
+
+      return it('should publish on the base channel', function () {
+        return this.rclient.publish
+          .calledWithExactly('applied-ops', 'random-message')
+          .should.equal(true)
+      })
+    })
+
+    describe('when the channel has an specific id', function () {
+      describe('when the individual channel setting is false', function () {
+        beforeEach(function () {
+          this.rclient.publish = sinon.stub()
+          this.settings.publishOnIndividualChannels = false
+          return this.ChannelManager.publish(
+            this.rclient,
+            'applied-ops',
+            '1234567890abcdef',
+            'random-message'
+          )
+        })
+
+        return it('should publish on the per-id channel', function () {
+          this.rclient.publish
+            .calledWithExactly('applied-ops', 'random-message')
+            .should.equal(true)
+          return this.rclient.publish.calledOnce.should.equal(true)
+        })
+      })
+
+      return describe('when the individual channel setting is true', function () {
+        beforeEach(function () {
+          this.rclient.publish = sinon.stub()
+          this.settings.publishOnIndividualChannels = true
+          return this.ChannelManager.publish(
+            this.rclient,
+            'applied-ops',
+            '1234567890abcdef',
+            'random-message'
+          )
+        })
+
+        return it('should publish on the per-id channel', function () {
+          this.rclient.publish
+            .calledWithExactly('applied-ops:1234567890abcdef', 'random-message')
+            .should.equal(true)
+          return this.rclient.publish.calledOnce.should.equal(true)
+        })
+      })
+    })
+
+    return describe('metrics', function () {
+      beforeEach(function () {
+        this.rclient.publish = sinon.stub()
+        return this.ChannelManager.publish(
+          this.rclient,
+          'applied-ops',
+          'all',
+          'random-message'
+        )
+      })
+
+      return it('should track the payload size', function () {
+        return this.metrics.summary
+          .calledWithExactly(
+            'redis.publish.applied-ops',
+            'random-message'.length
+          )
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 394 - 214
services/real-time/test/unit/js/ConnectedUsersManagerTests.js

@@ -12,218 +12,398 @@
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
 
 
-const should = require('chai').should();
-const SandboxedModule = require('sandboxed-module');
-const assert = require('assert');
-const path = require('path');
-const sinon = require('sinon');
-const modulePath = path.join(__dirname, "../../../app/js/ConnectedUsersManager");
-const {
-    expect
-} = require("chai");
-const tk = require("timekeeper");
-
-
-describe("ConnectedUsersManager", function() {
-
-	beforeEach(function() {
-
-		this.settings = {
-			redis: {
-				realtime: {
-					key_schema: {
-						clientsInProject({project_id}) { return `clients_in_project:${project_id}`; },
-						connectedUser({project_id, client_id}){ return `connected_user:${project_id}:${client_id}`; }
-					}
-				}
-			}
-		};
-		this.rClient = {
-			auth() {},
-			setex:sinon.stub(),
-			sadd:sinon.stub(),
-			get: sinon.stub(),
-			srem:sinon.stub(),
-			del:sinon.stub(),
-			smembers:sinon.stub(),
-			expire:sinon.stub(),
-			hset:sinon.stub(),
-			hgetall:sinon.stub(),
-			exec:sinon.stub(),
-			multi: () => { return this.rClient; }
-		};
-		tk.freeze(new Date());
-
-		this.ConnectedUsersManager = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex":this.settings,
-			"logger-sharelatex": { log() {}
-		},
-			"redis-sharelatex": { createClient:() => { 
-				return this.rClient;
-			}
-		}
-		}
-	}
-		);
-		this.client_id = "32132132";
-		this.project_id = "dskjh2u21321";
-		this.user = {
-			_id: "user-id-123",
-			first_name: "Joe",
-			last_name: "Bloggs",
-			email: "joe@example.com"
-		};
-		return this.cursorData = { row: 12, column: 9, doc_id: '53c3b8c85fee64000023dc6e' };});
-
-	afterEach(function() { return tk.reset(); });
-
-	describe("updateUserPosition", function() {
-		beforeEach(function() {
-			return this.rClient.exec.callsArgWith(0);
-		});
-
-		it("should set a key with the date and give it a ttl", function(done){
-			return this.ConnectedUsersManager.updateUserPosition(this.project_id, this.client_id, this.user, null, err=> {
-				this.rClient.hset.calledWith(`connected_user:${this.project_id}:${this.client_id}`, "last_updated_at", Date.now()).should.equal(true);
-				return done();
-			});
-		});
-
-		it("should set a key with the user_id", function(done){
-			return this.ConnectedUsersManager.updateUserPosition(this.project_id, this.client_id, this.user, null, err=> {
-				this.rClient.hset.calledWith(`connected_user:${this.project_id}:${this.client_id}`, "user_id", this.user._id).should.equal(true);
-				return done();
-			});
-		});
-
-		it("should set a key with the first_name", function(done){
-			return this.ConnectedUsersManager.updateUserPosition(this.project_id, this.client_id, this.user, null, err=> {
-				this.rClient.hset.calledWith(`connected_user:${this.project_id}:${this.client_id}`, "first_name", this.user.first_name).should.equal(true);
-				return done();
-			});
-		});
-
-		it("should set a key with the last_name", function(done){
-			return this.ConnectedUsersManager.updateUserPosition(this.project_id, this.client_id, this.user, null, err=> {
-				this.rClient.hset.calledWith(`connected_user:${this.project_id}:${this.client_id}`, "last_name", this.user.last_name).should.equal(true);
-				return done();
-			});
-		});
-
-		it("should set a key with the email", function(done){
-			return this.ConnectedUsersManager.updateUserPosition(this.project_id, this.client_id, this.user, null, err=> {
-				this.rClient.hset.calledWith(`connected_user:${this.project_id}:${this.client_id}`, "email", this.user.email).should.equal(true);
-				return done();
-			});
-		});
-
-		it("should push the client_id on to the project list", function(done){
-			return this.ConnectedUsersManager.updateUserPosition(this.project_id, this.client_id, this.user, null, err=> {
-				this.rClient.sadd.calledWith(`clients_in_project:${this.project_id}`, this.client_id).should.equal(true);
-				return done();
-			});
-		});
-
-		it("should add a ttl to the project set so it stays clean", function(done){
-			return this.ConnectedUsersManager.updateUserPosition(this.project_id, this.client_id, this.user, null, err=> {
-				this.rClient.expire.calledWith(`clients_in_project:${this.project_id}`, 24 * 4 * 60 * 60).should.equal(true);
-				return done();
-			});
-		});
-
-		it("should add a ttl to the connected user so it stays clean", function(done) {
-			return this.ConnectedUsersManager.updateUserPosition(this.project_id, this.client_id, this.user, null, err=> {
-				this.rClient.expire.calledWith(`connected_user:${this.project_id}:${this.client_id}`, 60 * 15).should.equal(true);
-				return done();
-			});
-		});
-
-		return it("should set the cursor position when provided", function(done){
-			return this.ConnectedUsersManager.updateUserPosition(this.project_id, this.client_id, this.user, this.cursorData, err=> {
-				this.rClient.hset.calledWith(`connected_user:${this.project_id}:${this.client_id}`, "cursorData", JSON.stringify(this.cursorData)).should.equal(true);
-				return done();
-			});
-		});
-	});
-
-	describe("markUserAsDisconnected", function() {
-		beforeEach(function() {
-			return this.rClient.exec.callsArgWith(0);
-		});
-
-		it("should remove the user from the set", function(done){
-			return this.ConnectedUsersManager.markUserAsDisconnected(this.project_id, this.client_id, err=> {
-				this.rClient.srem.calledWith(`clients_in_project:${this.project_id}`, this.client_id).should.equal(true);
-				return done();
-			});
-		});
-
-		it("should delete the connected_user string", function(done){
-			return this.ConnectedUsersManager.markUserAsDisconnected(this.project_id, this.client_id, err=> {
-				this.rClient.del.calledWith(`connected_user:${this.project_id}:${this.client_id}`).should.equal(true);
-				return done();
-			});
-		});
-
-		return it("should add a ttl to the connected user set so it stays clean", function(done){
-			return this.ConnectedUsersManager.markUserAsDisconnected(this.project_id, this.client_id, err=> {
-				this.rClient.expire.calledWith(`clients_in_project:${this.project_id}`, 24 * 4 * 60 * 60).should.equal(true);
-				return done();
-			});
-		});
-	});
-
-	describe("_getConnectedUser", function() {
-
-		it("should return a connected user if there is a user object", function(done){
-			const cursorData = JSON.stringify({cursorData:{row:1}});
-			this.rClient.hgetall.callsArgWith(1, null, {connected_at:new Date(), user_id: this.user._id, last_updated_at: `${Date.now()}`, cursorData});
-			return this.ConnectedUsersManager._getConnectedUser(this.project_id, this.client_id, (err, result)=> {
-				result.connected.should.equal(true);
-				result.client_id.should.equal(this.client_id);
-				return done();
-			});
-		});
-
-		it("should return a not connected user if there is no object", function(done){
-			this.rClient.hgetall.callsArgWith(1, null, null);
-			return this.ConnectedUsersManager._getConnectedUser(this.project_id, this.client_id, (err, result)=> {
-				result.connected.should.equal(false);
-				result.client_id.should.equal(this.client_id);
-				return done();
-			});
-		});
-
-		return it("should return a not connected user if there is an empty object", function(done){
-			this.rClient.hgetall.callsArgWith(1, null, {});
-			return this.ConnectedUsersManager._getConnectedUser(this.project_id, this.client_id, (err, result)=> {
-				result.connected.should.equal(false);
-				result.client_id.should.equal(this.client_id);
-				return done();
-			});
-		});
-	});
-
-	return describe("getConnectedUsers", function() {
-
-		beforeEach(function() {
-			this.users = ["1234", "5678", "9123", "8234"];
-			this.rClient.smembers.callsArgWith(1, null, this.users);
-			this.ConnectedUsersManager._getConnectedUser = sinon.stub();
-			this.ConnectedUsersManager._getConnectedUser.withArgs(this.project_id, this.users[0]).callsArgWith(2, null, {connected:true, client_age: 2, client_id:this.users[0]});
-			this.ConnectedUsersManager._getConnectedUser.withArgs(this.project_id, this.users[1]).callsArgWith(2, null, {connected:false, client_age: 1, client_id:this.users[1]});
-			this.ConnectedUsersManager._getConnectedUser.withArgs(this.project_id, this.users[2]).callsArgWith(2, null, {connected:true, client_age: 3, client_id:this.users[2]});
-			return this.ConnectedUsersManager._getConnectedUser.withArgs(this.project_id, this.users[3]).callsArgWith(2, null, {connected:true, client_age: 11, client_id:this.users[3]});
-		}); // connected but old
-
-		return it("should only return the users in the list which are still in redis and recently updated", function(done){
-			return this.ConnectedUsersManager.getConnectedUsers(this.project_id, (err, users)=> {
-				users.length.should.equal(2);
-				users[0].should.deep.equal({client_id:this.users[0], client_age: 2, connected:true});
-				users[1].should.deep.equal({client_id:this.users[2], client_age: 3, connected:true});
-				return done();
-			});
-		});
-	});
-});
+const should = require('chai').should()
+const SandboxedModule = require('sandboxed-module')
+const assert = require('assert')
+const path = require('path')
+const sinon = require('sinon')
+const modulePath = path.join(__dirname, '../../../app/js/ConnectedUsersManager')
+const { expect } = require('chai')
+const tk = require('timekeeper')
 
 
+describe('ConnectedUsersManager', function () {
+  beforeEach(function () {
+    this.settings = {
+      redis: {
+        realtime: {
+          key_schema: {
+            clientsInProject({ project_id }) {
+              return `clients_in_project:${project_id}`
+            },
+            connectedUser({ project_id, client_id }) {
+              return `connected_user:${project_id}:${client_id}`
+            }
+          }
+        }
+      }
+    }
+    this.rClient = {
+      auth() {},
+      setex: sinon.stub(),
+      sadd: sinon.stub(),
+      get: sinon.stub(),
+      srem: sinon.stub(),
+      del: sinon.stub(),
+      smembers: sinon.stub(),
+      expire: sinon.stub(),
+      hset: sinon.stub(),
+      hgetall: sinon.stub(),
+      exec: sinon.stub(),
+      multi: () => {
+        return this.rClient
+      }
+    }
+    tk.freeze(new Date())
+
+    this.ConnectedUsersManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': this.settings,
+        'logger-sharelatex': { log() {} },
+        'redis-sharelatex': {
+          createClient: () => {
+            return this.rClient
+          }
+        }
+      }
+    })
+    this.client_id = '32132132'
+    this.project_id = 'dskjh2u21321'
+    this.user = {
+      _id: 'user-id-123',
+      first_name: 'Joe',
+      last_name: 'Bloggs',
+      email: 'joe@example.com'
+    }
+    return (this.cursorData = {
+      row: 12,
+      column: 9,
+      doc_id: '53c3b8c85fee64000023dc6e'
+    })
+  })
+
+  afterEach(function () {
+    return tk.reset()
+  })
+
+  describe('updateUserPosition', function () {
+    beforeEach(function () {
+      return this.rClient.exec.callsArgWith(0)
+    })
+
+    it('should set a key with the date and give it a ttl', function (done) {
+      return this.ConnectedUsersManager.updateUserPosition(
+        this.project_id,
+        this.client_id,
+        this.user,
+        null,
+        (err) => {
+          this.rClient.hset
+            .calledWith(
+              `connected_user:${this.project_id}:${this.client_id}`,
+              'last_updated_at',
+              Date.now()
+            )
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should set a key with the user_id', function (done) {
+      return this.ConnectedUsersManager.updateUserPosition(
+        this.project_id,
+        this.client_id,
+        this.user,
+        null,
+        (err) => {
+          this.rClient.hset
+            .calledWith(
+              `connected_user:${this.project_id}:${this.client_id}`,
+              'user_id',
+              this.user._id
+            )
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should set a key with the first_name', function (done) {
+      return this.ConnectedUsersManager.updateUserPosition(
+        this.project_id,
+        this.client_id,
+        this.user,
+        null,
+        (err) => {
+          this.rClient.hset
+            .calledWith(
+              `connected_user:${this.project_id}:${this.client_id}`,
+              'first_name',
+              this.user.first_name
+            )
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should set a key with the last_name', function (done) {
+      return this.ConnectedUsersManager.updateUserPosition(
+        this.project_id,
+        this.client_id,
+        this.user,
+        null,
+        (err) => {
+          this.rClient.hset
+            .calledWith(
+              `connected_user:${this.project_id}:${this.client_id}`,
+              'last_name',
+              this.user.last_name
+            )
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should set a key with the email', function (done) {
+      return this.ConnectedUsersManager.updateUserPosition(
+        this.project_id,
+        this.client_id,
+        this.user,
+        null,
+        (err) => {
+          this.rClient.hset
+            .calledWith(
+              `connected_user:${this.project_id}:${this.client_id}`,
+              'email',
+              this.user.email
+            )
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should push the client_id on to the project list', function (done) {
+      return this.ConnectedUsersManager.updateUserPosition(
+        this.project_id,
+        this.client_id,
+        this.user,
+        null,
+        (err) => {
+          this.rClient.sadd
+            .calledWith(`clients_in_project:${this.project_id}`, this.client_id)
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should add a ttl to the project set so it stays clean', function (done) {
+      return this.ConnectedUsersManager.updateUserPosition(
+        this.project_id,
+        this.client_id,
+        this.user,
+        null,
+        (err) => {
+          this.rClient.expire
+            .calledWith(
+              `clients_in_project:${this.project_id}`,
+              24 * 4 * 60 * 60
+            )
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should add a ttl to the connected user so it stays clean', function (done) {
+      return this.ConnectedUsersManager.updateUserPosition(
+        this.project_id,
+        this.client_id,
+        this.user,
+        null,
+        (err) => {
+          this.rClient.expire
+            .calledWith(
+              `connected_user:${this.project_id}:${this.client_id}`,
+              60 * 15
+            )
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    return it('should set the cursor position when provided', function (done) {
+      return this.ConnectedUsersManager.updateUserPosition(
+        this.project_id,
+        this.client_id,
+        this.user,
+        this.cursorData,
+        (err) => {
+          this.rClient.hset
+            .calledWith(
+              `connected_user:${this.project_id}:${this.client_id}`,
+              'cursorData',
+              JSON.stringify(this.cursorData)
+            )
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+  })
+
+  describe('markUserAsDisconnected', function () {
+    beforeEach(function () {
+      return this.rClient.exec.callsArgWith(0)
+    })
+
+    it('should remove the user from the set', function (done) {
+      return this.ConnectedUsersManager.markUserAsDisconnected(
+        this.project_id,
+        this.client_id,
+        (err) => {
+          this.rClient.srem
+            .calledWith(`clients_in_project:${this.project_id}`, this.client_id)
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should delete the connected_user string', function (done) {
+      return this.ConnectedUsersManager.markUserAsDisconnected(
+        this.project_id,
+        this.client_id,
+        (err) => {
+          this.rClient.del
+            .calledWith(`connected_user:${this.project_id}:${this.client_id}`)
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    return it('should add a ttl to the connected user set so it stays clean', function (done) {
+      return this.ConnectedUsersManager.markUserAsDisconnected(
+        this.project_id,
+        this.client_id,
+        (err) => {
+          this.rClient.expire
+            .calledWith(
+              `clients_in_project:${this.project_id}`,
+              24 * 4 * 60 * 60
+            )
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+  })
+
+  describe('_getConnectedUser', function () {
+    it('should return a connected user if there is a user object', function (done) {
+      const cursorData = JSON.stringify({ cursorData: { row: 1 } })
+      this.rClient.hgetall.callsArgWith(1, null, {
+        connected_at: new Date(),
+        user_id: this.user._id,
+        last_updated_at: `${Date.now()}`,
+        cursorData
+      })
+      return this.ConnectedUsersManager._getConnectedUser(
+        this.project_id,
+        this.client_id,
+        (err, result) => {
+          result.connected.should.equal(true)
+          result.client_id.should.equal(this.client_id)
+          return done()
+        }
+      )
+    })
+
+    it('should return a not connected user if there is no object', function (done) {
+      this.rClient.hgetall.callsArgWith(1, null, null)
+      return this.ConnectedUsersManager._getConnectedUser(
+        this.project_id,
+        this.client_id,
+        (err, result) => {
+          result.connected.should.equal(false)
+          result.client_id.should.equal(this.client_id)
+          return done()
+        }
+      )
+    })
+
+    return it('should return a not connected user if there is an empty object', function (done) {
+      this.rClient.hgetall.callsArgWith(1, null, {})
+      return this.ConnectedUsersManager._getConnectedUser(
+        this.project_id,
+        this.client_id,
+        (err, result) => {
+          result.connected.should.equal(false)
+          result.client_id.should.equal(this.client_id)
+          return done()
+        }
+      )
+    })
+  })
+
+  return describe('getConnectedUsers', function () {
+    beforeEach(function () {
+      this.users = ['1234', '5678', '9123', '8234']
+      this.rClient.smembers.callsArgWith(1, null, this.users)
+      this.ConnectedUsersManager._getConnectedUser = sinon.stub()
+      this.ConnectedUsersManager._getConnectedUser
+        .withArgs(this.project_id, this.users[0])
+        .callsArgWith(2, null, {
+          connected: true,
+          client_age: 2,
+          client_id: this.users[0]
+        })
+      this.ConnectedUsersManager._getConnectedUser
+        .withArgs(this.project_id, this.users[1])
+        .callsArgWith(2, null, {
+          connected: false,
+          client_age: 1,
+          client_id: this.users[1]
+        })
+      this.ConnectedUsersManager._getConnectedUser
+        .withArgs(this.project_id, this.users[2])
+        .callsArgWith(2, null, {
+          connected: true,
+          client_age: 3,
+          client_id: this.users[2]
+        })
+      return this.ConnectedUsersManager._getConnectedUser
+        .withArgs(this.project_id, this.users[3])
+        .callsArgWith(2, null, {
+          connected: true,
+          client_age: 11,
+          client_id: this.users[3]
+        })
+    }) // connected but old
+
+    return it('should only return the users in the list which are still in redis and recently updated', function (done) {
+      return this.ConnectedUsersManager.getConnectedUsers(
+        this.project_id,
+        (err, users) => {
+          users.length.should.equal(2)
+          users[0].should.deep.equal({
+            client_id: this.users[0],
+            client_age: 2,
+            connected: true
+          })
+          users[1].should.deep.equal({
+            client_id: this.users[2],
+            client_age: 3,
+            connected: true
+          })
+          return done()
+        }
+      )
+    })
+  })
+})

+ 246 - 196
services/real-time/test/unit/js/DocumentUpdaterControllerTests.js

@@ -10,200 +10,250 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/DocumentUpdaterController');
-const MockClient = require("./helpers/MockClient");
-
-describe("DocumentUpdaterController", function() {
-	beforeEach(function() {
-		this.project_id = "project-id-123";
-		this.doc_id = "doc-id-123";
-		this.callback = sinon.stub();
-		this.io = { "mock": "socket.io" };
-		this.rclient = [];
-		this.RoomEvents = { on: sinon.stub() };
-		return this.EditorUpdatesController = SandboxedModule.require(modulePath, { requires: {
-			"logger-sharelatex": (this.logger = { error: sinon.stub(), log: sinon.stub(), warn: sinon.stub() }),
-			"settings-sharelatex": (this.settings = {
-				redis: {
-					documentupdater: {
-						key_schema: {
-							pendingUpdates({doc_id}) { return `PendingUpdates:${doc_id}`; }
-						}
-					},
-					pubsub: null
-				}
-			}),
-			"redis-sharelatex" : (this.redis = {
-				createClient: name => {
-					let rclientStub;
-					this.rclient.push(rclientStub = {name});
-					return rclientStub;
-				}
-			}),
-			"./SafeJsonParse": (this.SafeJsonParse =
-				{parse: (data, cb) => cb(null, JSON.parse(data))}),
-			"./EventLogger": (this.EventLogger = {checkEventOrder: sinon.stub()}),
-			"./HealthCheckManager": {check: sinon.stub()},
-			"metrics-sharelatex": (this.metrics = {inc: sinon.stub()}),
-			"./RoomManager" : (this.RoomManager = { eventSource: sinon.stub().returns(this.RoomEvents)}),
-			"./ChannelManager": (this.ChannelManager = {})
-		}
-	});});
-
-	describe("listenForUpdatesFromDocumentUpdater", function() {
-		beforeEach(function() {
-			this.rclient.length = 0;  // clear any existing clients
-			this.EditorUpdatesController.rclientList = [this.redis.createClient("first"), this.redis.createClient("second")];
-			this.rclient[0].subscribe = sinon.stub();
-			this.rclient[0].on = sinon.stub();
-			this.rclient[1].subscribe = sinon.stub();
-			this.rclient[1].on = sinon.stub();
-			return this.EditorUpdatesController.listenForUpdatesFromDocumentUpdater();
-		});
-		
-		it("should subscribe to the doc-updater stream", function() {
-			return this.rclient[0].subscribe.calledWith("applied-ops").should.equal(true);
-		});
-
-		it("should register a callback to handle updates", function() {
-			return this.rclient[0].on.calledWith("message").should.equal(true);
-		});
-
-		return it("should subscribe to any additional doc-updater stream", function() {
-			this.rclient[1].subscribe.calledWith("applied-ops").should.equal(true);
-			return this.rclient[1].on.calledWith("message").should.equal(true);
-		});
-	});
-
-	describe("_processMessageFromDocumentUpdater", function() {
-		describe("with bad JSON", function() {
-			beforeEach(function() {
-				this.SafeJsonParse.parse = sinon.stub().callsArgWith(1, new Error("oops"));
-				return this.EditorUpdatesController._processMessageFromDocumentUpdater(this.io, "applied-ops", "blah");
-			});
-			
-			return it("should log an error", function() {
-				return this.logger.error.called.should.equal(true);
-			});
-		});
-
-		describe("with update", function() {
-			beforeEach(function() {
-				this.message = {
-					doc_id: this.doc_id,
-					op: {t: "foo", p: 12}
-				};
-				this.EditorUpdatesController._applyUpdateFromDocumentUpdater = sinon.stub();
-				return this.EditorUpdatesController._processMessageFromDocumentUpdater(this.io, "applied-ops", JSON.stringify(this.message));
-			});
-
-			return it("should apply the update", function() {
-				return this.EditorUpdatesController._applyUpdateFromDocumentUpdater
-					.calledWith(this.io, this.doc_id, this.message.op)
-					.should.equal(true);
-			});
-		});
-
-		return describe("with error", function() {
-			beforeEach(function() {
-				this.message = {
-					doc_id: this.doc_id,
-					error: "Something went wrong"
-				};
-				this.EditorUpdatesController._processErrorFromDocumentUpdater = sinon.stub();
-				return this.EditorUpdatesController._processMessageFromDocumentUpdater(this.io, "applied-ops", JSON.stringify(this.message));
-			});
-
-			return it("should process the error", function() {
-				return this.EditorUpdatesController._processErrorFromDocumentUpdater
-					.calledWith(this.io, this.doc_id, this.message.error)
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("_applyUpdateFromDocumentUpdater", function() {
-		beforeEach(function() {
-			this.sourceClient = new MockClient();
-			this.otherClients = [new MockClient(), new MockClient()];
-			this.update = {
-				op: [ {t: "foo", p: 12} ],
-				meta: { source: this.sourceClient.publicId
-			},
-				v: (this.version = 42),
-				doc: this.doc_id
-			};
-			return this.io.sockets =
-				{clients: sinon.stub().returns([this.sourceClient, ...Array.from(this.otherClients), this.sourceClient])};
-		}); // include a duplicate client
-		
-		describe("normally", function() {
-			beforeEach(function() {
-				return this.EditorUpdatesController._applyUpdateFromDocumentUpdater(this.io, this.doc_id, this.update);
-			});
-
-			it("should send a version bump to the source client", function() {
-				this.sourceClient.emit
-					.calledWith("otUpdateApplied", {v: this.version, doc: this.doc_id})
-					.should.equal(true);
-				return this.sourceClient.emit.calledOnce.should.equal(true);
-			});
-
-			it("should get the clients connected to the document", function() {
-				return this.io.sockets.clients
-					.calledWith(this.doc_id)
-					.should.equal(true);
-			});
-
-			return it("should send the full update to the other clients", function() {
-				return Array.from(this.otherClients).map((client) =>
-					client.emit
-						.calledWith("otUpdateApplied", this.update)
-						.should.equal(true));
-			});
-		});
-		
-		return describe("with a duplicate op", function() {
-			beforeEach(function() {
-				this.update.dup = true;
-				return this.EditorUpdatesController._applyUpdateFromDocumentUpdater(this.io, this.doc_id, this.update);
-			});
-			
-			it("should send a version bump to the source client as usual", function() {
-				return this.sourceClient.emit
-					.calledWith("otUpdateApplied", {v: this.version, doc: this.doc_id})
-					.should.equal(true);
-			});
-
-			return it("should not send anything to the other clients (they've already had the op)", function() {
-				return Array.from(this.otherClients).map((client) =>
-					client.emit
-						.calledWith("otUpdateApplied")
-						.should.equal(false));
-			});
-		});
-	});
-
-	return describe("_processErrorFromDocumentUpdater", function() {
-		beforeEach(function() {
-			this.clients = [new MockClient(), new MockClient()];
-			this.io.sockets =
-				{clients: sinon.stub().returns(this.clients)};
-			return this.EditorUpdatesController._processErrorFromDocumentUpdater(this.io, this.doc_id, "Something went wrong");
-		});
-
-		it("should log a warning", function() {
-			return this.logger.warn.called.should.equal(true);
-		});
-
-		return it("should disconnect all clients in that document", function() {
-			this.io.sockets.clients.calledWith(this.doc_id).should.equal(true);
-			return Array.from(this.clients).map((client) =>
-				client.disconnect.called.should.equal(true));
-		});
-	});
-});
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/DocumentUpdaterController'
+)
+const MockClient = require('./helpers/MockClient')
 
 
+describe('DocumentUpdaterController', function () {
+  beforeEach(function () {
+    this.project_id = 'project-id-123'
+    this.doc_id = 'doc-id-123'
+    this.callback = sinon.stub()
+    this.io = { mock: 'socket.io' }
+    this.rclient = []
+    this.RoomEvents = { on: sinon.stub() }
+    return (this.EditorUpdatesController = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': (this.logger = {
+          error: sinon.stub(),
+          log: sinon.stub(),
+          warn: sinon.stub()
+        }),
+        'settings-sharelatex': (this.settings = {
+          redis: {
+            documentupdater: {
+              key_schema: {
+                pendingUpdates({ doc_id }) {
+                  return `PendingUpdates:${doc_id}`
+                }
+              }
+            },
+            pubsub: null
+          }
+        }),
+        'redis-sharelatex': (this.redis = {
+          createClient: (name) => {
+            let rclientStub
+            this.rclient.push((rclientStub = { name }))
+            return rclientStub
+          }
+        }),
+        './SafeJsonParse': (this.SafeJsonParse = {
+          parse: (data, cb) => cb(null, JSON.parse(data))
+        }),
+        './EventLogger': (this.EventLogger = { checkEventOrder: sinon.stub() }),
+        './HealthCheckManager': { check: sinon.stub() },
+        'metrics-sharelatex': (this.metrics = { inc: sinon.stub() }),
+        './RoomManager': (this.RoomManager = {
+          eventSource: sinon.stub().returns(this.RoomEvents)
+        }),
+        './ChannelManager': (this.ChannelManager = {})
+      }
+    }))
+  })
+
+  describe('listenForUpdatesFromDocumentUpdater', function () {
+    beforeEach(function () {
+      this.rclient.length = 0 // clear any existing clients
+      this.EditorUpdatesController.rclientList = [
+        this.redis.createClient('first'),
+        this.redis.createClient('second')
+      ]
+      this.rclient[0].subscribe = sinon.stub()
+      this.rclient[0].on = sinon.stub()
+      this.rclient[1].subscribe = sinon.stub()
+      this.rclient[1].on = sinon.stub()
+      return this.EditorUpdatesController.listenForUpdatesFromDocumentUpdater()
+    })
+
+    it('should subscribe to the doc-updater stream', function () {
+      return this.rclient[0].subscribe
+        .calledWith('applied-ops')
+        .should.equal(true)
+    })
+
+    it('should register a callback to handle updates', function () {
+      return this.rclient[0].on.calledWith('message').should.equal(true)
+    })
+
+    return it('should subscribe to any additional doc-updater stream', function () {
+      this.rclient[1].subscribe.calledWith('applied-ops').should.equal(true)
+      return this.rclient[1].on.calledWith('message').should.equal(true)
+    })
+  })
+
+  describe('_processMessageFromDocumentUpdater', function () {
+    describe('with bad JSON', function () {
+      beforeEach(function () {
+        this.SafeJsonParse.parse = sinon
+          .stub()
+          .callsArgWith(1, new Error('oops'))
+        return this.EditorUpdatesController._processMessageFromDocumentUpdater(
+          this.io,
+          'applied-ops',
+          'blah'
+        )
+      })
+
+      return it('should log an error', function () {
+        return this.logger.error.called.should.equal(true)
+      })
+    })
+
+    describe('with update', function () {
+      beforeEach(function () {
+        this.message = {
+          doc_id: this.doc_id,
+          op: { t: 'foo', p: 12 }
+        }
+        this.EditorUpdatesController._applyUpdateFromDocumentUpdater = sinon.stub()
+        return this.EditorUpdatesController._processMessageFromDocumentUpdater(
+          this.io,
+          'applied-ops',
+          JSON.stringify(this.message)
+        )
+      })
+
+      return it('should apply the update', function () {
+        return this.EditorUpdatesController._applyUpdateFromDocumentUpdater
+          .calledWith(this.io, this.doc_id, this.message.op)
+          .should.equal(true)
+      })
+    })
+
+    return describe('with error', function () {
+      beforeEach(function () {
+        this.message = {
+          doc_id: this.doc_id,
+          error: 'Something went wrong'
+        }
+        this.EditorUpdatesController._processErrorFromDocumentUpdater = sinon.stub()
+        return this.EditorUpdatesController._processMessageFromDocumentUpdater(
+          this.io,
+          'applied-ops',
+          JSON.stringify(this.message)
+        )
+      })
+
+      return it('should process the error', function () {
+        return this.EditorUpdatesController._processErrorFromDocumentUpdater
+          .calledWith(this.io, this.doc_id, this.message.error)
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('_applyUpdateFromDocumentUpdater', function () {
+    beforeEach(function () {
+      this.sourceClient = new MockClient()
+      this.otherClients = [new MockClient(), new MockClient()]
+      this.update = {
+        op: [{ t: 'foo', p: 12 }],
+        meta: { source: this.sourceClient.publicId },
+        v: (this.version = 42),
+        doc: this.doc_id
+      }
+      return (this.io.sockets = {
+        clients: sinon
+          .stub()
+          .returns([
+            this.sourceClient,
+            ...Array.from(this.otherClients),
+            this.sourceClient
+          ])
+      })
+    }) // include a duplicate client
+
+    describe('normally', function () {
+      beforeEach(function () {
+        return this.EditorUpdatesController._applyUpdateFromDocumentUpdater(
+          this.io,
+          this.doc_id,
+          this.update
+        )
+      })
+
+      it('should send a version bump to the source client', function () {
+        this.sourceClient.emit
+          .calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id })
+          .should.equal(true)
+        return this.sourceClient.emit.calledOnce.should.equal(true)
+      })
+
+      it('should get the clients connected to the document', function () {
+        return this.io.sockets.clients
+          .calledWith(this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('should send the full update to the other clients', function () {
+        return Array.from(this.otherClients).map((client) =>
+          client.emit
+            .calledWith('otUpdateApplied', this.update)
+            .should.equal(true)
+        )
+      })
+    })
+
+    return describe('with a duplicate op', function () {
+      beforeEach(function () {
+        this.update.dup = true
+        return this.EditorUpdatesController._applyUpdateFromDocumentUpdater(
+          this.io,
+          this.doc_id,
+          this.update
+        )
+      })
+
+      it('should send a version bump to the source client as usual', function () {
+        return this.sourceClient.emit
+          .calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id })
+          .should.equal(true)
+      })
+
+      return it("should not send anything to the other clients (they've already had the op)", function () {
+        return Array.from(this.otherClients).map((client) =>
+          client.emit.calledWith('otUpdateApplied').should.equal(false)
+        )
+      })
+    })
+  })
+
+  return describe('_processErrorFromDocumentUpdater', function () {
+    beforeEach(function () {
+      this.clients = [new MockClient(), new MockClient()]
+      this.io.sockets = { clients: sinon.stub().returns(this.clients) }
+      return this.EditorUpdatesController._processErrorFromDocumentUpdater(
+        this.io,
+        this.doc_id,
+        'Something went wrong'
+      )
+    })
+
+    it('should log a warning', function () {
+      return this.logger.warn.called.should.equal(true)
+    })
+
+    return it('should disconnect all clients in that document', function () {
+      this.io.sockets.clients.calledWith(this.doc_id).should.equal(true)
+      return Array.from(this.clients).map((client) =>
+        client.disconnect.called.should.equal(true)
+      )
+    })
+  })
+})

+ 369 - 255
services/real-time/test/unit/js/DocumentUpdaterManagerTests.js

@@ -10,258 +10,372 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-require('chai').should();
-const sinon = require("sinon");
-const SandboxedModule = require('sandboxed-module');
-const path = require("path");
-const modulePath = '../../../app/js/DocumentUpdaterManager';
-
-describe('DocumentUpdaterManager', function() {
-	beforeEach(function() {
-		let Timer;
-		this.project_id = "project-id-923";
-		this.doc_id = "doc-id-394";
-		this.lines = ["one", "two", "three"];
-		this.version = 42;
-		this.settings = {
-			apis: { documentupdater: {url: "http://doc-updater.example.com"}
-		},
-			redis: { documentupdater: {
-				key_schema: {
-					pendingUpdates({doc_id}) { return `PendingUpdates:${doc_id}`; }
-				}
-			}
-		},
-			maxUpdateSize: 7 * 1024 * 1024
-		};
-		this.rclient = {auth() {}};
-
-		return this.DocumentUpdaterManager = SandboxedModule.require(modulePath, {
-			requires: {
-				'settings-sharelatex':this.settings,
-				'logger-sharelatex': (this.logger = {log: sinon.stub(), error: sinon.stub(), warn: sinon.stub()}),
-				'request': (this.request = {}),
-				'redis-sharelatex' : { createClient: () => this.rclient
-			},
-				'metrics-sharelatex': (this.Metrics = {
-					summary: sinon.stub(),
-					Timer: (Timer = class Timer {
-						done() {}
-					})
-				})
-			},
-			globals: {
-				JSON: (this.JSON = Object.create(JSON))
-			}
-		}
-		);
-	}); // avoid modifying JSON object directly
-
-	describe("getDocument", function() {
-		beforeEach(function() {
-			return this.callback = sinon.stub();
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.body = JSON.stringify({
-					lines: this.lines,
-					version: this.version,
-					ops: (this.ops = ["mock-op-1", "mock-op-2"]),
-					ranges: (this.ranges = {"mock": "ranges"})});
-				this.fromVersion = 2;
-				this.request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, this.body);
-				return this.DocumentUpdaterManager.getDocument(this.project_id, this.doc_id, this.fromVersion, 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}?fromVersion=${this.fromVersion}`;
-				return this.request.get.calledWith(url).should.equal(true);
-			});
-
-			return it("should call the callback with the lines, version, ranges and ops", function() {
-				return this.callback.calledWith(null, this.lines, this.version, this.ranges, this.ops).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.fromVersion, this.callback);
-			});
-
-			return it("should return an error to the callback", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		[404, 422].forEach(statusCode => describe(`when the document updater returns a ${statusCode} status code`, function() {
-            beforeEach(function() {
-                this.request.get = sinon.stub().callsArgWith(1, null, { statusCode }, "");
-                return this.DocumentUpdaterManager.getDocument(this.project_id, this.doc_id, this.fromVersion, this.callback);
-            });
-
-            return it("should return the callback with an error", function() {
-                this.callback.called.should.equal(true);
-                const err = this.callback.getCall(0).args[0];
-                err.should.have.property('statusCode', statusCode);
-                err.should.have.property('message', "doc updater could not load requested ops");
-                this.logger.error.called.should.equal(false);
-                return this.logger.warn.called.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.fromVersion, this.callback);
-			});
-
-			return it("should return the callback with an error", function() {
-				this.callback.called.should.equal(true);
-				const err = this.callback.getCall(0).args[0];
-				err.should.have.property('statusCode', 500);
-				err.should.have.property('message', "doc updater returned a non-success status code: 500");
-				return this.logger.error.called.should.equal(true);
-			});
-		});
-	});
-
-	describe('flushProjectToMongoAndDelete', function() {
-		beforeEach(function() {
-			return this.callback = sinon.stub();
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.request.del = sinon.stub().callsArgWith(1, null, {statusCode: 204}, "");
-				return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(this.project_id, this.callback);
-			});
-
-			it('should delete the project from the document updater', function() {
-				const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}?background=true`;
-				return this.request.del.calledWith(url).should.equal(true);
-			});
-
-			return it("should call the callback with no error", function() {
-				return this.callback.calledWith(null).should.equal(true);
-			});
-		});
-
-		describe("when the document updater API returns an error", function() {
-			beforeEach(function() {
-				this.request.del = sinon.stub().callsArgWith(1, (this.error = new Error("something went wrong")), null, null);
-				return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(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 document updater returns a failure error code", function() {
-			beforeEach(function() {
-				this.request.del = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "");
-				return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(this.project_id, this.callback);
-			});
-
-			return it("should return the callback with an error", function() {
-				this.callback.called.should.equal(true);
-				const err = this.callback.getCall(0).args[0];
-				err.should.have.property('statusCode', 500);
-				return err.should.have.property('message', "document updater returned a failure status code: 500");
-			});
-		});
-	});
-
-	return describe('queueChange', function() {
-		beforeEach(function() {
-			this.change = {
-				"doc":"1234567890",
-				"op":[{"d":"test", "p":345}],
-				"v": 789
-			};
-			this.rclient.rpush = sinon.stub().yields();
-			return this.callback = sinon.stub();
-		});
-
-		describe("successfully", function() {
-			beforeEach(function() {
-				return this.DocumentUpdaterManager.queueChange(this.project_id, this.doc_id, this.change, this.callback);
-			});
-
-			it("should push the change", function() {
-				return this.rclient.rpush
-					.calledWith(`PendingUpdates:${this.doc_id}`, JSON.stringify(this.change))
-					.should.equal(true);
-			});
-
-			return it("should notify the doc updater of the change via the pending-updates-list queue", function() {
-				return this.rclient.rpush
-					.calledWith("pending-updates-list", `${this.project_id}:${this.doc_id}`)
-					.should.equal(true);
-			});
-		});
-
-		describe("with error talking to redis during rpush", function() {
-			beforeEach(function() {
-				this.rclient.rpush = sinon.stub().yields(new Error("something went wrong"));
-				return this.DocumentUpdaterManager.queueChange(this.project_id, this.doc_id, this.change, this.callback);
-			});
-
-			return it("should return an error", function() {
-				return this.callback.calledWithExactly(sinon.match(Error)).should.equal(true);
-			});
-		});
-
-		describe("with null byte corruption", function() {
-			beforeEach(function() {
-				this.JSON.stringify = () => '["bad bytes! \u0000 <- here"]';
-				return this.DocumentUpdaterManager.queueChange(this.project_id, this.doc_id, this.change, this.callback);
-			});
-
-			it("should return an error", function() {
-				return this.callback.calledWithExactly(sinon.match(Error)).should.equal(true);
-			});
-
-			return it("should not push the change onto the pending-updates-list queue", function() {
-				return this.rclient.rpush.called.should.equal(false);
-			});
-		});
-
-		describe("when the update is too large", function() {
-			beforeEach(function() {
-				this.change = {op: {p: 12,t: "update is too large".repeat(1024 * 400)}};
-				return this.DocumentUpdaterManager.queueChange(this.project_id, this.doc_id, this.change, this.callback);
-			});
-
-			it("should return an error", function() {
-				return this.callback.calledWithExactly(sinon.match(Error)).should.equal(true);
-			});
-
-			it("should add the size to the error", function() {
-				return this.callback.args[0][0].updateSize.should.equal(7782422);
-			});
-
-			return it("should not push the change onto the pending-updates-list queue", function() {
-				return this.rclient.rpush.called.should.equal(false);
-			});
-		});
-
-		return describe("with invalid keys", function() {
-			beforeEach(function() {
-				this.change = {
-					"op":[{"d":"test", "p":345}],
-					"version": 789 // not a valid key
-				};
-				return this.DocumentUpdaterManager.queueChange(this.project_id, this.doc_id, this.change, this.callback);
-			});
-
-			return it("should remove the invalid keys from the change", function() {
-				return this.rclient.rpush
-					.calledWith(`PendingUpdates:${this.doc_id}`, JSON.stringify({op:this.change.op}))
-					.should.equal(true);
-			});
-		});
-	});
-});
+require('chai').should()
+const sinon = require('sinon')
+const SandboxedModule = require('sandboxed-module')
+const path = require('path')
+const modulePath = '../../../app/js/DocumentUpdaterManager'
+
+describe('DocumentUpdaterManager', function () {
+  beforeEach(function () {
+    let Timer
+    this.project_id = 'project-id-923'
+    this.doc_id = 'doc-id-394'
+    this.lines = ['one', 'two', 'three']
+    this.version = 42
+    this.settings = {
+      apis: { documentupdater: { url: 'http://doc-updater.example.com' } },
+      redis: {
+        documentupdater: {
+          key_schema: {
+            pendingUpdates({ doc_id }) {
+              return `PendingUpdates:${doc_id}`
+            }
+          }
+        }
+      },
+      maxUpdateSize: 7 * 1024 * 1024
+    }
+    this.rclient = { auth() {} }
+
+    return (this.DocumentUpdaterManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': this.settings,
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub(),
+          warn: sinon.stub()
+        }),
+        request: (this.request = {}),
+        'redis-sharelatex': { createClient: () => this.rclient },
+        'metrics-sharelatex': (this.Metrics = {
+          summary: sinon.stub(),
+          Timer: (Timer = class Timer {
+            done() {}
+          })
+        })
+      },
+      globals: {
+        JSON: (this.JSON = Object.create(JSON))
+      }
+    }))
+  }) // avoid modifying JSON object directly
+
+  describe('getDocument', function () {
+    beforeEach(function () {
+      return (this.callback = sinon.stub())
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.body = JSON.stringify({
+          lines: this.lines,
+          version: this.version,
+          ops: (this.ops = ['mock-op-1', 'mock-op-2']),
+          ranges: (this.ranges = { mock: 'ranges' })
+        })
+        this.fromVersion = 2
+        this.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 }, this.body)
+        return this.DocumentUpdaterManager.getDocument(
+          this.project_id,
+          this.doc_id,
+          this.fromVersion,
+          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}?fromVersion=${this.fromVersion}`
+        return this.request.get.calledWith(url).should.equal(true)
+      })
+
+      return it('should call the callback with the lines, version, ranges and ops', function () {
+        return this.callback
+          .calledWith(null, this.lines, this.version, this.ranges, this.ops)
+          .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.fromVersion,
+          this.callback
+        )
+      })
+
+      return it('should return an error to the callback', function () {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+    ;[404, 422].forEach((statusCode) =>
+      describe(`when the document updater returns a ${statusCode} status code`, function () {
+        beforeEach(function () {
+          this.request.get = sinon
+            .stub()
+            .callsArgWith(1, null, { statusCode }, '')
+          return this.DocumentUpdaterManager.getDocument(
+            this.project_id,
+            this.doc_id,
+            this.fromVersion,
+            this.callback
+          )
+        })
+
+        return it('should return the callback with an error', function () {
+          this.callback.called.should.equal(true)
+          const err = this.callback.getCall(0).args[0]
+          err.should.have.property('statusCode', statusCode)
+          err.should.have.property(
+            'message',
+            'doc updater could not load requested ops'
+          )
+          this.logger.error.called.should.equal(false)
+          return this.logger.warn.called.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.fromVersion,
+          this.callback
+        )
+      })
+
+      return it('should return the callback with an error', function () {
+        this.callback.called.should.equal(true)
+        const err = this.callback.getCall(0).args[0]
+        err.should.have.property('statusCode', 500)
+        err.should.have.property(
+          'message',
+          'doc updater returned a non-success status code: 500'
+        )
+        return this.logger.error.called.should.equal(true)
+      })
+    })
+  })
+
+  describe('flushProjectToMongoAndDelete', function () {
+    beforeEach(function () {
+      return (this.callback = sinon.stub())
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.request.del = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 204 }, '')
+        return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(
+          this.project_id,
+          this.callback
+        )
+      })
+
+      it('should delete the project from the document updater', function () {
+        const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}?background=true`
+        return this.request.del.calledWith(url).should.equal(true)
+      })
+
+      return it('should call the callback with no error', function () {
+        return this.callback.calledWith(null).should.equal(true)
+      })
+    })
+
+    describe('when the document updater API returns an error', function () {
+      beforeEach(function () {
+        this.request.del = sinon
+          .stub()
+          .callsArgWith(
+            1,
+            (this.error = new Error('something went wrong')),
+            null,
+            null
+          )
+        return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(
+          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 document updater returns a failure error code', function () {
+      beforeEach(function () {
+        this.request.del = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 500 }, '')
+        return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(
+          this.project_id,
+          this.callback
+        )
+      })
+
+      return it('should return the callback with an error', function () {
+        this.callback.called.should.equal(true)
+        const err = this.callback.getCall(0).args[0]
+        err.should.have.property('statusCode', 500)
+        return err.should.have.property(
+          'message',
+          'document updater returned a failure status code: 500'
+        )
+      })
+    })
+  })
+
+  return describe('queueChange', function () {
+    beforeEach(function () {
+      this.change = {
+        doc: '1234567890',
+        op: [{ d: 'test', p: 345 }],
+        v: 789
+      }
+      this.rclient.rpush = sinon.stub().yields()
+      return (this.callback = sinon.stub())
+    })
+
+    describe('successfully', function () {
+      beforeEach(function () {
+        return this.DocumentUpdaterManager.queueChange(
+          this.project_id,
+          this.doc_id,
+          this.change,
+          this.callback
+        )
+      })
+
+      it('should push the change', function () {
+        return this.rclient.rpush
+          .calledWith(
+            `PendingUpdates:${this.doc_id}`,
+            JSON.stringify(this.change)
+          )
+          .should.equal(true)
+      })
+
+      return it('should notify the doc updater of the change via the pending-updates-list queue', function () {
+        return this.rclient.rpush
+          .calledWith(
+            'pending-updates-list',
+            `${this.project_id}:${this.doc_id}`
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('with error talking to redis during rpush', function () {
+      beforeEach(function () {
+        this.rclient.rpush = sinon
+          .stub()
+          .yields(new Error('something went wrong'))
+        return this.DocumentUpdaterManager.queueChange(
+          this.project_id,
+          this.doc_id,
+          this.change,
+          this.callback
+        )
+      })
+
+      return it('should return an error', function () {
+        return this.callback
+          .calledWithExactly(sinon.match(Error))
+          .should.equal(true)
+      })
+    })
+
+    describe('with null byte corruption', function () {
+      beforeEach(function () {
+        this.JSON.stringify = () => '["bad bytes! \u0000 <- here"]'
+        return this.DocumentUpdaterManager.queueChange(
+          this.project_id,
+          this.doc_id,
+          this.change,
+          this.callback
+        )
+      })
+
+      it('should return an error', function () {
+        return this.callback
+          .calledWithExactly(sinon.match(Error))
+          .should.equal(true)
+      })
+
+      return it('should not push the change onto the pending-updates-list queue', function () {
+        return this.rclient.rpush.called.should.equal(false)
+      })
+    })
+
+    describe('when the update is too large', function () {
+      beforeEach(function () {
+        this.change = {
+          op: { p: 12, t: 'update is too large'.repeat(1024 * 400) }
+        }
+        return this.DocumentUpdaterManager.queueChange(
+          this.project_id,
+          this.doc_id,
+          this.change,
+          this.callback
+        )
+      })
+
+      it('should return an error', function () {
+        return this.callback
+          .calledWithExactly(sinon.match(Error))
+          .should.equal(true)
+      })
+
+      it('should add the size to the error', function () {
+        return this.callback.args[0][0].updateSize.should.equal(7782422)
+      })
+
+      return it('should not push the change onto the pending-updates-list queue', function () {
+        return this.rclient.rpush.called.should.equal(false)
+      })
+    })
+
+    return describe('with invalid keys', function () {
+      beforeEach(function () {
+        this.change = {
+          op: [{ d: 'test', p: 345 }],
+          version: 789 // not a valid key
+        }
+        return this.DocumentUpdaterManager.queueChange(
+          this.project_id,
+          this.doc_id,
+          this.change,
+          this.callback
+        )
+      })
+
+      return it('should remove the invalid keys from the change', function () {
+        return this.rclient.rpush
+          .calledWith(
+            `PendingUpdates:${this.doc_id}`,
+            JSON.stringify({ op: this.change.op })
+          )
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 115 - 102
services/real-time/test/unit/js/DrainManagerTests.js

@@ -9,111 +9,124 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-const should = require('chai').should();
-const sinon = require("sinon");
-const SandboxedModule = require('sandboxed-module');
-const path = require("path");
-const modulePath = path.join(__dirname, "../../../app/js/DrainManager");
+const should = require('chai').should()
+const sinon = require('sinon')
+const SandboxedModule = require('sandboxed-module')
+const path = require('path')
+const modulePath = path.join(__dirname, '../../../app/js/DrainManager')
 
 
-describe("DrainManager", function() {
-	beforeEach(function() {
-		this.DrainManager = SandboxedModule.require(modulePath, { requires: {
-			"logger-sharelatex": (this.logger = {log: sinon.stub()})
-		}
-	}
-		);
-		return this.io = {
-			sockets: {
-				clients: sinon.stub()
-			}
-		};
-	});
+describe('DrainManager', function () {
+  beforeEach(function () {
+    this.DrainManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': (this.logger = { log: sinon.stub() })
+      }
+    })
+    return (this.io = {
+      sockets: {
+        clients: sinon.stub()
+      }
+    })
+  })
 
 
-	describe("startDrainTimeWindow", function() {
-		beforeEach(function() {
-			this.clients = [];
-			for (let i = 0; i <= 5399; i++) {
-				this.clients[i] = {
-					id: i,
-					emit: sinon.stub()
-				};
-			}
-			this.io.sockets.clients.returns(this.clients);
-			return this.DrainManager.startDrain = sinon.stub();
-		});
+  describe('startDrainTimeWindow', function () {
+    beforeEach(function () {
+      this.clients = []
+      for (let i = 0; i <= 5399; i++) {
+        this.clients[i] = {
+          id: i,
+          emit: sinon.stub()
+        }
+      }
+      this.io.sockets.clients.returns(this.clients)
+      return (this.DrainManager.startDrain = sinon.stub())
+    })
 
 
-		return it("should set a drain rate fast enough", function(done){
-			this.DrainManager.startDrainTimeWindow(this.io, 9);
-			this.DrainManager.startDrain.calledWith(this.io, 10).should.equal(true);
-			return done();
-		});
-	});
+    return it('should set a drain rate fast enough', function (done) {
+      this.DrainManager.startDrainTimeWindow(this.io, 9)
+      this.DrainManager.startDrain.calledWith(this.io, 10).should.equal(true)
+      return done()
+    })
+  })
 
 
+  return describe('reconnectNClients', function () {
+    beforeEach(function () {
+      this.clients = []
+      for (let i = 0; i <= 9; i++) {
+        this.clients[i] = {
+          id: i,
+          emit: sinon.stub()
+        }
+      }
+      return this.io.sockets.clients.returns(this.clients)
+    })
 
 
-	return describe("reconnectNClients", function() {
-		beforeEach(function() {
-			this.clients = [];
-			for (let i = 0; i <= 9; i++) {
-				this.clients[i] = {
-					id: i,
-					emit: sinon.stub()
-				};
-			}
-			return this.io.sockets.clients.returns(this.clients);
-		});
+    return describe('after first pass', function () {
+      beforeEach(function () {
+        return this.DrainManager.reconnectNClients(this.io, 3)
+      })
 
 
-		return describe("after first pass", function() {
-			beforeEach(function() {
-				return this.DrainManager.reconnectNClients(this.io, 3);
-			});
-			
-			it("should reconnect the first 3 clients", function() {
-				return [0, 1, 2].map((i) =>
-					this.clients[i].emit.calledWith("reconnectGracefully").should.equal(true));
-			});
-			
-			it("should not reconnect any more clients", function() {
-				return [3, 4, 5, 6, 7, 8, 9].map((i) =>
-					this.clients[i].emit.calledWith("reconnectGracefully").should.equal(false));
-			});
-			
-			return describe("after second pass", function() {
-				beforeEach(function() {
-					return this.DrainManager.reconnectNClients(this.io, 3);
-				});
-				
-				it("should reconnect the next 3 clients", function() {
-					return [3, 4, 5].map((i) =>
-						this.clients[i].emit.calledWith("reconnectGracefully").should.equal(true));
-				});
-				
-				it("should not reconnect any more clients", function() {
-					return [6, 7, 8, 9].map((i) =>
-						this.clients[i].emit.calledWith("reconnectGracefully").should.equal(false));
-				});
-				
-				it("should not reconnect the first 3 clients again", function() {
-					return [0, 1, 2].map((i) =>
-						this.clients[i].emit.calledOnce.should.equal(true));
-				});
-				
-				return describe("after final pass", function() {
-					beforeEach(function() {
-						return this.DrainManager.reconnectNClients(this.io, 100);
-					});
-				
-					it("should not reconnect the first 6 clients again", function() {
-						return [0, 1, 2, 3, 4, 5].map((i) =>
-							this.clients[i].emit.calledOnce.should.equal(true));
-					});
-					
-					return it("should log out that it reached the end", function() {
-						return this.logger.log
-							.calledWith("All clients have been told to reconnectGracefully")
-							.should.equal(true);
-					});
-				});
-			});
-		});
-	});
-});
+      it('should reconnect the first 3 clients', function () {
+        return [0, 1, 2].map((i) =>
+          this.clients[i].emit
+            .calledWith('reconnectGracefully')
+            .should.equal(true)
+        )
+      })
+
+      it('should not reconnect any more clients', function () {
+        return [3, 4, 5, 6, 7, 8, 9].map((i) =>
+          this.clients[i].emit
+            .calledWith('reconnectGracefully')
+            .should.equal(false)
+        )
+      })
+
+      return describe('after second pass', function () {
+        beforeEach(function () {
+          return this.DrainManager.reconnectNClients(this.io, 3)
+        })
+
+        it('should reconnect the next 3 clients', function () {
+          return [3, 4, 5].map((i) =>
+            this.clients[i].emit
+              .calledWith('reconnectGracefully')
+              .should.equal(true)
+          )
+        })
+
+        it('should not reconnect any more clients', function () {
+          return [6, 7, 8, 9].map((i) =>
+            this.clients[i].emit
+              .calledWith('reconnectGracefully')
+              .should.equal(false)
+          )
+        })
+
+        it('should not reconnect the first 3 clients again', function () {
+          return [0, 1, 2].map((i) =>
+            this.clients[i].emit.calledOnce.should.equal(true)
+          )
+        })
+
+        return describe('after final pass', function () {
+          beforeEach(function () {
+            return this.DrainManager.reconnectNClients(this.io, 100)
+          })
+
+          it('should not reconnect the first 6 clients again', function () {
+            return [0, 1, 2, 3, 4, 5].map((i) =>
+              this.clients[i].emit.calledOnce.should.equal(true)
+            )
+          })
+
+          return it('should log out that it reached the end', function () {
+            return this.logger.log
+              .calledWith('All clients have been told to reconnectGracefully')
+              .should.equal(true)
+          })
+        })
+      })
+    })
+  })
+})

+ 135 - 84
services/real-time/test/unit/js/EventLoggerTests.js

@@ -8,99 +8,150 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-require('chai').should();
-const {
-    expect
-} = require("chai");
-const SandboxedModule = require('sandboxed-module');
-const modulePath = '../../../app/js/EventLogger';
-const sinon = require("sinon");
-const tk = require("timekeeper");
+require('chai').should()
+const { expect } = require('chai')
+const SandboxedModule = require('sandboxed-module')
+const modulePath = '../../../app/js/EventLogger'
+const sinon = require('sinon')
+const tk = require('timekeeper')
 
 
-describe('EventLogger', function() {
-	beforeEach(function() {
-		this.start = Date.now();
-		tk.freeze(new Date(this.start));
-		this.EventLogger = SandboxedModule.require(modulePath, { requires: {
-			"logger-sharelatex": (this.logger = {error: sinon.stub(), warn: sinon.stub()}),
-			"metrics-sharelatex": (this.metrics = {inc: sinon.stub()})
-		}
-	});
-		this.channel = "applied-ops";
-		this.id_1 = "random-hostname:abc-1";
-		this.message_1 = "message-1";
-		this.id_2 = "random-hostname:abc-2";
-		return this.message_2 = "message-2";
-	});
+describe('EventLogger', function () {
+  beforeEach(function () {
+    this.start = Date.now()
+    tk.freeze(new Date(this.start))
+    this.EventLogger = SandboxedModule.require(modulePath, {
+      requires: {
+        'logger-sharelatex': (this.logger = {
+          error: sinon.stub(),
+          warn: sinon.stub()
+        }),
+        'metrics-sharelatex': (this.metrics = { inc: sinon.stub() })
+      }
+    })
+    this.channel = 'applied-ops'
+    this.id_1 = 'random-hostname:abc-1'
+    this.message_1 = 'message-1'
+    this.id_2 = 'random-hostname:abc-2'
+    return (this.message_2 = 'message-2')
+  })
 
 
-	afterEach(function() { return tk.reset(); });
+  afterEach(function () {
+    return tk.reset()
+  })
 
 
-	return describe('checkEventOrder', function() {
+  return describe('checkEventOrder', function () {
+    describe('when the events are in order', function () {
+      beforeEach(function () {
+        this.EventLogger.checkEventOrder(
+          this.channel,
+          this.id_1,
+          this.message_1
+        )
+        return (this.status = this.EventLogger.checkEventOrder(
+          this.channel,
+          this.id_2,
+          this.message_2
+        ))
+      })
 
 
-		describe('when the events are in order', function() {
-			beforeEach(function() {
-				this.EventLogger.checkEventOrder(this.channel, this.id_1, this.message_1);
-				return this.status = this.EventLogger.checkEventOrder(this.channel, this.id_2, this.message_2);
-			});
+      it('should accept events in order', function () {
+        return expect(this.status).to.be.undefined
+      })
 
 
-			it('should accept events in order', function() {
-				return expect(this.status).to.be.undefined;
-			});
+      return it('should increment the valid event metric', function () {
+        return this.metrics.inc.calledWith(`event.${this.channel}.valid`, 1)
+          .should.equal.true
+      })
+    })
 
 
-			return it('should increment the valid event metric', function() {
-				return this.metrics.inc.calledWith(`event.${this.channel}.valid`, 1)
-					.should.equal.true;
-			});
-		});
+    describe('when there is a duplicate events', function () {
+      beforeEach(function () {
+        this.EventLogger.checkEventOrder(
+          this.channel,
+          this.id_1,
+          this.message_1
+        )
+        return (this.status = this.EventLogger.checkEventOrder(
+          this.channel,
+          this.id_1,
+          this.message_1
+        ))
+      })
 
 
-		describe('when there is a duplicate events', function() {
-			beforeEach(function() {
-				this.EventLogger.checkEventOrder(this.channel, this.id_1, this.message_1);
-				return this.status = this.EventLogger.checkEventOrder(this.channel, this.id_1, this.message_1);
-			});
+      it('should return "duplicate" for the same event', function () {
+        return expect(this.status).to.equal('duplicate')
+      })
 
 
-			it('should return "duplicate" for the same event', function() {
-				return expect(this.status).to.equal("duplicate");
-			});
+      return it('should increment the duplicate event metric', function () {
+        return this.metrics.inc.calledWith(`event.${this.channel}.duplicate`, 1)
+          .should.equal.true
+      })
+    })
 
 
-			return it('should increment the duplicate event metric', function() {
-				return this.metrics.inc.calledWith(`event.${this.channel}.duplicate`, 1)
-					.should.equal.true;
-			});
-		});
+    describe('when there are out of order events', function () {
+      beforeEach(function () {
+        this.EventLogger.checkEventOrder(
+          this.channel,
+          this.id_1,
+          this.message_1
+        )
+        this.EventLogger.checkEventOrder(
+          this.channel,
+          this.id_2,
+          this.message_2
+        )
+        return (this.status = this.EventLogger.checkEventOrder(
+          this.channel,
+          this.id_1,
+          this.message_1
+        ))
+      })
 
 
-		describe('when there are out of order events', function() {
-			beforeEach(function() {
-				this.EventLogger.checkEventOrder(this.channel, this.id_1, this.message_1);
-				this.EventLogger.checkEventOrder(this.channel, this.id_2, this.message_2);
-				return this.status = this.EventLogger.checkEventOrder(this.channel, this.id_1, this.message_1);
-			});
+      it('should return "out-of-order" for the event', function () {
+        return expect(this.status).to.equal('out-of-order')
+      })
 
 
-			it('should return "out-of-order" for the event', function() {
-				return expect(this.status).to.equal("out-of-order");
-			});
+      return it('should increment the out-of-order event metric', function () {
+        return this.metrics.inc.calledWith(
+          `event.${this.channel}.out-of-order`,
+          1
+        ).should.equal.true
+      })
+    })
 
 
-			return it('should increment the out-of-order event metric', function() {
-				return this.metrics.inc.calledWith(`event.${this.channel}.out-of-order`, 1)
-					.should.equal.true;
-			});
-		});
-
-		return describe('after MAX_STALE_TIME_IN_MS', function() { return it('should flush old entries', function() {
-            let status;
-            this.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10;
-            this.EventLogger.checkEventOrder(this.channel, this.id_1, this.message_1);
-            for (let i = 1; i <= 8; i++) {
-                status = this.EventLogger.checkEventOrder(this.channel, this.id_1, this.message_1);
-                expect(status).to.equal("duplicate");
-            }
-            // the next event should flush the old entries aboce
-            this.EventLogger.MAX_STALE_TIME_IN_MS=1000;
-            tk.freeze(new Date(this.start + (5 * 1000)));
-            // because we flushed the entries this should not be a duplicate
-            this.EventLogger.checkEventOrder(this.channel, 'other-1', this.message_2);
-            status = this.EventLogger.checkEventOrder(this.channel, this.id_1, this.message_1);
-            return expect(status).to.be.undefined;
-        }); });
-	});
-});
+    return describe('after MAX_STALE_TIME_IN_MS', function () {
+      return it('should flush old entries', function () {
+        let status
+        this.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10
+        this.EventLogger.checkEventOrder(
+          this.channel,
+          this.id_1,
+          this.message_1
+        )
+        for (let i = 1; i <= 8; i++) {
+          status = this.EventLogger.checkEventOrder(
+            this.channel,
+            this.id_1,
+            this.message_1
+          )
+          expect(status).to.equal('duplicate')
+        }
+        // the next event should flush the old entries aboce
+        this.EventLogger.MAX_STALE_TIME_IN_MS = 1000
+        tk.freeze(new Date(this.start + 5 * 1000))
+        // because we flushed the entries this should not be a duplicate
+        this.EventLogger.checkEventOrder(
+          this.channel,
+          'other-1',
+          this.message_2
+        )
+        status = this.EventLogger.checkEventOrder(
+          this.channel,
+          this.id_1,
+          this.message_1
+        )
+        return expect(status).to.be.undefined
+      })
+    })
+  })
+})

+ 407 - 354
services/real-time/test/unit/js/RoomManagerTests.js

@@ -10,357 +10,410 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-const chai = require('chai');
-const {
-    expect
-} = chai;
-const should = chai.should();
-const sinon = require("sinon");
-const modulePath = "../../../app/js/RoomManager.js";
-const SandboxedModule = require('sandboxed-module');
-
-describe('RoomManager', function() {
-	beforeEach(function() {
-		this.project_id = "project-id-123";
-		this.doc_id = "doc-id-456";
-		this.other_doc_id = "doc-id-789";
-		this.client = {namespace: {name: ''}, id: "first-client"};
-		this.RoomManager = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex": (this.settings = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), warn: sinon.stub(), error: sinon.stub() }),
-			"metrics-sharelatex": (this.metrics = { gauge: sinon.stub() })
-		}
-	});
-		this.RoomManager._clientsInRoom = sinon.stub();
-		this.RoomManager._clientAlreadyInRoom = sinon.stub();
-		this.RoomEvents = this.RoomManager.eventSource();
-		sinon.spy(this.RoomEvents, 'emit');
-		return sinon.spy(this.RoomEvents, 'once');
-	});
-	
-	describe("emitOnCompletion", function() { return describe("when a subscribe errors", function() {
-        afterEach(function() {
-            return process.removeListener("unhandledRejection", this.onUnhandled);
-        });
-
-        beforeEach(function(done) {
-            this.onUnhandled = error => {
-                this.unhandledError = error;
-                return done(new Error(`unhandledRejection: ${error.message}`));
-            };
-            process.on("unhandledRejection", this.onUnhandled);
-
-            let reject;
-            const subscribePromise = new Promise((_, r) => reject = r);
-            const promises = [subscribePromise];
-            const eventName = "project-subscribed-123";
-            this.RoomEvents.once(eventName, () => setTimeout(done, 100));
-            this.RoomManager.emitOnCompletion(promises, eventName);
-            return setTimeout(() => reject(new Error("subscribe failed")));
-        });
-
-        return it("should keep going", function() {
-            return expect(this.unhandledError).to.not.exist;
-        });
-    }); });
-
-	describe("joinProject", function() {
-	
-		describe("when the project room is empty", function() {
-
-			beforeEach(function(done) {
-				this.RoomManager._clientsInRoom
-					.withArgs(this.client, this.project_id)
-					.onFirstCall().returns(0);
-				this.client.join = sinon.stub();
-				this.callback = sinon.stub();
-				this.RoomEvents.on('project-active', id => {
-					return setTimeout(() => {
-						return this.RoomEvents.emit(`project-subscribed-${id}`);
-					}
-					, 100);
-				});
-				return this.RoomManager.joinProject(this.client, this.project_id, err => {
-					this.callback(err);
-					return done();
-				});
-			});
-
-			it("should emit a 'project-active' event with the id", function() {
-				return this.RoomEvents.emit.calledWithExactly('project-active', this.project_id).should.equal(true);
-			});
-
-			it("should listen for the 'project-subscribed-id' event", function() {
-				return this.RoomEvents.once.calledWith(`project-subscribed-${this.project_id}`).should.equal(true);
-			});
-
-			return it("should join the room using the id", function() {
-				return this.client.join.calledWithExactly(this.project_id).should.equal(true);
-			});
-		});
-
-		return describe("when there are other clients in the project room", function() {
-
-			beforeEach(function() {
-				this.RoomManager._clientsInRoom
-					.withArgs(this.client, this.project_id)
-					.onFirstCall().returns(123)
-					.onSecondCall().returns(124);
-				this.client.join = sinon.stub();
-				return this.RoomManager.joinProject(this.client, this.project_id);
-			});
-
-			it("should join the room using the id", function() {
-				return this.client.join.called.should.equal(true);
-			});
-
-			return it("should not emit any events", function() {
-				return this.RoomEvents.emit.called.should.equal(false);
-			});
-		});
-	});
-
-
-	describe("joinDoc", function() {
-
-		describe("when the doc room is empty", function() {
-
-			beforeEach(function(done) {
-				this.RoomManager._clientsInRoom
-					.withArgs(this.client, this.doc_id)
-					.onFirstCall().returns(0);
-				this.client.join = sinon.stub();
-				this.callback = sinon.stub();
-				this.RoomEvents.on('doc-active', id => {
-					return setTimeout(() => {
-						return this.RoomEvents.emit(`doc-subscribed-${id}`);
-					}
-					, 100);
-				});
-				return this.RoomManager.joinDoc(this.client, this.doc_id, err => {
-					this.callback(err);
-					return done();
-				});
-			});
-
-			it("should emit a 'doc-active' event with the id", function() {
-				return this.RoomEvents.emit.calledWithExactly('doc-active', this.doc_id).should.equal(true);
-			});
-
-			it("should listen for the 'doc-subscribed-id' event", function() {
-				return this.RoomEvents.once.calledWith(`doc-subscribed-${this.doc_id}`).should.equal(true);
-			});
-
-			return it("should join the room using the id", function() {
-				return this.client.join.calledWithExactly(this.doc_id).should.equal(true);
-			});
-		});
-
-		return describe("when there are other clients in the doc room", function() {
-
-			beforeEach(function() {
-				this.RoomManager._clientsInRoom
-					.withArgs(this.client, this.doc_id)
-					.onFirstCall().returns(123)
-					.onSecondCall().returns(124);
-				this.client.join = sinon.stub();
-				return this.RoomManager.joinDoc(this.client, this.doc_id);
-			});
-
-			it("should join the room using the id", function() {
-				return this.client.join.called.should.equal(true);
-			});
-
-			return it("should not emit any events", function() {
-				return this.RoomEvents.emit.called.should.equal(false);
-			});
-		});
-	});
-
-
-	describe("leaveDoc", function() {
-
-		describe("when doc room will be empty after this client has left", function() {
-
-			beforeEach(function() {
-				this.RoomManager._clientAlreadyInRoom
-					.withArgs(this.client, this.doc_id)
-					.returns(true);
-				this.RoomManager._clientsInRoom
-					.withArgs(this.client, this.doc_id)
-					.onCall(0).returns(0);
-				this.client.leave = sinon.stub();
-				return this.RoomManager.leaveDoc(this.client, this.doc_id);
-			});
-
-			it("should leave the room using the id", function() {
-				return this.client.leave.calledWithExactly(this.doc_id).should.equal(true);
-			});
-
-			return it("should emit a 'doc-empty' event with the id", function() {
-				return this.RoomEvents.emit.calledWithExactly('doc-empty', this.doc_id).should.equal(true);
-			});
-		});
-
-
-		describe("when there are other clients in the doc room", function() {
-
-			beforeEach(function() {
-				this.RoomManager._clientAlreadyInRoom
-					.withArgs(this.client, this.doc_id)
-					.returns(true);
-				this.RoomManager._clientsInRoom
-					.withArgs(this.client, this.doc_id)
-					.onCall(0).returns(123);
-				this.client.leave = sinon.stub();
-				return this.RoomManager.leaveDoc(this.client, this.doc_id);
-			});
-
-			it("should leave the room using the id", function() {
-				return this.client.leave.calledWithExactly(this.doc_id).should.equal(true);
-			});
-
-			return it("should not emit any events", function() {
-				return this.RoomEvents.emit.called.should.equal(false);
-			});
-		});
-
-		return describe("when the client is not in the doc room", function() {
-
-			beforeEach(function() {
-				this.RoomManager._clientAlreadyInRoom
-					.withArgs(this.client, this.doc_id)
-					.returns(false);
-				this.RoomManager._clientsInRoom
-					.withArgs(this.client, this.doc_id)
-					.onCall(0).returns(0);
-				this.client.leave = sinon.stub();
-				return this.RoomManager.leaveDoc(this.client, this.doc_id);
-			});
-
-			it("should not leave the room", function() {
-				return this.client.leave.called.should.equal(false);
-			});
-
-			return it("should not emit any events", function() {
-				return this.RoomEvents.emit.called.should.equal(false);
-			});
-		});
-	});
-
-
-	return describe("leaveProjectAndDocs", function() { return describe("when the client is connected to the project and multiple docs", function() {
-
-        beforeEach(function() {
-            this.RoomManager._roomsClientIsIn = sinon.stub().returns([this.project_id, this.doc_id, this.other_doc_id]);
-            this.client.join = sinon.stub();
-            return this.client.leave = sinon.stub();
-        });
-
-        describe("when this is the only client connected", function() {
-
-            beforeEach(function(done) {
-                // first call is for the join,
-                // second for the leave
-                this.RoomManager._clientsInRoom
-                    .withArgs(this.client, this.doc_id)
-                    .onCall(0).returns(0)
-                    .onCall(1).returns(0);
-                this.RoomManager._clientsInRoom
-                    .withArgs(this.client, this.other_doc_id)
-                    .onCall(0).returns(0)
-                    .onCall(1).returns(0);
-                this.RoomManager._clientsInRoom
-                    .withArgs(this.client, this.project_id)
-                    .onCall(0).returns(0)
-                    .onCall(1).returns(0);
-                this.RoomManager._clientAlreadyInRoom
-                    .withArgs(this.client, this.doc_id)
-                    .returns(true)
-                    .withArgs(this.client, this.other_doc_id)
-                    .returns(true)
-                    .withArgs(this.client, this.project_id)
-                    .returns(true);
-                this.RoomEvents.on('project-active', id => {
-                    return setTimeout(() => {
-                        return this.RoomEvents.emit(`project-subscribed-${id}`);
-                    }
-                    , 100);
-                });
-                this.RoomEvents.on('doc-active', id => {
-                    return setTimeout(() => {
-                        return this.RoomEvents.emit(`doc-subscribed-${id}`);
-                    }
-                    , 100);
-                });
-                // put the client in the rooms
-                return this.RoomManager.joinProject(this.client, this.project_id, () => {
-                    return this.RoomManager.joinDoc(this.client, this.doc_id, () => {
-                        return this.RoomManager.joinDoc(this.client, this.other_doc_id, () => {
-                            // now leave the project
-                            this.RoomManager.leaveProjectAndDocs(this.client);
-                            return done();
-                        });
-                    });
-                });
-            });
-
-            it("should leave all the docs", function() {
-                this.client.leave.calledWithExactly(this.doc_id).should.equal(true);
-                return this.client.leave.calledWithExactly(this.other_doc_id).should.equal(true);
-            });
-
-            it("should leave the project", function() {
-                return this.client.leave.calledWithExactly(this.project_id).should.equal(true);
-            });
-
-            it("should emit a 'doc-empty' event with the id for each doc", function() {
-                this.RoomEvents.emit.calledWithExactly('doc-empty', this.doc_id).should.equal(true);
-                return this.RoomEvents.emit.calledWithExactly('doc-empty', this.other_doc_id).should.equal(true);
-            });
-
-            return it("should emit a 'project-empty' event with the id for the project", function() {
-                return this.RoomEvents.emit.calledWithExactly('project-empty', this.project_id).should.equal(true);
-            });
-        });
-
-        return describe("when other clients are still connected", function() {
-
-            beforeEach(function() {
-                this.RoomManager._clientsInRoom
-                    .withArgs(this.client, this.doc_id)
-                    .onFirstCall().returns(123)
-                    .onSecondCall().returns(122);
-                this.RoomManager._clientsInRoom
-                    .withArgs(this.client, this.other_doc_id)
-                    .onFirstCall().returns(123)
-                    .onSecondCall().returns(122);
-                this.RoomManager._clientsInRoom
-                    .withArgs(this.client, this.project_id)
-                    .onFirstCall().returns(123)
-                    .onSecondCall().returns(122);
-                this.RoomManager._clientAlreadyInRoom
-                    .withArgs(this.client, this.doc_id)
-                    .returns(true)
-                    .withArgs(this.client, this.other_doc_id)
-                    .returns(true)
-                    .withArgs(this.client, this.project_id)
-                    .returns(true);
-                return this.RoomManager.leaveProjectAndDocs(this.client);
-            });
-
-            it("should leave all the docs", function() {
-                this.client.leave.calledWithExactly(this.doc_id).should.equal(true);
-                return this.client.leave.calledWithExactly(this.other_doc_id).should.equal(true);
-            });
-
-            it("should leave the project", function() {
-                return this.client.leave.calledWithExactly(this.project_id).should.equal(true);
-            });
-
-            return it("should not emit any events", function() {
-                return this.RoomEvents.emit.called.should.equal(false);
-            });
-        });
-    }); });
-});
+const chai = require('chai')
+const { expect } = chai
+const should = chai.should()
+const sinon = require('sinon')
+const modulePath = '../../../app/js/RoomManager.js'
+const SandboxedModule = require('sandboxed-module')
+
+describe('RoomManager', function () {
+  beforeEach(function () {
+    this.project_id = 'project-id-123'
+    this.doc_id = 'doc-id-456'
+    this.other_doc_id = 'doc-id-789'
+    this.client = { namespace: { name: '' }, id: 'first-client' }
+    this.RoomManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': (this.settings = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          warn: sinon.stub(),
+          error: sinon.stub()
+        }),
+        'metrics-sharelatex': (this.metrics = { gauge: sinon.stub() })
+      }
+    })
+    this.RoomManager._clientsInRoom = sinon.stub()
+    this.RoomManager._clientAlreadyInRoom = sinon.stub()
+    this.RoomEvents = this.RoomManager.eventSource()
+    sinon.spy(this.RoomEvents, 'emit')
+    return sinon.spy(this.RoomEvents, 'once')
+  })
+
+  describe('emitOnCompletion', function () {
+    return describe('when a subscribe errors', function () {
+      afterEach(function () {
+        return process.removeListener('unhandledRejection', this.onUnhandled)
+      })
+
+      beforeEach(function (done) {
+        this.onUnhandled = (error) => {
+          this.unhandledError = error
+          return done(new Error(`unhandledRejection: ${error.message}`))
+        }
+        process.on('unhandledRejection', this.onUnhandled)
+
+        let reject
+        const subscribePromise = new Promise((_, r) => (reject = r))
+        const promises = [subscribePromise]
+        const eventName = 'project-subscribed-123'
+        this.RoomEvents.once(eventName, () => setTimeout(done, 100))
+        this.RoomManager.emitOnCompletion(promises, eventName)
+        return setTimeout(() => reject(new Error('subscribe failed')))
+      })
+
+      return it('should keep going', function () {
+        return expect(this.unhandledError).to.not.exist
+      })
+    })
+  })
+
+  describe('joinProject', function () {
+    describe('when the project room is empty', function () {
+      beforeEach(function (done) {
+        this.RoomManager._clientsInRoom
+          .withArgs(this.client, this.project_id)
+          .onFirstCall()
+          .returns(0)
+        this.client.join = sinon.stub()
+        this.callback = sinon.stub()
+        this.RoomEvents.on('project-active', (id) => {
+          return setTimeout(() => {
+            return this.RoomEvents.emit(`project-subscribed-${id}`)
+          }, 100)
+        })
+        return this.RoomManager.joinProject(
+          this.client,
+          this.project_id,
+          (err) => {
+            this.callback(err)
+            return done()
+          }
+        )
+      })
+
+      it("should emit a 'project-active' event with the id", function () {
+        return this.RoomEvents.emit
+          .calledWithExactly('project-active', this.project_id)
+          .should.equal(true)
+      })
+
+      it("should listen for the 'project-subscribed-id' event", function () {
+        return this.RoomEvents.once
+          .calledWith(`project-subscribed-${this.project_id}`)
+          .should.equal(true)
+      })
+
+      return it('should join the room using the id', function () {
+        return this.client.join
+          .calledWithExactly(this.project_id)
+          .should.equal(true)
+      })
+    })
+
+    return describe('when there are other clients in the project room', function () {
+      beforeEach(function () {
+        this.RoomManager._clientsInRoom
+          .withArgs(this.client, this.project_id)
+          .onFirstCall()
+          .returns(123)
+          .onSecondCall()
+          .returns(124)
+        this.client.join = sinon.stub()
+        return this.RoomManager.joinProject(this.client, this.project_id)
+      })
+
+      it('should join the room using the id', function () {
+        return this.client.join.called.should.equal(true)
+      })
+
+      return it('should not emit any events', function () {
+        return this.RoomEvents.emit.called.should.equal(false)
+      })
+    })
+  })
+
+  describe('joinDoc', function () {
+    describe('when the doc room is empty', function () {
+      beforeEach(function (done) {
+        this.RoomManager._clientsInRoom
+          .withArgs(this.client, this.doc_id)
+          .onFirstCall()
+          .returns(0)
+        this.client.join = sinon.stub()
+        this.callback = sinon.stub()
+        this.RoomEvents.on('doc-active', (id) => {
+          return setTimeout(() => {
+            return this.RoomEvents.emit(`doc-subscribed-${id}`)
+          }, 100)
+        })
+        return this.RoomManager.joinDoc(this.client, this.doc_id, (err) => {
+          this.callback(err)
+          return done()
+        })
+      })
+
+      it("should emit a 'doc-active' event with the id", function () {
+        return this.RoomEvents.emit
+          .calledWithExactly('doc-active', this.doc_id)
+          .should.equal(true)
+      })
+
+      it("should listen for the 'doc-subscribed-id' event", function () {
+        return this.RoomEvents.once
+          .calledWith(`doc-subscribed-${this.doc_id}`)
+          .should.equal(true)
+      })
+
+      return it('should join the room using the id', function () {
+        return this.client.join
+          .calledWithExactly(this.doc_id)
+          .should.equal(true)
+      })
+    })
+
+    return describe('when there are other clients in the doc room', function () {
+      beforeEach(function () {
+        this.RoomManager._clientsInRoom
+          .withArgs(this.client, this.doc_id)
+          .onFirstCall()
+          .returns(123)
+          .onSecondCall()
+          .returns(124)
+        this.client.join = sinon.stub()
+        return this.RoomManager.joinDoc(this.client, this.doc_id)
+      })
+
+      it('should join the room using the id', function () {
+        return this.client.join.called.should.equal(true)
+      })
+
+      return it('should not emit any events', function () {
+        return this.RoomEvents.emit.called.should.equal(false)
+      })
+    })
+  })
+
+  describe('leaveDoc', function () {
+    describe('when doc room will be empty after this client has left', function () {
+      beforeEach(function () {
+        this.RoomManager._clientAlreadyInRoom
+          .withArgs(this.client, this.doc_id)
+          .returns(true)
+        this.RoomManager._clientsInRoom
+          .withArgs(this.client, this.doc_id)
+          .onCall(0)
+          .returns(0)
+        this.client.leave = sinon.stub()
+        return this.RoomManager.leaveDoc(this.client, this.doc_id)
+      })
+
+      it('should leave the room using the id', function () {
+        return this.client.leave
+          .calledWithExactly(this.doc_id)
+          .should.equal(true)
+      })
+
+      return it("should emit a 'doc-empty' event with the id", function () {
+        return this.RoomEvents.emit
+          .calledWithExactly('doc-empty', this.doc_id)
+          .should.equal(true)
+      })
+    })
+
+    describe('when there are other clients in the doc room', function () {
+      beforeEach(function () {
+        this.RoomManager._clientAlreadyInRoom
+          .withArgs(this.client, this.doc_id)
+          .returns(true)
+        this.RoomManager._clientsInRoom
+          .withArgs(this.client, this.doc_id)
+          .onCall(0)
+          .returns(123)
+        this.client.leave = sinon.stub()
+        return this.RoomManager.leaveDoc(this.client, this.doc_id)
+      })
+
+      it('should leave the room using the id', function () {
+        return this.client.leave
+          .calledWithExactly(this.doc_id)
+          .should.equal(true)
+      })
+
+      return it('should not emit any events', function () {
+        return this.RoomEvents.emit.called.should.equal(false)
+      })
+    })
+
+    return describe('when the client is not in the doc room', function () {
+      beforeEach(function () {
+        this.RoomManager._clientAlreadyInRoom
+          .withArgs(this.client, this.doc_id)
+          .returns(false)
+        this.RoomManager._clientsInRoom
+          .withArgs(this.client, this.doc_id)
+          .onCall(0)
+          .returns(0)
+        this.client.leave = sinon.stub()
+        return this.RoomManager.leaveDoc(this.client, this.doc_id)
+      })
+
+      it('should not leave the room', function () {
+        return this.client.leave.called.should.equal(false)
+      })
+
+      return it('should not emit any events', function () {
+        return this.RoomEvents.emit.called.should.equal(false)
+      })
+    })
+  })
+
+  return describe('leaveProjectAndDocs', function () {
+    return describe('when the client is connected to the project and multiple docs', function () {
+      beforeEach(function () {
+        this.RoomManager._roomsClientIsIn = sinon
+          .stub()
+          .returns([this.project_id, this.doc_id, this.other_doc_id])
+        this.client.join = sinon.stub()
+        return (this.client.leave = sinon.stub())
+      })
+
+      describe('when this is the only client connected', function () {
+        beforeEach(function (done) {
+          // first call is for the join,
+          // second for the leave
+          this.RoomManager._clientsInRoom
+            .withArgs(this.client, this.doc_id)
+            .onCall(0)
+            .returns(0)
+            .onCall(1)
+            .returns(0)
+          this.RoomManager._clientsInRoom
+            .withArgs(this.client, this.other_doc_id)
+            .onCall(0)
+            .returns(0)
+            .onCall(1)
+            .returns(0)
+          this.RoomManager._clientsInRoom
+            .withArgs(this.client, this.project_id)
+            .onCall(0)
+            .returns(0)
+            .onCall(1)
+            .returns(0)
+          this.RoomManager._clientAlreadyInRoom
+            .withArgs(this.client, this.doc_id)
+            .returns(true)
+            .withArgs(this.client, this.other_doc_id)
+            .returns(true)
+            .withArgs(this.client, this.project_id)
+            .returns(true)
+          this.RoomEvents.on('project-active', (id) => {
+            return setTimeout(() => {
+              return this.RoomEvents.emit(`project-subscribed-${id}`)
+            }, 100)
+          })
+          this.RoomEvents.on('doc-active', (id) => {
+            return setTimeout(() => {
+              return this.RoomEvents.emit(`doc-subscribed-${id}`)
+            }, 100)
+          })
+          // put the client in the rooms
+          return this.RoomManager.joinProject(
+            this.client,
+            this.project_id,
+            () => {
+              return this.RoomManager.joinDoc(this.client, this.doc_id, () => {
+                return this.RoomManager.joinDoc(
+                  this.client,
+                  this.other_doc_id,
+                  () => {
+                    // now leave the project
+                    this.RoomManager.leaveProjectAndDocs(this.client)
+                    return done()
+                  }
+                )
+              })
+            }
+          )
+        })
+
+        it('should leave all the docs', function () {
+          this.client.leave.calledWithExactly(this.doc_id).should.equal(true)
+          return this.client.leave
+            .calledWithExactly(this.other_doc_id)
+            .should.equal(true)
+        })
+
+        it('should leave the project', function () {
+          return this.client.leave
+            .calledWithExactly(this.project_id)
+            .should.equal(true)
+        })
+
+        it("should emit a 'doc-empty' event with the id for each doc", function () {
+          this.RoomEvents.emit
+            .calledWithExactly('doc-empty', this.doc_id)
+            .should.equal(true)
+          return this.RoomEvents.emit
+            .calledWithExactly('doc-empty', this.other_doc_id)
+            .should.equal(true)
+        })
+
+        return it("should emit a 'project-empty' event with the id for the project", function () {
+          return this.RoomEvents.emit
+            .calledWithExactly('project-empty', this.project_id)
+            .should.equal(true)
+        })
+      })
+
+      return describe('when other clients are still connected', function () {
+        beforeEach(function () {
+          this.RoomManager._clientsInRoom
+            .withArgs(this.client, this.doc_id)
+            .onFirstCall()
+            .returns(123)
+            .onSecondCall()
+            .returns(122)
+          this.RoomManager._clientsInRoom
+            .withArgs(this.client, this.other_doc_id)
+            .onFirstCall()
+            .returns(123)
+            .onSecondCall()
+            .returns(122)
+          this.RoomManager._clientsInRoom
+            .withArgs(this.client, this.project_id)
+            .onFirstCall()
+            .returns(123)
+            .onSecondCall()
+            .returns(122)
+          this.RoomManager._clientAlreadyInRoom
+            .withArgs(this.client, this.doc_id)
+            .returns(true)
+            .withArgs(this.client, this.other_doc_id)
+            .returns(true)
+            .withArgs(this.client, this.project_id)
+            .returns(true)
+          return this.RoomManager.leaveProjectAndDocs(this.client)
+        })
+
+        it('should leave all the docs', function () {
+          this.client.leave.calledWithExactly(this.doc_id).should.equal(true)
+          return this.client.leave
+            .calledWithExactly(this.other_doc_id)
+            .should.equal(true)
+        })
+
+        it('should leave the project', function () {
+          return this.client.leave
+            .calledWithExactly(this.project_id)
+            .should.equal(true)
+        })
+
+        return it('should not emit any events', function () {
+          return this.RoomEvents.emit.called.should.equal(false)
+        })
+      })
+    })
+  })
+})

+ 44 - 44
services/real-time/test/unit/js/SafeJsonParseTest.js

@@ -11,49 +11,49 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-require('chai').should();
-const {
-    expect
-} = require("chai");
-const SandboxedModule = require('sandboxed-module');
-const modulePath = '../../../app/js/SafeJsonParse';
-const sinon = require("sinon");
+require('chai').should()
+const { expect } = require('chai')
+const SandboxedModule = require('sandboxed-module')
+const modulePath = '../../../app/js/SafeJsonParse'
+const sinon = require('sinon')
 
 
-describe('SafeJsonParse', function() {
-	beforeEach(function() {
-		return this.SafeJsonParse = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex": (this.Settings = {
-				maxUpdateSize: 16 * 1024
-			}),
-			"logger-sharelatex": (this.logger = {error: sinon.stub()})
-		}
-	});});
+describe('SafeJsonParse', function () {
+  beforeEach(function () {
+    return (this.SafeJsonParse = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': (this.Settings = {
+          maxUpdateSize: 16 * 1024
+        }),
+        'logger-sharelatex': (this.logger = { error: sinon.stub() })
+      }
+    }))
+  })
 
 
-	return describe("parse", function() {
-		it("should parse documents correctly", function(done) {
-			return this.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => {
-				expect(parsed).to.deep.equal({foo: "bar"});
-				return done();
-			});
-		});
-		
-		it("should return an error on bad data", function(done) {
-			return this.SafeJsonParse.parse('blah', (error, parsed) => {
-				expect(error).to.exist;
-				return done();
-			});
-		});
-		
-		return it("should return an error on oversized data", function(done) {
-			// we have a 2k overhead on top of max size
-			const big_blob = Array(16*1024).join("A");
-			const data = `{\"foo\": \"${big_blob}\"}`;
-			this.Settings.maxUpdateSize = 2 * 1024;
-			return this.SafeJsonParse.parse(data, (error, parsed) => {
-				this.logger.error.called.should.equal(true);
-				expect(error).to.exist;
-				return done();
-			});
-		});
-	});
-});
+  return describe('parse', function () {
+    it('should parse documents correctly', function (done) {
+      return this.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => {
+        expect(parsed).to.deep.equal({ foo: 'bar' })
+        return done()
+      })
+    })
+
+    it('should return an error on bad data', function (done) {
+      return this.SafeJsonParse.parse('blah', (error, parsed) => {
+        expect(error).to.exist
+        return done()
+      })
+    })
+
+    return it('should return an error on oversized data', function (done) {
+      // we have a 2k overhead on top of max size
+      const big_blob = Array(16 * 1024).join('A')
+      const data = `{\"foo\": \"${big_blob}\"}`
+      this.Settings.maxUpdateSize = 2 * 1024
+      return this.SafeJsonParse.parse(data, (error, parsed) => {
+        this.logger.error.called.should.equal(true)
+        expect(error).to.exist
+        return done()
+      })
+    })
+  })
+})

+ 186 - 165
services/real-time/test/unit/js/SessionSocketsTests.js

@@ -9,168 +9,189 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-const {EventEmitter} = require('events');
-const {expect} = require('chai');
-const SandboxedModule = require('sandboxed-module');
-const modulePath = '../../../app/js/SessionSockets';
-const sinon = require('sinon');
-
-describe('SessionSockets', function() {
-	before(function() {
-		this.SessionSocketsModule = SandboxedModule.require(modulePath);
-		this.io = new EventEmitter();
-		this.id1 = Math.random().toString();
-		this.id2 = Math.random().toString();
-		const redisResponses = {
-			error: [new Error('Redis: something went wrong'), null],
-			unknownId: [null, null]
-		};
-		redisResponses[this.id1] = [null, {user: {_id: '123'}}];
-		redisResponses[this.id2] = [null, {user: {_id: 'abc'}}];
-
-		this.sessionStore = {
-			get: sinon.stub().callsFake((id, fn) => fn.apply(null, redisResponses[id]))
-		};
-		this.cookieParser = function(req, res, next) {
-			req.signedCookies = req._signedCookies;
-			return next();
-		};
-		this.SessionSockets = this.SessionSocketsModule(this.io, this.sessionStore, this.cookieParser, 'ol.sid');
-		return this.checkSocket = (socket, fn) => {
-			this.SessionSockets.once('connection', fn);
-			return this.io.emit('connection', socket);
-		};
-	});
-
-	describe('without cookies', function() {
-		before(function() {
-			return this.socket = {handshake: {}};});
-
-		it('should return a lookup error', function(done) {
-			return this.checkSocket(this.socket, (error) => {
-				expect(error).to.exist;
-				expect(error.message).to.equal('could not look up session by key');
-				return done();
-			});
-		});
-
-		return it('should not query redis', function(done) {
-			return this.checkSocket(this.socket, () => {
-				expect(this.sessionStore.get.called).to.equal(false);
-				return done();
-			});
-		});
-	});
-
-	describe('with a different cookie', function() {
-		before(function() {
-			return this.socket = {handshake: {_signedCookies: {other: 1}}};});
-
-		it('should return a lookup error', function(done) {
-			return this.checkSocket(this.socket, (error) => {
-				expect(error).to.exist;
-				expect(error.message).to.equal('could not look up session by key');
-				return done();
-			});
-		});
-
-		return it('should not query redis', function(done) {
-			return this.checkSocket(this.socket, () => {
-				expect(this.sessionStore.get.called).to.equal(false);
-				return done();
-			});
-		});
-	});
-
-	describe('with a valid cookie and a failing session lookup', function() {
-		before(function() {
-			return this.socket = {handshake: {_signedCookies: {'ol.sid': 'error'}}};});
-
-		it('should query redis', function(done) {
-			return this.checkSocket(this.socket, () => {
-				expect(this.sessionStore.get.called).to.equal(true);
-				return done();
-			});
-		});
-
-		return it('should return a redis error', function(done) {
-			return this.checkSocket(this.socket, (error) => {
-				expect(error).to.exist;
-				expect(error.message).to.equal('Redis: something went wrong');
-				return done();
-			});
-		});
-	});
-
-	describe('with a valid cookie and no matching session', function() {
-		before(function() {
-			return this.socket = {handshake: {_signedCookies: {'ol.sid': 'unknownId'}}};});
-
-		it('should query redis', function(done) {
-			return this.checkSocket(this.socket, () => {
-				expect(this.sessionStore.get.called).to.equal(true);
-				return done();
-			});
-		});
-
-		return it('should return a lookup error', function(done) {
-			return this.checkSocket(this.socket, (error) => {
-				expect(error).to.exist;
-				expect(error.message).to.equal('could not look up session by key');
-				return done();
-			});
-		});
-	});
-
-	describe('with a valid cookie and a matching session', function() {
-		before(function() {
-			return this.socket = {handshake: {_signedCookies: {'ol.sid': this.id1}}};});
-
-		it('should query redis', function(done) {
-			return this.checkSocket(this.socket, () => {
-				expect(this.sessionStore.get.called).to.equal(true);
-				return done();
-			});
-		});
-
-		it('should not return an error', function(done) {
-			return this.checkSocket(this.socket, (error) => {
-				expect(error).to.not.exist;
-				return done();
-			});
-		});
-
-		return it('should return the session', function(done) {
-			return this.checkSocket(this.socket, (error, s, session) => {
-				expect(session).to.deep.equal({user: {_id: '123'}});
-				return done();
-			});
-		});
-	});
-
-	return describe('with a different valid cookie and matching session', function() {
-		before(function() {
-			return this.socket = {handshake: {_signedCookies: {'ol.sid': this.id2}}};});
-
-		it('should query redis', function(done) {
-			return this.checkSocket(this.socket, () => {
-				expect(this.sessionStore.get.called).to.equal(true);
-				return done();
-			});
-		});
-
-		it('should not return an error', function(done) {
-			return this.checkSocket(this.socket, (error) => {
-				expect(error).to.not.exist;
-				return done();
-			});
-		});
-
-		return it('should return the other session', function(done) {
-			return this.checkSocket(this.socket, (error, s, session) => {
-				expect(session).to.deep.equal({user: {_id: 'abc'}});
-				return done();
-			});
-		});
-	});
-});
+const { EventEmitter } = require('events')
+const { expect } = require('chai')
+const SandboxedModule = require('sandboxed-module')
+const modulePath = '../../../app/js/SessionSockets'
+const sinon = require('sinon')
+
+describe('SessionSockets', function () {
+  before(function () {
+    this.SessionSocketsModule = SandboxedModule.require(modulePath)
+    this.io = new EventEmitter()
+    this.id1 = Math.random().toString()
+    this.id2 = Math.random().toString()
+    const redisResponses = {
+      error: [new Error('Redis: something went wrong'), null],
+      unknownId: [null, null]
+    }
+    redisResponses[this.id1] = [null, { user: { _id: '123' } }]
+    redisResponses[this.id2] = [null, { user: { _id: 'abc' } }]
+
+    this.sessionStore = {
+      get: sinon
+        .stub()
+        .callsFake((id, fn) => fn.apply(null, redisResponses[id]))
+    }
+    this.cookieParser = function (req, res, next) {
+      req.signedCookies = req._signedCookies
+      return next()
+    }
+    this.SessionSockets = this.SessionSocketsModule(
+      this.io,
+      this.sessionStore,
+      this.cookieParser,
+      'ol.sid'
+    )
+    return (this.checkSocket = (socket, fn) => {
+      this.SessionSockets.once('connection', fn)
+      return this.io.emit('connection', socket)
+    })
+  })
+
+  describe('without cookies', function () {
+    before(function () {
+      return (this.socket = { handshake: {} })
+    })
+
+    it('should return a lookup error', function (done) {
+      return this.checkSocket(this.socket, (error) => {
+        expect(error).to.exist
+        expect(error.message).to.equal('could not look up session by key')
+        return done()
+      })
+    })
+
+    return it('should not query redis', function (done) {
+      return this.checkSocket(this.socket, () => {
+        expect(this.sessionStore.get.called).to.equal(false)
+        return done()
+      })
+    })
+  })
+
+  describe('with a different cookie', function () {
+    before(function () {
+      return (this.socket = { handshake: { _signedCookies: { other: 1 } } })
+    })
+
+    it('should return a lookup error', function (done) {
+      return this.checkSocket(this.socket, (error) => {
+        expect(error).to.exist
+        expect(error.message).to.equal('could not look up session by key')
+        return done()
+      })
+    })
+
+    return it('should not query redis', function (done) {
+      return this.checkSocket(this.socket, () => {
+        expect(this.sessionStore.get.called).to.equal(false)
+        return done()
+      })
+    })
+  })
+
+  describe('with a valid cookie and a failing session lookup', function () {
+    before(function () {
+      return (this.socket = {
+        handshake: { _signedCookies: { 'ol.sid': 'error' } }
+      })
+    })
+
+    it('should query redis', function (done) {
+      return this.checkSocket(this.socket, () => {
+        expect(this.sessionStore.get.called).to.equal(true)
+        return done()
+      })
+    })
+
+    return it('should return a redis error', function (done) {
+      return this.checkSocket(this.socket, (error) => {
+        expect(error).to.exist
+        expect(error.message).to.equal('Redis: something went wrong')
+        return done()
+      })
+    })
+  })
+
+  describe('with a valid cookie and no matching session', function () {
+    before(function () {
+      return (this.socket = {
+        handshake: { _signedCookies: { 'ol.sid': 'unknownId' } }
+      })
+    })
+
+    it('should query redis', function (done) {
+      return this.checkSocket(this.socket, () => {
+        expect(this.sessionStore.get.called).to.equal(true)
+        return done()
+      })
+    })
+
+    return it('should return a lookup error', function (done) {
+      return this.checkSocket(this.socket, (error) => {
+        expect(error).to.exist
+        expect(error.message).to.equal('could not look up session by key')
+        return done()
+      })
+    })
+  })
+
+  describe('with a valid cookie and a matching session', function () {
+    before(function () {
+      return (this.socket = {
+        handshake: { _signedCookies: { 'ol.sid': this.id1 } }
+      })
+    })
+
+    it('should query redis', function (done) {
+      return this.checkSocket(this.socket, () => {
+        expect(this.sessionStore.get.called).to.equal(true)
+        return done()
+      })
+    })
+
+    it('should not return an error', function (done) {
+      return this.checkSocket(this.socket, (error) => {
+        expect(error).to.not.exist
+        return done()
+      })
+    })
+
+    return it('should return the session', function (done) {
+      return this.checkSocket(this.socket, (error, s, session) => {
+        expect(session).to.deep.equal({ user: { _id: '123' } })
+        return done()
+      })
+    })
+  })
+
+  return describe('with a different valid cookie and matching session', function () {
+    before(function () {
+      return (this.socket = {
+        handshake: { _signedCookies: { 'ol.sid': this.id2 } }
+      })
+    })
+
+    it('should query redis', function (done) {
+      return this.checkSocket(this.socket, () => {
+        expect(this.sessionStore.get.called).to.equal(true)
+        return done()
+      })
+    })
+
+    it('should not return an error', function (done) {
+      return this.checkSocket(this.socket, (error) => {
+        expect(error).to.not.exist
+        return done()
+      })
+    })
+
+    return it('should return the other session', function (done) {
+      return this.checkSocket(this.socket, (error, s, session) => {
+        expect(session).to.deep.equal({ user: { _id: 'abc' } })
+        return done()
+      })
+    })
+  })
+})

+ 141 - 96
services/real-time/test/unit/js/WebApiManagerTests.js

@@ -9,109 +9,154 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-const chai = require('chai');
-const should = chai.should();
-const sinon = require("sinon");
-const modulePath = "../../../app/js/WebApiManager.js";
-const SandboxedModule = require('sandboxed-module');
-const { CodedError } = require('../../../app/js/Errors');
+const chai = require('chai')
+const should = chai.should()
+const sinon = require('sinon')
+const modulePath = '../../../app/js/WebApiManager.js'
+const SandboxedModule = require('sandboxed-module')
+const { CodedError } = require('../../../app/js/Errors')
 
 
-describe('WebApiManager', function() {
-	beforeEach(function() {
-		this.project_id = "project-id-123";
-		this.user_id = "user-id-123";
-		this.user = {_id: this.user_id};
-		this.callback = sinon.stub();
-		return this.WebApiManager = SandboxedModule.require(modulePath, { requires: {
-			"request": (this.request = {}),
-			"settings-sharelatex": (this.settings = {
-				apis: {
-					web: {
-						url: "http://web.example.com",
-						user: "username",
-						pass: "password"
-					}
-				}
-			}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() })
-		}
-	});});
+describe('WebApiManager', function () {
+  beforeEach(function () {
+    this.project_id = 'project-id-123'
+    this.user_id = 'user-id-123'
+    this.user = { _id: this.user_id }
+    this.callback = sinon.stub()
+    return (this.WebApiManager = SandboxedModule.require(modulePath, {
+      requires: {
+        request: (this.request = {}),
+        'settings-sharelatex': (this.settings = {
+          apis: {
+            web: {
+              url: 'http://web.example.com',
+              user: 'username',
+              pass: 'password'
+            }
+          }
+        }),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        })
+      }
+    }))
+  })
 
 
-	return describe("joinProject", function() {
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.response = {
-					project: { name: "Test project" },
-					privilegeLevel: "owner",
-					isRestrictedUser: true
-				};
-				this.request.post = sinon.stub().callsArgWith(1, null, {statusCode: 200}, this.response);
-				return this.WebApiManager.joinProject(this.project_id, this.user, this.callback);
-			});
+  return describe('joinProject', function () {
+    describe('successfully', function () {
+      beforeEach(function () {
+        this.response = {
+          project: { name: 'Test project' },
+          privilegeLevel: 'owner',
+          isRestrictedUser: true
+        }
+        this.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 }, this.response)
+        return this.WebApiManager.joinProject(
+          this.project_id,
+          this.user,
+          this.callback
+        )
+      })
 
 
-			it("should send a request to web to join the project", function() {
-				return this.request.post
-					.calledWith({
-						url: `${this.settings.apis.web.url}/project/${this.project_id}/join`,
-						qs: {
-							user_id: this.user_id
-						},
-						auth: {
-							user: this.settings.apis.web.user,
-							pass: this.settings.apis.web.pass,
-							sendImmediately: true
-						},
-						json: true,
-						jar: false,
-						headers: {}
-					})
-					.should.equal(true);
-			});
+      it('should send a request to web to join the project', function () {
+        return this.request.post
+          .calledWith({
+            url: `${this.settings.apis.web.url}/project/${this.project_id}/join`,
+            qs: {
+              user_id: this.user_id
+            },
+            auth: {
+              user: this.settings.apis.web.user,
+              pass: this.settings.apis.web.pass,
+              sendImmediately: true
+            },
+            json: true,
+            jar: false,
+            headers: {}
+          })
+          .should.equal(true)
+      })
 
 
-			return it("should return the project, privilegeLevel, and restricted flag", function() {
-				return this.callback
-					.calledWith(null, this.response.project, this.response.privilegeLevel, this.response.isRestrictedUser)
-					.should.equal(true);
-			});
-		});
+      return it('should return the project, privilegeLevel, and restricted flag', function () {
+        return this.callback
+          .calledWith(
+            null,
+            this.response.project,
+            this.response.privilegeLevel,
+            this.response.isRestrictedUser
+          )
+          .should.equal(true)
+      })
+    })
 
 
-		describe("with an error from web", function() {
-			beforeEach(function() {
-				this.request.post = sinon.stub().callsArgWith(1, null, {statusCode: 500}, null);
-				return this.WebApiManager.joinProject(this.project_id, this.user_id, this.callback);
-			});
+    describe('with an error from web', function () {
+      beforeEach(function () {
+        this.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 500 }, null)
+        return this.WebApiManager.joinProject(
+          this.project_id,
+          this.user_id,
+          this.callback
+        )
+      })
 
 
-			return it("should call the callback with an error", function() {
-				return this.callback
-					.calledWith(sinon.match({message: "non-success status code from web: 500"}))
-					.should.equal(true);
-			});
-		});
+      return it('should call the callback with an error', function () {
+        return this.callback
+          .calledWith(
+            sinon.match({ message: 'non-success status code from web: 500' })
+          )
+          .should.equal(true)
+      })
+    })
 
 
-		describe("with no data from web", function() {
-			beforeEach(function() {
-				this.request.post = sinon.stub().callsArgWith(1, null, {statusCode: 200}, null);
-				return this.WebApiManager.joinProject(this.project_id, this.user_id, this.callback);
-			});
+    describe('with no data from web', function () {
+      beforeEach(function () {
+        this.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 }, null)
+        return this.WebApiManager.joinProject(
+          this.project_id,
+          this.user_id,
+          this.callback
+        )
+      })
 
 
-			return it("should call the callback with an error", function() {
-				return this.callback
-					.calledWith(sinon.match({message: "no data returned from joinProject request"}))
-					.should.equal(true);
-			});
-		});
+      return it('should call the callback with an error', function () {
+        return this.callback
+          .calledWith(
+            sinon.match({
+              message: 'no data returned from joinProject request'
+            })
+          )
+          .should.equal(true)
+      })
+    })
 
 
-		return describe("when the project is over its rate limit", function() {
-			beforeEach(function() {
-				this.request.post = sinon.stub().callsArgWith(1, null, {statusCode: 429}, null);
-				return this.WebApiManager.joinProject(this.project_id, this.user_id, this.callback);
-			});
+    return describe('when the project is over its rate limit', function () {
+      beforeEach(function () {
+        this.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 429 }, null)
+        return this.WebApiManager.joinProject(
+          this.project_id,
+          this.user_id,
+          this.callback
+        )
+      })
 
 
-			return it("should call the callback with a TooManyRequests error code", function() {
-				return this.callback
-					.calledWith(sinon.match({message: "rate-limit hit when joining project", code: "TooManyRequests"}))
-					.should.equal(true);
-			});
-		});
-	});
-});
+      return it('should call the callback with a TooManyRequests error code', function () {
+        return this.callback
+          .calledWith(
+            sinon.match({
+              message: 'rate-limit hit when joining project',
+              code: 'TooManyRequests'
+            })
+          )
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 1480 - 1082
services/real-time/test/unit/js/WebsocketControllerTests.js

@@ -12,1085 +12,1483 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-const chai = require('chai');
-const should = chai.should();
-const sinon = require("sinon");
-const {
-    expect
-} = chai;
-const modulePath = "../../../app/js/WebsocketController.js";
-const SandboxedModule = require('sandboxed-module');
-const tk = require("timekeeper");
-
-describe('WebsocketController', function() {
-	beforeEach(function() {
-		tk.freeze(new Date());
-		this.project_id = "project-id-123";
-		this.user = {
-			_id: (this.user_id = "user-id-123"),
-			first_name: "James",
-			last_name: "Allen",
-			email: "james@example.com",
-			signUpDate: new Date("2014-01-01"),
-			loginCount: 42
-		};
-		this.callback = sinon.stub();
-		this.client = {
-			disconnected: false,
-			id: (this.client_id = "mock-client-id-123"),
-			publicId: `other-id-${Math.random()}`,
-			ol_context: {},
-			join: sinon.stub(),
-			leave: sinon.stub()
-		};
-		return this.WebsocketController = SandboxedModule.require(modulePath, { requires: {
-			"./WebApiManager": (this.WebApiManager = {}),
-			"./AuthorizationManager": (this.AuthorizationManager = {}),
-			"./DocumentUpdaterManager": (this.DocumentUpdaterManager = {}),
-			"./ConnectedUsersManager": (this.ConnectedUsersManager = {}),
-			"./WebsocketLoadBalancer": (this.WebsocketLoadBalancer = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub(), warn: sinon.stub() }),
-			"metrics-sharelatex": (this.metrics = {
-				inc: sinon.stub(),
-				set: sinon.stub()
-			}),
-			"./RoomManager": (this.RoomManager = {})
-		}
-	});});
-
-	afterEach(function() { return tk.reset(); });
-
-	describe("joinProject", function() {
-		describe("when authorised", function() {
-			beforeEach(function() {
-				this.client.id = "mock-client-id";
-				this.project = {
-					name: "Test Project",
-					owner: {
-						_id: (this.owner_id = "mock-owner-id-123")
-					}
-				};
-				this.privilegeLevel = "owner";
-				this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4);
-				this.isRestrictedUser = true;
-				this.WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, this.project, this.privilegeLevel, this.isRestrictedUser);
-				this.RoomManager.joinProject = sinon.stub().callsArg(2);
-				return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
-			});
-
-			it("should load the project from web", function() {
-				return this.WebApiManager.joinProject
-					.calledWith(this.project_id, this.user)
-					.should.equal(true);
-			});
-
-			it("should join the project room", function() {
-				return this.RoomManager.joinProject.calledWith(this.client, this.project_id).should.equal(true);
-			});
-
-			it("should set the privilege level on the client", function() {
-				return this.client.ol_context.privilege_level.should.equal(this.privilegeLevel);
-			});
-			it("should set the user's id on the client", function() {
-				return this.client.ol_context.user_id.should.equal(this.user._id);
-			});
-			it("should set the user's email on the client", function() {
-				return this.client.ol_context.email.should.equal(this.user.email);
-			});
-			it("should set the user's first_name on the client", function() {
-				return this.client.ol_context.first_name.should.equal(this.user.first_name);
-			});
-			it("should set the user's last_name on the client", function() {
-				return this.client.ol_context.last_name.should.equal(this.user.last_name);
-			});
-			it("should set the user's sign up date on the client", function() {
-				return this.client.ol_context.signup_date.should.equal(this.user.signUpDate);
-			});
-			it("should set the user's login_count on the client", function() {
-				return this.client.ol_context.login_count.should.equal(this.user.loginCount);
-			});
-			it("should set the connected time on the client", function() {
-				return this.client.ol_context.connected_time.should.equal(new Date());
-			});
-			it("should set the project_id on the client", function() {
-				return this.client.ol_context.project_id.should.equal(this.project_id);
-			});
-			it("should set the project owner id on the client", function() {
-				return this.client.ol_context.owner_id.should.equal(this.owner_id);
-			});
-			it("should set the is_restricted_user flag on the client", function() {
-				return this.client.ol_context.is_restricted_user.should.equal(this.isRestrictedUser);
-			});
-			it("should call the callback with the project, privilegeLevel and protocolVersion", function() {
-				return this.callback
-					.calledWith(null, this.project, this.privilegeLevel, this.WebsocketController.PROTOCOL_VERSION)
-					.should.equal(true);
-			});
-
-			it("should mark the user as connected in ConnectedUsersManager", function() {
-				return this.ConnectedUsersManager.updateUserPosition
-					.calledWith(this.project_id, this.client.publicId, this.user, null)
-					.should.equal(true);
-			});
-
-			return it("should increment the join-project metric", function() {
-				return this.metrics.inc.calledWith("editor.join-project").should.equal(true);
-			});
-		});
-
-		describe("when not authorized", function() {
-			beforeEach(function() {
-				this.WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, null, null);
-				return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
-			});
-
-			it("should return an error", function() {
-				return this.callback
-					.calledWith(sinon.match({message: "not authorized"}))
-					.should.equal(true);
-			});
-
-			return it("should not log an error", function() {
-				return this.logger.error.called.should.equal(false);
-			});
-		});
-
-		describe("when the subscribe failed", function() {
-			beforeEach(function() {
-				this.client.id = "mock-client-id";
-				this.project = {
-					name: "Test Project",
-					owner: {
-						_id: (this.owner_id = "mock-owner-id-123")
-					}
-				};
-				this.privilegeLevel = "owner";
-				this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4);
-				this.isRestrictedUser = true;
-				this.WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, this.project, this.privilegeLevel, this.isRestrictedUser);
-				this.RoomManager.joinProject = sinon.stub().callsArgWith(2, new Error("subscribe failed"));
-				return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
-			});
-
-			return it("should return an error", function() {
-				this.callback
-					.calledWith(sinon.match({message: "subscribe failed"}))
-					.should.equal(true);
-				return this.callback.args[0][0].message.should.equal("subscribe failed");
-			});
-		});
-
-		describe("when the client has disconnected", function() {
-			beforeEach(function() {
-				this.client.disconnected = true;
-				this.WebApiManager.joinProject = sinon.stub().callsArg(2);
-				return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
-			});
-
-			it("should not call WebApiManager.joinProject", function() {
-				return expect(this.WebApiManager.joinProject.called).to.equal(false);
-			});
-
-			it("should call the callback with no details", function() {
-				return expect(this.callback.args[0]).to.deep.equal([]);
-		});
-
-			return it("should increment the editor.join-project.disconnected metric with a status", function() {
-				return expect(this.metrics.inc.calledWith('editor.join-project.disconnected', 1, {status: 'immediately'})).to.equal(true);
-			});
-		});
-
-		return describe("when the client disconnects while WebApiManager.joinProject is running", function() {
-			beforeEach(function() {
-				this.WebApiManager.joinProject = (project, user, cb) => {
-					this.client.disconnected = true;
-					return cb(null, this.project, this.privilegeLevel, this.isRestrictedUser);
-				};
-
-				return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
-			});
-
-			it("should call the callback with no details", function() {
-				return expect(this.callback.args[0]).to.deep.equal([]);
-		});
-
-			return it("should increment the editor.join-project.disconnected metric with a status", function() {
-				return expect(this.metrics.inc.calledWith('editor.join-project.disconnected', 1, {status: 'after-web-api-call'})).to.equal(true);
-			});
-		});
-	});
-
-	describe("leaveProject", function() {
-		beforeEach(function() {
-			this.DocumentUpdaterManager.flushProjectToMongoAndDelete = sinon.stub().callsArg(1);
-			this.ConnectedUsersManager.markUserAsDisconnected = sinon.stub().callsArg(2);
-			this.WebsocketLoadBalancer.emitToRoom = sinon.stub();
-			this.RoomManager.leaveProjectAndDocs = sinon.stub();
-			this.clientsInRoom = [];
-			this.io = {
-				sockets: {
-					clients: room_id => {
-						if (room_id !== this.project_id) {
-							throw "expected room_id to be project_id";
-						}
-						return this.clientsInRoom;
-					}
-				}
-			};
-			this.client.ol_context.project_id = this.project_id;
-			this.client.ol_context.user_id = this.user_id;
-			this.WebsocketController.FLUSH_IF_EMPTY_DELAY = 0;
-			return tk.reset();
-		}); // Allow setTimeout to work.
-
-		describe("when the client did not joined a project yet", function() {
-			beforeEach(function(done) {
-				this.client.ol_context = {};
-				return this.WebsocketController.leaveProject(this.io, this.client, done);
-			});
-
-			it("should bail out when calling leaveProject", function() {
-				this.WebsocketLoadBalancer.emitToRoom.called.should.equal(false);
-				this.RoomManager.leaveProjectAndDocs.called.should.equal(false);
-				return this.ConnectedUsersManager.markUserAsDisconnected.called.should.equal(false);
-			});
-
-			return it("should not inc any metric", function() {
-				return this.metrics.inc.called.should.equal(false);
-			});
-		});
-
-		describe("when the project is empty", function() {
-			beforeEach(function(done) {
-				this.clientsInRoom = [];
-				return this.WebsocketController.leaveProject(this.io, this.client, done);
-			});
-
-			it("should end clientTracking.clientDisconnected to the project room", function() {
-				return this.WebsocketLoadBalancer.emitToRoom
-					.calledWith(this.project_id, "clientTracking.clientDisconnected", this.client.publicId)
-					.should.equal(true);
-			});
-
-			it("should mark the user as disconnected", function() {
-				return this.ConnectedUsersManager.markUserAsDisconnected
-					.calledWith(this.project_id, this.client.publicId)
-					.should.equal(true);
-			});
-
-			it("should flush the project in the document updater", function() {
-				return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
-					.calledWith(this.project_id)
-					.should.equal(true);
-			});
-
-			it("should increment the leave-project metric", function() {
-				return this.metrics.inc.calledWith("editor.leave-project").should.equal(true);
-			});
-
-			return it("should track the disconnection in RoomManager", function() {
-				return this.RoomManager.leaveProjectAndDocs
-					.calledWith(this.client)
-					.should.equal(true);
-			});
-		});
-
-		describe("when the project is not empty", function() {
-			beforeEach(function() {
-				this.clientsInRoom = ["mock-remaining-client"];
-				return this.WebsocketController.leaveProject(this.io, this.client);
-			});
-
-			return it("should not flush the project in the document updater", function() {
-				return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
-					.called.should.equal(false);
-			});
-		});
-
-		describe("when client has not authenticated", function() {
-			beforeEach(function(done) {
-				this.client.ol_context.user_id = null;
-				this.client.ol_context.project_id = null;
-				return this.WebsocketController.leaveProject(this.io, this.client, done);
-			});
-
-			it("should not end clientTracking.clientDisconnected to the project room", function() {
-				return this.WebsocketLoadBalancer.emitToRoom
-					.calledWith(this.project_id, "clientTracking.clientDisconnected", this.client.publicId)
-					.should.equal(false);
-			});
-
-			it("should not mark the user as disconnected", function() {
-				return this.ConnectedUsersManager.markUserAsDisconnected
-					.calledWith(this.project_id, this.client.publicId)
-					.should.equal(false);
-			});
-
-			it("should not flush the project in the document updater", function() {
-				return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
-					.calledWith(this.project_id)
-					.should.equal(false);
-			});
-
-			return it("should not increment the leave-project metric", function() {
-				return this.metrics.inc.calledWith("editor.leave-project").should.equal(false);
-			});
-		});
-
-		return describe("when client has not joined a project", function() {
-			beforeEach(function(done) {
-				this.client.ol_context.user_id = this.user_id;
-				this.client.ol_context.project_id = null;
-				return this.WebsocketController.leaveProject(this.io, this.client, done);
-			});
-
-			it("should not end clientTracking.clientDisconnected to the project room", function() {
-				return this.WebsocketLoadBalancer.emitToRoom
-					.calledWith(this.project_id, "clientTracking.clientDisconnected", this.client.publicId)
-					.should.equal(false);
-			});
-
-			it("should not mark the user as disconnected", function() {
-				return this.ConnectedUsersManager.markUserAsDisconnected
-					.calledWith(this.project_id, this.client.publicId)
-					.should.equal(false);
-			});
-
-			it("should not flush the project in the document updater", function() {
-				return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
-					.calledWith(this.project_id)
-					.should.equal(false);
-			});
-
-			return it("should not increment the leave-project metric", function() {
-				return this.metrics.inc.calledWith("editor.leave-project").should.equal(false);
-			});
-		});
-	});
-
-	describe("joinDoc", function() {
-		beforeEach(function() {
-			this.doc_id = "doc-id-123";
-			this.doc_lines = ["doc", "lines"];
-			this.version = 42;
-			this.ops = ["mock", "ops"];
-			this.ranges = { "mock": "ranges" };
-			this.options = {};
-
-			this.client.ol_context.project_id = this.project_id;
-			this.client.ol_context.is_restricted_user = false;
-			this.AuthorizationManager.addAccessToDoc = sinon.stub();
-			this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null);
-			this.DocumentUpdaterManager.getDocument = sinon.stub().callsArgWith(3, null, this.doc_lines, this.version, this.ranges, this.ops);
-			return this.RoomManager.joinDoc = sinon.stub().callsArg(2);
-		});
-
-		describe("works", function() {
-			beforeEach(function() {
-				return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
-			});
-
-			it("should check that the client is authorized to view the project", function() {
-				return this.AuthorizationManager.assertClientCanViewProject
-					.calledWith(this.client)
-					.should.equal(true);
-			});
-
-			it("should get the document from the DocumentUpdaterManager with fromVersion", function() {
-				return this.DocumentUpdaterManager.getDocument
-					.calledWith(this.project_id, this.doc_id, -1)
-					.should.equal(true);
-			});
-
-			it("should add permissions for the client to access the doc", function() {
-				return this.AuthorizationManager.addAccessToDoc
-					.calledWith(this.client, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should join the client to room for the doc_id", function() {
-				return this.RoomManager.joinDoc
-					.calledWith(this.client, this.doc_id)
-					.should.equal(true);
-			});
-
-			it("should call the callback with the lines, version, ranges and ops", function() {
-				return this.callback
-					.calledWith(null, this.doc_lines, this.version, this.ops, this.ranges)
-					.should.equal(true);
-			});
-
-			return it("should increment the join-doc metric", function() {
-				return this.metrics.inc.calledWith("editor.join-doc").should.equal(true);
-			});
-		});
-
-		describe("with a fromVersion", function() {
-			beforeEach(function() {
-				this.fromVersion = 40;
-				return this.WebsocketController.joinDoc(this.client, this.doc_id, this.fromVersion, this.options, this.callback);
-			});
-
-			return it("should get the document from the DocumentUpdaterManager with fromVersion", function() {
-				return this.DocumentUpdaterManager.getDocument
-					.calledWith(this.project_id, this.doc_id, this.fromVersion)
-					.should.equal(true);
-			});
-		});
-
-		describe("with doclines that need escaping", function() {
-			beforeEach(function() {
-				this.doc_lines.push(["räksmörgås"]);
-				return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
-			});
-
-			return it("should call the callback with the escaped lines", function() {
-				const escaped_lines = this.callback.args[0][1];
-				const escaped_word = escaped_lines.pop();
-				escaped_word.should.equal('räksmörgås');
-				// Check that unescaping works
-				return decodeURIComponent(escape(escaped_word)).should.equal("räksmörgås");
-			});
-		});
-
-		describe("with comments that need encoding", function() {
-			beforeEach(function() {
-				this.ranges.comments = [{ op: { c: "räksmörgås" } }];
-				return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, { encodeRanges: true }, this.callback);
-			});
-
-			return it("should call the callback with the encoded comment", function() {
-				const encoded_comments = this.callback.args[0][4];
-				const encoded_comment = encoded_comments.comments.pop();
-				const encoded_comment_text = encoded_comment.op.c;
-				return encoded_comment_text.should.equal('räksmörgås');
-			});
-		});
-
-		describe("with changes that need encoding", function() {
-			it("should call the callback with the encoded insert change", function() {
-				this.ranges.changes = [{ op: { i: "räksmörgås" } }];
-				this.WebsocketController.joinDoc(this.client, this.doc_id, -1, { encodeRanges: true }, this.callback);
-
-				const encoded_changes = this.callback.args[0][4];
-				const encoded_change = encoded_changes.changes.pop();
-				const encoded_change_text = encoded_change.op.i;
-				return encoded_change_text.should.equal('räksmörgås');
-			});
-
-			return it("should call the callback with the encoded delete change", function() {
-				this.ranges.changes = [{ op: { d: "räksmörgås" } }];
-				this.WebsocketController.joinDoc(this.client, this.doc_id, -1, { encodeRanges: true }, this.callback);
-
-				const encoded_changes = this.callback.args[0][4];
-				const encoded_change = encoded_changes.changes.pop();
-				const encoded_change_text = encoded_change.op.d;
-				return encoded_change_text.should.equal('räksmörgås');
-			});
-		});
-
-		describe("when not authorized", function() {
-			beforeEach(function() {
-				this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, (this.err = new Error("not authorized")));
-				return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
-			});
-
-			it("should call the callback with an error", function() {
-				return this.callback.calledWith(sinon.match({message: "not authorized"})).should.equal(true);
-			});
-
-			return it("should not call the DocumentUpdaterManager", function() {
-				return this.DocumentUpdaterManager.getDocument.called.should.equal(false);
-			});
-		});
-
-		describe("with a restricted client", function() {
-			beforeEach(function() {
-				this.ranges.comments = [{op: {a: 1}}, {op: {a: 2}}];
-				this.client.ol_context.is_restricted_user = true;
-				return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
-			});
-
-			return it("should overwrite ranges.comments with an empty list", function() {
-				const ranges = this.callback.args[0][4];
-				return expect(ranges.comments).to.deep.equal([]);
-		});
-	});
-
-		describe("when the client has disconnected", function() {
-			beforeEach(function() {
-				this.client.disconnected = true;
-				return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
-			});
-
-			it("should call the callback with no details", function() {
-				return expect(this.callback.args[0]).to.deep.equal([]);
-			});
-
-			it("should increment the editor.join-doc.disconnected metric with a status", function() {
-				return expect(this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {status: 'immediately'})).to.equal(true);
-			});
-
-			return it("should not get the document", function() {
-				return expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false);
-			});
-		});
-
-		describe("when the client disconnects while RoomManager.joinDoc is running", function() {
-			beforeEach(function() {
-				this.RoomManager.joinDoc = (client, doc_id, cb) => {
-					this.client.disconnected = true;
-					return cb();
-				};
-
-				return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
-			});
-
-			it("should call the callback with no details", function() {
-				return expect(this.callback.args[0]).to.deep.equal([]);
-			});
-
-			it("should increment the editor.join-doc.disconnected metric with a status", function() {
-				return expect(this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {status: 'after-joining-room'})).to.equal(true);
-			});
-
-			return it("should not get the document", function() {
-				return expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false);
-			});
-		});
-
-		return describe("when the client disconnects while DocumentUpdaterManager.getDocument is running", function() {
-			beforeEach(function() {
-				this.DocumentUpdaterManager.getDocument = (project_id, doc_id, fromVersion, callback) => {
-					this.client.disconnected = true;
-					return callback(null, this.doc_lines, this.version, this.ranges, this.ops);
-				};
-
-				return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
-			});
-
-			it("should call the callback with no details", function() {
-				return expect(this.callback.args[0]).to.deep.equal([]);
-		});
-
-			return it("should increment the editor.join-doc.disconnected metric with a status", function() {
-				return expect(this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {status: 'after-doc-updater-call'})).to.equal(true);
-			});
-		});
-	});
-
-	describe("leaveDoc", function() {
-		beforeEach(function() {
-			this.doc_id = "doc-id-123";
-			this.client.ol_context.project_id = this.project_id;
-			this.RoomManager.leaveDoc = sinon.stub();
-			return this.WebsocketController.leaveDoc(this.client, this.doc_id, this.callback);
-		});
-
-		it("should remove the client from the doc_id room", function() {
-			return this.RoomManager.leaveDoc
-				.calledWith(this.client, this.doc_id).should.equal(true);
-		});
-
-		it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-
-		return it("should increment the leave-doc metric", function() {
-			return this.metrics.inc.calledWith("editor.leave-doc").should.equal(true);
-		});
-	});
-
-	describe("getConnectedUsers", function() {
-		beforeEach(function() {
-			this.client.ol_context.project_id = this.project_id;
-			this.users = ["mock", "users"];
-			this.WebsocketLoadBalancer.emitToRoom = sinon.stub();
-			return this.ConnectedUsersManager.getConnectedUsers = sinon.stub().callsArgWith(1, null, this.users);
-		});
-
-		describe("when authorized", function() {
-			beforeEach(function(done) {
-				this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null);
-				return this.WebsocketController.getConnectedUsers(this.client, (...args) => {
-					this.callback(...Array.from(args || []));
-					return done();
-				});
-			});
-
-			it("should check that the client is authorized to view the project", function() {
-				return this.AuthorizationManager.assertClientCanViewProject
-					.calledWith(this.client)
-					.should.equal(true);
-			});
-
-			it("should broadcast a request to update the client list", function() {
-				return this.WebsocketLoadBalancer.emitToRoom
-					.calledWith(this.project_id, "clientTracking.refresh")
-					.should.equal(true);
-			});
-
-			it("should get the connected users for the project", function() {
-				return this.ConnectedUsersManager.getConnectedUsers
-					.calledWith(this.project_id)
-					.should.equal(true);
-			});
-
-			it("should return the users", function() {
-				return this.callback.calledWith(null, this.users).should.equal(true);
-			});
-
-			return it("should increment the get-connected-users metric", function() {
-				return this.metrics.inc.calledWith("editor.get-connected-users").should.equal(true);
-			});
-		});
-
-		describe("when not authorized", function() {
-			beforeEach(function() {
-				this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, (this.err = new Error("not authorized")));
-				return this.WebsocketController.getConnectedUsers(this.client, this.callback);
-			});
-
-			it("should not get the connected users for the project", function() {
-				return this.ConnectedUsersManager.getConnectedUsers
-					.called
-					.should.equal(false);
-			});
-
-			return it("should return an error", function() {
-				return this.callback.calledWith(this.err).should.equal(true);
-			});
-		});
-
-		describe("when restricted user", function() {
-			beforeEach(function() {
-				this.client.ol_context.is_restricted_user = true;
-				this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null);
-				return this.WebsocketController.getConnectedUsers(this.client, this.callback);
-			});
-
-			it("should return an empty array of users", function() {
-				return this.callback.calledWith(null, []).should.equal(true);
-			});
-
-			return it("should not get the connected users for the project", function() {
-				return this.ConnectedUsersManager.getConnectedUsers
-					.called
-					.should.equal(false);
-			});
-		});
-
-		return describe("when the client has disconnected", function() {
-			beforeEach(function() {
-				this.client.disconnected = true;
-				this.AuthorizationManager.assertClientCanViewProject = sinon.stub();
-				return this.WebsocketController.getConnectedUsers(this.client, this.callback);
-			});
-
-			it("should call the callback with no details", function() {
-				return expect(this.callback.args[0]).to.deep.equal([]);
-			});
-
-			return it("should not check permissions", function() {
-				return expect(this.AuthorizationManager.assertClientCanViewProject.called).to.equal(false);
-			});
-		});
-	});
-
-	describe("updateClientPosition", function() {
-		beforeEach(function() {
-			this.WebsocketLoadBalancer.emitToRoom = sinon.stub();
-			this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArgWith(4);
-			this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub().callsArgWith(2, null);
-			return this.update = {
-				doc_id: (this.doc_id = "doc-id-123"),
-				row: (this.row = 42),
-				column: (this.column = 37)
-			};});
-
-		describe("with a logged in user", function() {
-			beforeEach(function() {
-				this.client.ol_context = {
-					project_id: this.project_id,
-					first_name: (this.first_name = "Douglas"),
-					last_name: (this.last_name = "Adams"),
-					email: (this.email = "joe@example.com"),
-					user_id: (this.user_id = "user-id-123")
-				};
-				this.WebsocketController.updateClientPosition(this.client, this.update);
-
-				return this.populatedCursorData = {
-					doc_id: this.doc_id,
-					id: this.client.publicId,
-					name: `${this.first_name} ${this.last_name}`,
-					row: this.row,
-					column: this.column,
-					email: this.email,
-					user_id: this.user_id
-				};
-			});
-
-			it("should send the update to the project room with the user's name", function() {
-				return this.WebsocketLoadBalancer.emitToRoom.calledWith(this.project_id, "clientTracking.clientUpdated", this.populatedCursorData).should.equal(true);
-			});
-
-			it("should send the  cursor data to the connected user manager", function(done){
-				this.ConnectedUsersManager.updateUserPosition.calledWith(this.project_id, this.client.publicId, {
-					_id: this.user_id,
-					email: this.email,
-					first_name: this.first_name,
-					last_name: this.last_name
-				}, {
-					row: this.row,
-					column: this.column,
-					doc_id: this.doc_id
-				}).should.equal(true);
-				return done();
-			});
-
-			return it("should increment the update-client-position metric at 0.1 frequency", function() {
-				return this.metrics.inc.calledWith("editor.update-client-position", 0.1).should.equal(true);
-			});
-		});
-
-		describe("with a logged in user who has no last_name set", function() {
-			beforeEach(function() {
-				this.client.ol_context = {
-					project_id: this.project_id,
-					first_name: (this.first_name = "Douglas"),
-					last_name: undefined,
-					email: (this.email = "joe@example.com"),
-					user_id: (this.user_id = "user-id-123")
-				};
-				this.WebsocketController.updateClientPosition(this.client, this.update);
-
-				return this.populatedCursorData = {
-					doc_id: this.doc_id,
-					id: this.client.publicId,
-					name: `${this.first_name}`,
-					row: this.row,
-					column: this.column,
-					email: this.email,
-					user_id: this.user_id
-				};
-			});
-
-			it("should send the update to the project room with the user's name", function() {
-				return this.WebsocketLoadBalancer.emitToRoom.calledWith(this.project_id, "clientTracking.clientUpdated", this.populatedCursorData).should.equal(true);
-			});
-
-			it("should send the  cursor data to the connected user manager", function(done){
-				this.ConnectedUsersManager.updateUserPosition.calledWith(this.project_id, this.client.publicId, {
-					_id: this.user_id,
-					email: this.email,
-					first_name: this.first_name,
-					last_name: undefined
-				}, {
-					row: this.row,
-					column: this.column,
-					doc_id: this.doc_id
-				}).should.equal(true);
-				return done();
-			});
-
-			return it("should increment the update-client-position metric at 0.1 frequency", function() {
-				return this.metrics.inc.calledWith("editor.update-client-position", 0.1).should.equal(true);
-			});
-		});
-
-		describe("with a logged in user who has no first_name set", function() {
-			beforeEach(function() {
-				this.client.ol_context = {
-					project_id: this.project_id,
-					first_name: undefined,
-					last_name: (this.last_name = "Adams"),
-					email: (this.email = "joe@example.com"),
-					user_id: (this.user_id = "user-id-123")
-				};
-				this.WebsocketController.updateClientPosition(this.client, this.update);
-
-				return this.populatedCursorData = {
-					doc_id: this.doc_id,
-					id: this.client.publicId,
-					name: `${this.last_name}`,
-					row: this.row,
-					column: this.column,
-					email: this.email,
-					user_id: this.user_id
-				};
-			});
-
-			it("should send the update to the project room with the user's name", function() {
-				return this.WebsocketLoadBalancer.emitToRoom.calledWith(this.project_id, "clientTracking.clientUpdated", this.populatedCursorData).should.equal(true);
-			});
-
-			it("should send the  cursor data to the connected user manager", function(done){
-				this.ConnectedUsersManager.updateUserPosition.calledWith(this.project_id, this.client.publicId, {
-					_id: this.user_id,
-					email: this.email,
-					first_name: undefined,
-					last_name: this.last_name
-				}, {
-					row: this.row,
-					column: this.column,
-					doc_id: this.doc_id
-				}).should.equal(true);
-				return done();
-			});
-
-			return it("should increment the update-client-position metric at 0.1 frequency", function() {
-				return this.metrics.inc.calledWith("editor.update-client-position", 0.1).should.equal(true);
-			});
-		});
-		describe("with a logged in user who has no names set", function() {
-			beforeEach(function() {
-				this.client.ol_context = {
-					project_id: this.project_id,
-					first_name: undefined,
-					last_name: undefined,
-					email: (this.email = "joe@example.com"),
-					user_id: (this.user_id = "user-id-123")
-				};
-				return this.WebsocketController.updateClientPosition(this.client, this.update);
-			});
-
-			return it("should send the update to the project name with no name", function() {
-				return this.WebsocketLoadBalancer.emitToRoom
-					.calledWith(this.project_id, "clientTracking.clientUpdated", {
-						doc_id: this.doc_id,
-						id: this.client.publicId,
-						user_id: this.user_id,
-						name: "",
-						row: this.row,
-						column: this.column,
-						email: this.email
-					})
-					.should.equal(true);
-			});
-		});
-
-
-		describe("with an anonymous user", function() {
-			beforeEach(function() {
-				this.client.ol_context = {
-					project_id: this.project_id
-				};
-				return this.WebsocketController.updateClientPosition(this.client, this.update);
-			});
-
-			it("should send the update to the project room with no name", function() {
-				return this.WebsocketLoadBalancer.emitToRoom
-					.calledWith(this.project_id, "clientTracking.clientUpdated", {
-						doc_id: this.doc_id,
-						id: this.client.publicId,
-						name: "",
-						row: this.row,
-						column: this.column
-					})
-					.should.equal(true);
-			});
-
-			return it("should not send cursor data to the connected user manager", function(done){
-				this.ConnectedUsersManager.updateUserPosition.called.should.equal(false);
-				return done();
-			});
-		});
-
-		return describe("when the client has disconnected", function() {
-			beforeEach(function() {
-				this.client.disconnected = true;
-				this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub();
-				return this.WebsocketController.updateClientPosition(this.client, this.update, this.callback);
-			});
-
-			it("should call the callback with no details", function() {
-				return expect(this.callback.args[0]).to.deep.equal([]);
-			});
-
-			return it("should not check permissions", function() {
-				return expect(this.AuthorizationManager.assertClientCanViewProjectAndDoc.called).to.equal(false);
-			});
-		});
-	});
-
-	describe("applyOtUpdate", function() {
-		beforeEach(function() {
-			this.update = {op: {p: 12, t: "foo"}};
-			this.client.ol_context.user_id = this.user_id;
-			this.client.ol_context.project_id = this.project_id;
-			this.WebsocketController._assertClientCanApplyUpdate = sinon.stub().yields();
-			return this.DocumentUpdaterManager.queueChange = sinon.stub().callsArg(3);
-		});
-
-		describe("succesfully", function() {
-			beforeEach(function() {
-				return this.WebsocketController.applyOtUpdate(this.client, this.doc_id, this.update, this.callback);
-			});
-
-			it("should set the source of the update to the client id", function() {
-				return this.update.meta.source.should.equal(this.client.publicId);
-			});
-
-			it("should set the user_id of the update to the user id", function() {
-				return this.update.meta.user_id.should.equal(this.user_id);
-			});
-
-			it("should queue the update", function() {
-				return this.DocumentUpdaterManager.queueChange
-					.calledWith(this.project_id, this.doc_id, this.update)
-					.should.equal(true);
-			});
-
-			it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-
-			return it("should increment the doc updates", function() {
-				return this.metrics.inc.calledWith("editor.doc-update").should.equal(true);
-			});
-		});
-
-		describe("unsuccessfully", function() {
-			beforeEach(function() {
-				this.client.disconnect = sinon.stub();
-				this.DocumentUpdaterManager.queueChange = sinon.stub().callsArgWith(3, (this.error = new Error("Something went wrong")));
-				return this.WebsocketController.applyOtUpdate(this.client, this.doc_id, this.update, this.callback);
-			});
-
-			it("should disconnect the client", function() {
-				return this.client.disconnect.called.should.equal(true);
-			});
-
-			it("should log an error", function() {
-				return this.logger.error.called.should.equal(true);
-			});
-
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		describe("when not authorized", function() {
-			beforeEach(function() {
-				this.client.disconnect = sinon.stub();
-				this.WebsocketController._assertClientCanApplyUpdate = sinon.stub().yields(this.error = new Error("not authorized"));
-				return this.WebsocketController.applyOtUpdate(this.client, this.doc_id, this.update, this.callback);
-			});
-
-			// This happens in a setTimeout to allow the client a chance to receive the error first.
-			// I'm not sure how to unit test, but it is acceptance tested.
-			// it "should disconnect the client", ->
-			// 	@client.disconnect.called.should.equal true
-
-			it("should log a warning", function() {
-				return this.logger.warn.called.should.equal(true);
-			});
-
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWith(this.error).should.equal(true);
-			});
-		});
-
-		return describe("update_too_large", function() {
-			beforeEach(function(done) {
-				this.client.disconnect = sinon.stub();
-				this.client.emit = sinon.stub();
-				this.client.ol_context.user_id = this.user_id;
-				this.client.ol_context.project_id = this.project_id;
-				const error = new Error("update is too large");
-				error.updateSize = 7372835;
-				this.DocumentUpdaterManager.queueChange = sinon.stub().callsArgWith(3, error);
-				this.WebsocketController.applyOtUpdate(this.client, this.doc_id, this.update, this.callback);
-				return setTimeout(() => done()
-				, 1);
-			});
-
-			it("should call the callback with no error", function() {
-				this.callback.called.should.equal(true);
-				return this.callback.args[0].should.deep.equal([]);
-		});
-
-			it("should log a warning with the size and context", function() {
-				this.logger.warn.called.should.equal(true);
-				return this.logger.warn.args[0].should.deep.equal([{
-					user_id: this.user_id, project_id: this.project_id, doc_id: this.doc_id, updateSize: 7372835
-				}, 'update is too large']);
-		});
-
-			describe("after 100ms", function() {
-				beforeEach(function(done) { return setTimeout(done, 100); });
-
-				it("should send an otUpdateError the client", function() {
-					return this.client.emit.calledWith('otUpdateError').should.equal(true);
-				});
-
-				return it("should disconnect the client", function() {
-					return this.client.disconnect.called.should.equal(true);
-				});
-			});
-
-			return describe("when the client disconnects during the next 100ms", function() {
-				beforeEach(function(done) {
-					this.client.disconnected = true;
-					return setTimeout(done, 100);
-				});
-
-				it("should not send an otUpdateError the client", function() {
-					return this.client.emit.calledWith('otUpdateError').should.equal(false);
-				});
-
-				it("should not disconnect the client", function() {
-					return this.client.disconnect.called.should.equal(false);
-				});
-
-				return it("should increment the editor.doc-update.disconnected metric with a status", function() {
-					return expect(this.metrics.inc.calledWith('editor.doc-update.disconnected', 1, {status:'at-otUpdateError'})).to.equal(true);
-				});
-			});
-		});
-	});
-
-	return describe("_assertClientCanApplyUpdate", function() {
-		beforeEach(function() {
-			this.edit_update = { op: [{i: "foo", p: 42}, {c: "bar", p: 132}] }; // comments may still be in an edit op
-			this.comment_update = { op: [{c: "bar", p: 132}] };
-			this.AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub();
-			return this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub();
-		});
-
-		describe("with a read-write client", function() { return it("should return successfully", function(done) {
-            this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null);
-            return this.WebsocketController._assertClientCanApplyUpdate(this.client, this.doc_id, this.edit_update, (error) => {
-                expect(error).to.be.null;
-                return done();
-            });
-        }); });
-
-		describe("with a read-only client and an edit op", function() { return it("should return an error", function(done) {
-            this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(new Error("not authorized"));
-            this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null);
-            return this.WebsocketController._assertClientCanApplyUpdate(this.client, this.doc_id, this.edit_update, (error) => {
-                expect(error.message).to.equal("not authorized");
-                return done();
-            });
-        }); });
-
-		describe("with a read-only client and a comment op", function() { return it("should return successfully", function(done) {
-            this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(new Error("not authorized"));
-            this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null);
-            return this.WebsocketController._assertClientCanApplyUpdate(this.client, this.doc_id, this.comment_update, (error) => {
-                expect(error).to.be.null;
-                return done();
-            });
-        }); });
-
-		return describe("with a totally unauthorized client", function() { return it("should return an error", function(done) {
-            this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(new Error("not authorized"));
-            this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(new Error("not authorized"));
-            return this.WebsocketController._assertClientCanApplyUpdate(this.client, this.doc_id, this.comment_update, (error) => {
-                expect(error.message).to.equal("not authorized");
-                return done();
-            });
-        }); });
-	});
-});
+const chai = require('chai')
+const should = chai.should()
+const sinon = require('sinon')
+const { expect } = chai
+const modulePath = '../../../app/js/WebsocketController.js'
+const SandboxedModule = require('sandboxed-module')
+const tk = require('timekeeper')
+
+describe('WebsocketController', function () {
+  beforeEach(function () {
+    tk.freeze(new Date())
+    this.project_id = 'project-id-123'
+    this.user = {
+      _id: (this.user_id = 'user-id-123'),
+      first_name: 'James',
+      last_name: 'Allen',
+      email: 'james@example.com',
+      signUpDate: new Date('2014-01-01'),
+      loginCount: 42
+    }
+    this.callback = sinon.stub()
+    this.client = {
+      disconnected: false,
+      id: (this.client_id = 'mock-client-id-123'),
+      publicId: `other-id-${Math.random()}`,
+      ol_context: {},
+      join: sinon.stub(),
+      leave: sinon.stub()
+    }
+    return (this.WebsocketController = SandboxedModule.require(modulePath, {
+      requires: {
+        './WebApiManager': (this.WebApiManager = {}),
+        './AuthorizationManager': (this.AuthorizationManager = {}),
+        './DocumentUpdaterManager': (this.DocumentUpdaterManager = {}),
+        './ConnectedUsersManager': (this.ConnectedUsersManager = {}),
+        './WebsocketLoadBalancer': (this.WebsocketLoadBalancer = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub(),
+          warn: sinon.stub()
+        }),
+        'metrics-sharelatex': (this.metrics = {
+          inc: sinon.stub(),
+          set: sinon.stub()
+        }),
+        './RoomManager': (this.RoomManager = {})
+      }
+    }))
+  })
+
+  afterEach(function () {
+    return tk.reset()
+  })
+
+  describe('joinProject', function () {
+    describe('when authorised', function () {
+      beforeEach(function () {
+        this.client.id = 'mock-client-id'
+        this.project = {
+          name: 'Test Project',
+          owner: {
+            _id: (this.owner_id = 'mock-owner-id-123')
+          }
+        }
+        this.privilegeLevel = 'owner'
+        this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4)
+        this.isRestrictedUser = true
+        this.WebApiManager.joinProject = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.project,
+            this.privilegeLevel,
+            this.isRestrictedUser
+          )
+        this.RoomManager.joinProject = sinon.stub().callsArg(2)
+        return this.WebsocketController.joinProject(
+          this.client,
+          this.user,
+          this.project_id,
+          this.callback
+        )
+      })
+
+      it('should load the project from web', function () {
+        return this.WebApiManager.joinProject
+          .calledWith(this.project_id, this.user)
+          .should.equal(true)
+      })
+
+      it('should join the project room', function () {
+        return this.RoomManager.joinProject
+          .calledWith(this.client, this.project_id)
+          .should.equal(true)
+      })
+
+      it('should set the privilege level on the client', function () {
+        return this.client.ol_context.privilege_level.should.equal(
+          this.privilegeLevel
+        )
+      })
+      it("should set the user's id on the client", function () {
+        return this.client.ol_context.user_id.should.equal(this.user._id)
+      })
+      it("should set the user's email on the client", function () {
+        return this.client.ol_context.email.should.equal(this.user.email)
+      })
+      it("should set the user's first_name on the client", function () {
+        return this.client.ol_context.first_name.should.equal(
+          this.user.first_name
+        )
+      })
+      it("should set the user's last_name on the client", function () {
+        return this.client.ol_context.last_name.should.equal(
+          this.user.last_name
+        )
+      })
+      it("should set the user's sign up date on the client", function () {
+        return this.client.ol_context.signup_date.should.equal(
+          this.user.signUpDate
+        )
+      })
+      it("should set the user's login_count on the client", function () {
+        return this.client.ol_context.login_count.should.equal(
+          this.user.loginCount
+        )
+      })
+      it('should set the connected time on the client', function () {
+        return this.client.ol_context.connected_time.should.equal(new Date())
+      })
+      it('should set the project_id on the client', function () {
+        return this.client.ol_context.project_id.should.equal(this.project_id)
+      })
+      it('should set the project owner id on the client', function () {
+        return this.client.ol_context.owner_id.should.equal(this.owner_id)
+      })
+      it('should set the is_restricted_user flag on the client', function () {
+        return this.client.ol_context.is_restricted_user.should.equal(
+          this.isRestrictedUser
+        )
+      })
+      it('should call the callback with the project, privilegeLevel and protocolVersion', function () {
+        return this.callback
+          .calledWith(
+            null,
+            this.project,
+            this.privilegeLevel,
+            this.WebsocketController.PROTOCOL_VERSION
+          )
+          .should.equal(true)
+      })
+
+      it('should mark the user as connected in ConnectedUsersManager', function () {
+        return this.ConnectedUsersManager.updateUserPosition
+          .calledWith(this.project_id, this.client.publicId, this.user, null)
+          .should.equal(true)
+      })
+
+      return it('should increment the join-project metric', function () {
+        return this.metrics.inc
+          .calledWith('editor.join-project')
+          .should.equal(true)
+      })
+    })
+
+    describe('when not authorized', function () {
+      beforeEach(function () {
+        this.WebApiManager.joinProject = sinon
+          .stub()
+          .callsArgWith(2, null, null, null)
+        return this.WebsocketController.joinProject(
+          this.client,
+          this.user,
+          this.project_id,
+          this.callback
+        )
+      })
+
+      it('should return an error', function () {
+        return this.callback
+          .calledWith(sinon.match({ message: 'not authorized' }))
+          .should.equal(true)
+      })
+
+      return it('should not log an error', function () {
+        return this.logger.error.called.should.equal(false)
+      })
+    })
+
+    describe('when the subscribe failed', function () {
+      beforeEach(function () {
+        this.client.id = 'mock-client-id'
+        this.project = {
+          name: 'Test Project',
+          owner: {
+            _id: (this.owner_id = 'mock-owner-id-123')
+          }
+        }
+        this.privilegeLevel = 'owner'
+        this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4)
+        this.isRestrictedUser = true
+        this.WebApiManager.joinProject = sinon
+          .stub()
+          .callsArgWith(
+            2,
+            null,
+            this.project,
+            this.privilegeLevel,
+            this.isRestrictedUser
+          )
+        this.RoomManager.joinProject = sinon
+          .stub()
+          .callsArgWith(2, new Error('subscribe failed'))
+        return this.WebsocketController.joinProject(
+          this.client,
+          this.user,
+          this.project_id,
+          this.callback
+        )
+      })
+
+      return it('should return an error', function () {
+        this.callback
+          .calledWith(sinon.match({ message: 'subscribe failed' }))
+          .should.equal(true)
+        return this.callback.args[0][0].message.should.equal('subscribe failed')
+      })
+    })
+
+    describe('when the client has disconnected', function () {
+      beforeEach(function () {
+        this.client.disconnected = true
+        this.WebApiManager.joinProject = sinon.stub().callsArg(2)
+        return this.WebsocketController.joinProject(
+          this.client,
+          this.user,
+          this.project_id,
+          this.callback
+        )
+      })
+
+      it('should not call WebApiManager.joinProject', function () {
+        return expect(this.WebApiManager.joinProject.called).to.equal(false)
+      })
+
+      it('should call the callback with no details', function () {
+        return expect(this.callback.args[0]).to.deep.equal([])
+      })
+
+      return it('should increment the editor.join-project.disconnected metric with a status', function () {
+        return expect(
+          this.metrics.inc.calledWith('editor.join-project.disconnected', 1, {
+            status: 'immediately'
+          })
+        ).to.equal(true)
+      })
+    })
+
+    return describe('when the client disconnects while WebApiManager.joinProject is running', function () {
+      beforeEach(function () {
+        this.WebApiManager.joinProject = (project, user, cb) => {
+          this.client.disconnected = true
+          return cb(
+            null,
+            this.project,
+            this.privilegeLevel,
+            this.isRestrictedUser
+          )
+        }
+
+        return this.WebsocketController.joinProject(
+          this.client,
+          this.user,
+          this.project_id,
+          this.callback
+        )
+      })
+
+      it('should call the callback with no details', function () {
+        return expect(this.callback.args[0]).to.deep.equal([])
+      })
+
+      return it('should increment the editor.join-project.disconnected metric with a status', function () {
+        return expect(
+          this.metrics.inc.calledWith('editor.join-project.disconnected', 1, {
+            status: 'after-web-api-call'
+          })
+        ).to.equal(true)
+      })
+    })
+  })
+
+  describe('leaveProject', function () {
+    beforeEach(function () {
+      this.DocumentUpdaterManager.flushProjectToMongoAndDelete = sinon
+        .stub()
+        .callsArg(1)
+      this.ConnectedUsersManager.markUserAsDisconnected = sinon
+        .stub()
+        .callsArg(2)
+      this.WebsocketLoadBalancer.emitToRoom = sinon.stub()
+      this.RoomManager.leaveProjectAndDocs = sinon.stub()
+      this.clientsInRoom = []
+      this.io = {
+        sockets: {
+          clients: (room_id) => {
+            if (room_id !== this.project_id) {
+              throw 'expected room_id to be project_id'
+            }
+            return this.clientsInRoom
+          }
+        }
+      }
+      this.client.ol_context.project_id = this.project_id
+      this.client.ol_context.user_id = this.user_id
+      this.WebsocketController.FLUSH_IF_EMPTY_DELAY = 0
+      return tk.reset()
+    }) // Allow setTimeout to work.
+
+    describe('when the client did not joined a project yet', function () {
+      beforeEach(function (done) {
+        this.client.ol_context = {}
+        return this.WebsocketController.leaveProject(this.io, this.client, done)
+      })
+
+      it('should bail out when calling leaveProject', function () {
+        this.WebsocketLoadBalancer.emitToRoom.called.should.equal(false)
+        this.RoomManager.leaveProjectAndDocs.called.should.equal(false)
+        return this.ConnectedUsersManager.markUserAsDisconnected.called.should.equal(
+          false
+        )
+      })
+
+      return it('should not inc any metric', function () {
+        return this.metrics.inc.called.should.equal(false)
+      })
+    })
+
+    describe('when the project is empty', function () {
+      beforeEach(function (done) {
+        this.clientsInRoom = []
+        return this.WebsocketController.leaveProject(this.io, this.client, done)
+      })
+
+      it('should end clientTracking.clientDisconnected to the project room', function () {
+        return this.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            this.project_id,
+            'clientTracking.clientDisconnected',
+            this.client.publicId
+          )
+          .should.equal(true)
+      })
+
+      it('should mark the user as disconnected', function () {
+        return this.ConnectedUsersManager.markUserAsDisconnected
+          .calledWith(this.project_id, this.client.publicId)
+          .should.equal(true)
+      })
+
+      it('should flush the project in the document updater', function () {
+        return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
+          .calledWith(this.project_id)
+          .should.equal(true)
+      })
+
+      it('should increment the leave-project metric', function () {
+        return this.metrics.inc
+          .calledWith('editor.leave-project')
+          .should.equal(true)
+      })
+
+      return it('should track the disconnection in RoomManager', function () {
+        return this.RoomManager.leaveProjectAndDocs
+          .calledWith(this.client)
+          .should.equal(true)
+      })
+    })
+
+    describe('when the project is not empty', function () {
+      beforeEach(function () {
+        this.clientsInRoom = ['mock-remaining-client']
+        return this.WebsocketController.leaveProject(this.io, this.client)
+      })
+
+      return it('should not flush the project in the document updater', function () {
+        return this.DocumentUpdaterManager.flushProjectToMongoAndDelete.called.should.equal(
+          false
+        )
+      })
+    })
+
+    describe('when client has not authenticated', function () {
+      beforeEach(function (done) {
+        this.client.ol_context.user_id = null
+        this.client.ol_context.project_id = null
+        return this.WebsocketController.leaveProject(this.io, this.client, done)
+      })
+
+      it('should not end clientTracking.clientDisconnected to the project room', function () {
+        return this.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            this.project_id,
+            'clientTracking.clientDisconnected',
+            this.client.publicId
+          )
+          .should.equal(false)
+      })
+
+      it('should not mark the user as disconnected', function () {
+        return this.ConnectedUsersManager.markUserAsDisconnected
+          .calledWith(this.project_id, this.client.publicId)
+          .should.equal(false)
+      })
+
+      it('should not flush the project in the document updater', function () {
+        return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
+          .calledWith(this.project_id)
+          .should.equal(false)
+      })
+
+      return it('should not increment the leave-project metric', function () {
+        return this.metrics.inc
+          .calledWith('editor.leave-project')
+          .should.equal(false)
+      })
+    })
+
+    return describe('when client has not joined a project', function () {
+      beforeEach(function (done) {
+        this.client.ol_context.user_id = this.user_id
+        this.client.ol_context.project_id = null
+        return this.WebsocketController.leaveProject(this.io, this.client, done)
+      })
+
+      it('should not end clientTracking.clientDisconnected to the project room', function () {
+        return this.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            this.project_id,
+            'clientTracking.clientDisconnected',
+            this.client.publicId
+          )
+          .should.equal(false)
+      })
+
+      it('should not mark the user as disconnected', function () {
+        return this.ConnectedUsersManager.markUserAsDisconnected
+          .calledWith(this.project_id, this.client.publicId)
+          .should.equal(false)
+      })
+
+      it('should not flush the project in the document updater', function () {
+        return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
+          .calledWith(this.project_id)
+          .should.equal(false)
+      })
+
+      return it('should not increment the leave-project metric', function () {
+        return this.metrics.inc
+          .calledWith('editor.leave-project')
+          .should.equal(false)
+      })
+    })
+  })
+
+  describe('joinDoc', function () {
+    beforeEach(function () {
+      this.doc_id = 'doc-id-123'
+      this.doc_lines = ['doc', 'lines']
+      this.version = 42
+      this.ops = ['mock', 'ops']
+      this.ranges = { mock: 'ranges' }
+      this.options = {}
+
+      this.client.ol_context.project_id = this.project_id
+      this.client.ol_context.is_restricted_user = false
+      this.AuthorizationManager.addAccessToDoc = sinon.stub()
+      this.AuthorizationManager.assertClientCanViewProject = sinon
+        .stub()
+        .callsArgWith(1, null)
+      this.DocumentUpdaterManager.getDocument = sinon
+        .stub()
+        .callsArgWith(
+          3,
+          null,
+          this.doc_lines,
+          this.version,
+          this.ranges,
+          this.ops
+        )
+      return (this.RoomManager.joinDoc = sinon.stub().callsArg(2))
+    })
+
+    describe('works', function () {
+      beforeEach(function () {
+        return this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          this.options,
+          this.callback
+        )
+      })
+
+      it('should check that the client is authorized to view the project', function () {
+        return this.AuthorizationManager.assertClientCanViewProject
+          .calledWith(this.client)
+          .should.equal(true)
+      })
+
+      it('should get the document from the DocumentUpdaterManager with fromVersion', function () {
+        return this.DocumentUpdaterManager.getDocument
+          .calledWith(this.project_id, this.doc_id, -1)
+          .should.equal(true)
+      })
+
+      it('should add permissions for the client to access the doc', function () {
+        return this.AuthorizationManager.addAccessToDoc
+          .calledWith(this.client, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should join the client to room for the doc_id', function () {
+        return this.RoomManager.joinDoc
+          .calledWith(this.client, this.doc_id)
+          .should.equal(true)
+      })
+
+      it('should call the callback with the lines, version, ranges and ops', function () {
+        return this.callback
+          .calledWith(null, this.doc_lines, this.version, this.ops, this.ranges)
+          .should.equal(true)
+      })
+
+      return it('should increment the join-doc metric', function () {
+        return this.metrics.inc.calledWith('editor.join-doc').should.equal(true)
+      })
+    })
+
+    describe('with a fromVersion', function () {
+      beforeEach(function () {
+        this.fromVersion = 40
+        return this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          this.fromVersion,
+          this.options,
+          this.callback
+        )
+      })
+
+      return it('should get the document from the DocumentUpdaterManager with fromVersion', function () {
+        return this.DocumentUpdaterManager.getDocument
+          .calledWith(this.project_id, this.doc_id, this.fromVersion)
+          .should.equal(true)
+      })
+    })
+
+    describe('with doclines that need escaping', function () {
+      beforeEach(function () {
+        this.doc_lines.push(['räksmörgås'])
+        return this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          this.options,
+          this.callback
+        )
+      })
+
+      return it('should call the callback with the escaped lines', function () {
+        const escaped_lines = this.callback.args[0][1]
+        const escaped_word = escaped_lines.pop()
+        escaped_word.should.equal('räksmörgås')
+        // Check that unescaping works
+        return decodeURIComponent(escape(escaped_word)).should.equal(
+          'räksmörgås'
+        )
+      })
+    })
+
+    describe('with comments that need encoding', function () {
+      beforeEach(function () {
+        this.ranges.comments = [{ op: { c: 'räksmörgås' } }]
+        return this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          { encodeRanges: true },
+          this.callback
+        )
+      })
+
+      return it('should call the callback with the encoded comment', function () {
+        const encoded_comments = this.callback.args[0][4]
+        const encoded_comment = encoded_comments.comments.pop()
+        const encoded_comment_text = encoded_comment.op.c
+        return encoded_comment_text.should.equal('räksmörgås')
+      })
+    })
+
+    describe('with changes that need encoding', function () {
+      it('should call the callback with the encoded insert change', function () {
+        this.ranges.changes = [{ op: { i: 'räksmörgås' } }]
+        this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          { encodeRanges: true },
+          this.callback
+        )
+
+        const encoded_changes = this.callback.args[0][4]
+        const encoded_change = encoded_changes.changes.pop()
+        const encoded_change_text = encoded_change.op.i
+        return encoded_change_text.should.equal('räksmörgås')
+      })
+
+      return it('should call the callback with the encoded delete change', function () {
+        this.ranges.changes = [{ op: { d: 'räksmörgås' } }]
+        this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          { encodeRanges: true },
+          this.callback
+        )
+
+        const encoded_changes = this.callback.args[0][4]
+        const encoded_change = encoded_changes.changes.pop()
+        const encoded_change_text = encoded_change.op.d
+        return encoded_change_text.should.equal('räksmörgås')
+      })
+    })
+
+    describe('when not authorized', function () {
+      beforeEach(function () {
+        this.AuthorizationManager.assertClientCanViewProject = sinon
+          .stub()
+          .callsArgWith(1, (this.err = new Error('not authorized')))
+        return this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          this.options,
+          this.callback
+        )
+      })
+
+      it('should call the callback with an error', function () {
+        return this.callback
+          .calledWith(sinon.match({ message: 'not authorized' }))
+          .should.equal(true)
+      })
+
+      return it('should not call the DocumentUpdaterManager', function () {
+        return this.DocumentUpdaterManager.getDocument.called.should.equal(
+          false
+        )
+      })
+    })
+
+    describe('with a restricted client', function () {
+      beforeEach(function () {
+        this.ranges.comments = [{ op: { a: 1 } }, { op: { a: 2 } }]
+        this.client.ol_context.is_restricted_user = true
+        return this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          this.options,
+          this.callback
+        )
+      })
+
+      return it('should overwrite ranges.comments with an empty list', function () {
+        const ranges = this.callback.args[0][4]
+        return expect(ranges.comments).to.deep.equal([])
+      })
+    })
+
+    describe('when the client has disconnected', function () {
+      beforeEach(function () {
+        this.client.disconnected = true
+        return this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          this.options,
+          this.callback
+        )
+      })
+
+      it('should call the callback with no details', function () {
+        return expect(this.callback.args[0]).to.deep.equal([])
+      })
+
+      it('should increment the editor.join-doc.disconnected metric with a status', function () {
+        return expect(
+          this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
+            status: 'immediately'
+          })
+        ).to.equal(true)
+      })
+
+      return it('should not get the document', function () {
+        return expect(this.DocumentUpdaterManager.getDocument.called).to.equal(
+          false
+        )
+      })
+    })
+
+    describe('when the client disconnects while RoomManager.joinDoc is running', function () {
+      beforeEach(function () {
+        this.RoomManager.joinDoc = (client, doc_id, cb) => {
+          this.client.disconnected = true
+          return cb()
+        }
+
+        return this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          this.options,
+          this.callback
+        )
+      })
+
+      it('should call the callback with no details', function () {
+        return expect(this.callback.args[0]).to.deep.equal([])
+      })
+
+      it('should increment the editor.join-doc.disconnected metric with a status', function () {
+        return expect(
+          this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
+            status: 'after-joining-room'
+          })
+        ).to.equal(true)
+      })
+
+      return it('should not get the document', function () {
+        return expect(this.DocumentUpdaterManager.getDocument.called).to.equal(
+          false
+        )
+      })
+    })
+
+    return describe('when the client disconnects while DocumentUpdaterManager.getDocument is running', function () {
+      beforeEach(function () {
+        this.DocumentUpdaterManager.getDocument = (
+          project_id,
+          doc_id,
+          fromVersion,
+          callback
+        ) => {
+          this.client.disconnected = true
+          return callback(
+            null,
+            this.doc_lines,
+            this.version,
+            this.ranges,
+            this.ops
+          )
+        }
+
+        return this.WebsocketController.joinDoc(
+          this.client,
+          this.doc_id,
+          -1,
+          this.options,
+          this.callback
+        )
+      })
+
+      it('should call the callback with no details', function () {
+        return expect(this.callback.args[0]).to.deep.equal([])
+      })
+
+      return it('should increment the editor.join-doc.disconnected metric with a status', function () {
+        return expect(
+          this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
+            status: 'after-doc-updater-call'
+          })
+        ).to.equal(true)
+      })
+    })
+  })
+
+  describe('leaveDoc', function () {
+    beforeEach(function () {
+      this.doc_id = 'doc-id-123'
+      this.client.ol_context.project_id = this.project_id
+      this.RoomManager.leaveDoc = sinon.stub()
+      return this.WebsocketController.leaveDoc(
+        this.client,
+        this.doc_id,
+        this.callback
+      )
+    })
+
+    it('should remove the client from the doc_id room', function () {
+      return this.RoomManager.leaveDoc
+        .calledWith(this.client, this.doc_id)
+        .should.equal(true)
+    })
+
+    it('should call the callback', function () {
+      return this.callback.called.should.equal(true)
+    })
+
+    return it('should increment the leave-doc metric', function () {
+      return this.metrics.inc.calledWith('editor.leave-doc').should.equal(true)
+    })
+  })
+
+  describe('getConnectedUsers', function () {
+    beforeEach(function () {
+      this.client.ol_context.project_id = this.project_id
+      this.users = ['mock', 'users']
+      this.WebsocketLoadBalancer.emitToRoom = sinon.stub()
+      return (this.ConnectedUsersManager.getConnectedUsers = sinon
+        .stub()
+        .callsArgWith(1, null, this.users))
+    })
+
+    describe('when authorized', function () {
+      beforeEach(function (done) {
+        this.AuthorizationManager.assertClientCanViewProject = sinon
+          .stub()
+          .callsArgWith(1, null)
+        return this.WebsocketController.getConnectedUsers(
+          this.client,
+          (...args) => {
+            this.callback(...Array.from(args || []))
+            return done()
+          }
+        )
+      })
+
+      it('should check that the client is authorized to view the project', function () {
+        return this.AuthorizationManager.assertClientCanViewProject
+          .calledWith(this.client)
+          .should.equal(true)
+      })
+
+      it('should broadcast a request to update the client list', function () {
+        return this.WebsocketLoadBalancer.emitToRoom
+          .calledWith(this.project_id, 'clientTracking.refresh')
+          .should.equal(true)
+      })
+
+      it('should get the connected users for the project', function () {
+        return this.ConnectedUsersManager.getConnectedUsers
+          .calledWith(this.project_id)
+          .should.equal(true)
+      })
+
+      it('should return the users', function () {
+        return this.callback.calledWith(null, this.users).should.equal(true)
+      })
+
+      return it('should increment the get-connected-users metric', function () {
+        return this.metrics.inc
+          .calledWith('editor.get-connected-users')
+          .should.equal(true)
+      })
+    })
+
+    describe('when not authorized', function () {
+      beforeEach(function () {
+        this.AuthorizationManager.assertClientCanViewProject = sinon
+          .stub()
+          .callsArgWith(1, (this.err = new Error('not authorized')))
+        return this.WebsocketController.getConnectedUsers(
+          this.client,
+          this.callback
+        )
+      })
+
+      it('should not get the connected users for the project', function () {
+        return this.ConnectedUsersManager.getConnectedUsers.called.should.equal(
+          false
+        )
+      })
+
+      return it('should return an error', function () {
+        return this.callback.calledWith(this.err).should.equal(true)
+      })
+    })
+
+    describe('when restricted user', function () {
+      beforeEach(function () {
+        this.client.ol_context.is_restricted_user = true
+        this.AuthorizationManager.assertClientCanViewProject = sinon
+          .stub()
+          .callsArgWith(1, null)
+        return this.WebsocketController.getConnectedUsers(
+          this.client,
+          this.callback
+        )
+      })
+
+      it('should return an empty array of users', function () {
+        return this.callback.calledWith(null, []).should.equal(true)
+      })
+
+      return it('should not get the connected users for the project', function () {
+        return this.ConnectedUsersManager.getConnectedUsers.called.should.equal(
+          false
+        )
+      })
+    })
+
+    return describe('when the client has disconnected', function () {
+      beforeEach(function () {
+        this.client.disconnected = true
+        this.AuthorizationManager.assertClientCanViewProject = sinon.stub()
+        return this.WebsocketController.getConnectedUsers(
+          this.client,
+          this.callback
+        )
+      })
+
+      it('should call the callback with no details', function () {
+        return expect(this.callback.args[0]).to.deep.equal([])
+      })
+
+      return it('should not check permissions', function () {
+        return expect(
+          this.AuthorizationManager.assertClientCanViewProject.called
+        ).to.equal(false)
+      })
+    })
+  })
+
+  describe('updateClientPosition', function () {
+    beforeEach(function () {
+      this.WebsocketLoadBalancer.emitToRoom = sinon.stub()
+      this.ConnectedUsersManager.updateUserPosition = sinon
+        .stub()
+        .callsArgWith(4)
+      this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon
+        .stub()
+        .callsArgWith(2, null)
+      return (this.update = {
+        doc_id: (this.doc_id = 'doc-id-123'),
+        row: (this.row = 42),
+        column: (this.column = 37)
+      })
+    })
+
+    describe('with a logged in user', function () {
+      beforeEach(function () {
+        this.client.ol_context = {
+          project_id: this.project_id,
+          first_name: (this.first_name = 'Douglas'),
+          last_name: (this.last_name = 'Adams'),
+          email: (this.email = 'joe@example.com'),
+          user_id: (this.user_id = 'user-id-123')
+        }
+        this.WebsocketController.updateClientPosition(this.client, this.update)
+
+        return (this.populatedCursorData = {
+          doc_id: this.doc_id,
+          id: this.client.publicId,
+          name: `${this.first_name} ${this.last_name}`,
+          row: this.row,
+          column: this.column,
+          email: this.email,
+          user_id: this.user_id
+        })
+      })
+
+      it("should send the update to the project room with the user's name", function () {
+        return this.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            this.project_id,
+            'clientTracking.clientUpdated',
+            this.populatedCursorData
+          )
+          .should.equal(true)
+      })
+
+      it('should send the  cursor data to the connected user manager', function (done) {
+        this.ConnectedUsersManager.updateUserPosition
+          .calledWith(
+            this.project_id,
+            this.client.publicId,
+            {
+              _id: this.user_id,
+              email: this.email,
+              first_name: this.first_name,
+              last_name: this.last_name
+            },
+            {
+              row: this.row,
+              column: this.column,
+              doc_id: this.doc_id
+            }
+          )
+          .should.equal(true)
+        return done()
+      })
+
+      return it('should increment the update-client-position metric at 0.1 frequency', function () {
+        return this.metrics.inc
+          .calledWith('editor.update-client-position', 0.1)
+          .should.equal(true)
+      })
+    })
+
+    describe('with a logged in user who has no last_name set', function () {
+      beforeEach(function () {
+        this.client.ol_context = {
+          project_id: this.project_id,
+          first_name: (this.first_name = 'Douglas'),
+          last_name: undefined,
+          email: (this.email = 'joe@example.com'),
+          user_id: (this.user_id = 'user-id-123')
+        }
+        this.WebsocketController.updateClientPosition(this.client, this.update)
+
+        return (this.populatedCursorData = {
+          doc_id: this.doc_id,
+          id: this.client.publicId,
+          name: `${this.first_name}`,
+          row: this.row,
+          column: this.column,
+          email: this.email,
+          user_id: this.user_id
+        })
+      })
+
+      it("should send the update to the project room with the user's name", function () {
+        return this.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            this.project_id,
+            'clientTracking.clientUpdated',
+            this.populatedCursorData
+          )
+          .should.equal(true)
+      })
+
+      it('should send the  cursor data to the connected user manager', function (done) {
+        this.ConnectedUsersManager.updateUserPosition
+          .calledWith(
+            this.project_id,
+            this.client.publicId,
+            {
+              _id: this.user_id,
+              email: this.email,
+              first_name: this.first_name,
+              last_name: undefined
+            },
+            {
+              row: this.row,
+              column: this.column,
+              doc_id: this.doc_id
+            }
+          )
+          .should.equal(true)
+        return done()
+      })
+
+      return it('should increment the update-client-position metric at 0.1 frequency', function () {
+        return this.metrics.inc
+          .calledWith('editor.update-client-position', 0.1)
+          .should.equal(true)
+      })
+    })
+
+    describe('with a logged in user who has no first_name set', function () {
+      beforeEach(function () {
+        this.client.ol_context = {
+          project_id: this.project_id,
+          first_name: undefined,
+          last_name: (this.last_name = 'Adams'),
+          email: (this.email = 'joe@example.com'),
+          user_id: (this.user_id = 'user-id-123')
+        }
+        this.WebsocketController.updateClientPosition(this.client, this.update)
+
+        return (this.populatedCursorData = {
+          doc_id: this.doc_id,
+          id: this.client.publicId,
+          name: `${this.last_name}`,
+          row: this.row,
+          column: this.column,
+          email: this.email,
+          user_id: this.user_id
+        })
+      })
+
+      it("should send the update to the project room with the user's name", function () {
+        return this.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            this.project_id,
+            'clientTracking.clientUpdated',
+            this.populatedCursorData
+          )
+          .should.equal(true)
+      })
+
+      it('should send the  cursor data to the connected user manager', function (done) {
+        this.ConnectedUsersManager.updateUserPosition
+          .calledWith(
+            this.project_id,
+            this.client.publicId,
+            {
+              _id: this.user_id,
+              email: this.email,
+              first_name: undefined,
+              last_name: this.last_name
+            },
+            {
+              row: this.row,
+              column: this.column,
+              doc_id: this.doc_id
+            }
+          )
+          .should.equal(true)
+        return done()
+      })
+
+      return it('should increment the update-client-position metric at 0.1 frequency', function () {
+        return this.metrics.inc
+          .calledWith('editor.update-client-position', 0.1)
+          .should.equal(true)
+      })
+    })
+    describe('with a logged in user who has no names set', function () {
+      beforeEach(function () {
+        this.client.ol_context = {
+          project_id: this.project_id,
+          first_name: undefined,
+          last_name: undefined,
+          email: (this.email = 'joe@example.com'),
+          user_id: (this.user_id = 'user-id-123')
+        }
+        return this.WebsocketController.updateClientPosition(
+          this.client,
+          this.update
+        )
+      })
+
+      return it('should send the update to the project name with no name', function () {
+        return this.WebsocketLoadBalancer.emitToRoom
+          .calledWith(this.project_id, 'clientTracking.clientUpdated', {
+            doc_id: this.doc_id,
+            id: this.client.publicId,
+            user_id: this.user_id,
+            name: '',
+            row: this.row,
+            column: this.column,
+            email: this.email
+          })
+          .should.equal(true)
+      })
+    })
+
+    describe('with an anonymous user', function () {
+      beforeEach(function () {
+        this.client.ol_context = {
+          project_id: this.project_id
+        }
+        return this.WebsocketController.updateClientPosition(
+          this.client,
+          this.update
+        )
+      })
+
+      it('should send the update to the project room with no name', function () {
+        return this.WebsocketLoadBalancer.emitToRoom
+          .calledWith(this.project_id, 'clientTracking.clientUpdated', {
+            doc_id: this.doc_id,
+            id: this.client.publicId,
+            name: '',
+            row: this.row,
+            column: this.column
+          })
+          .should.equal(true)
+      })
+
+      return it('should not send cursor data to the connected user manager', function (done) {
+        this.ConnectedUsersManager.updateUserPosition.called.should.equal(false)
+        return done()
+      })
+    })
+
+    return describe('when the client has disconnected', function () {
+      beforeEach(function () {
+        this.client.disconnected = true
+        this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub()
+        return this.WebsocketController.updateClientPosition(
+          this.client,
+          this.update,
+          this.callback
+        )
+      })
+
+      it('should call the callback with no details', function () {
+        return expect(this.callback.args[0]).to.deep.equal([])
+      })
+
+      return it('should not check permissions', function () {
+        return expect(
+          this.AuthorizationManager.assertClientCanViewProjectAndDoc.called
+        ).to.equal(false)
+      })
+    })
+  })
+
+  describe('applyOtUpdate', function () {
+    beforeEach(function () {
+      this.update = { op: { p: 12, t: 'foo' } }
+      this.client.ol_context.user_id = this.user_id
+      this.client.ol_context.project_id = this.project_id
+      this.WebsocketController._assertClientCanApplyUpdate = sinon
+        .stub()
+        .yields()
+      return (this.DocumentUpdaterManager.queueChange = sinon
+        .stub()
+        .callsArg(3))
+    })
+
+    describe('succesfully', function () {
+      beforeEach(function () {
+        return this.WebsocketController.applyOtUpdate(
+          this.client,
+          this.doc_id,
+          this.update,
+          this.callback
+        )
+      })
+
+      it('should set the source of the update to the client id', function () {
+        return this.update.meta.source.should.equal(this.client.publicId)
+      })
+
+      it('should set the user_id of the update to the user id', function () {
+        return this.update.meta.user_id.should.equal(this.user_id)
+      })
+
+      it('should queue the update', function () {
+        return this.DocumentUpdaterManager.queueChange
+          .calledWith(this.project_id, this.doc_id, this.update)
+          .should.equal(true)
+      })
+
+      it('should call the callback', function () {
+        return this.callback.called.should.equal(true)
+      })
+
+      return it('should increment the doc updates', function () {
+        return this.metrics.inc
+          .calledWith('editor.doc-update')
+          .should.equal(true)
+      })
+    })
+
+    describe('unsuccessfully', function () {
+      beforeEach(function () {
+        this.client.disconnect = sinon.stub()
+        this.DocumentUpdaterManager.queueChange = sinon
+          .stub()
+          .callsArgWith(3, (this.error = new Error('Something went wrong')))
+        return this.WebsocketController.applyOtUpdate(
+          this.client,
+          this.doc_id,
+          this.update,
+          this.callback
+        )
+      })
+
+      it('should disconnect the client', function () {
+        return this.client.disconnect.called.should.equal(true)
+      })
+
+      it('should log an error', function () {
+        return this.logger.error.called.should.equal(true)
+      })
+
+      return it('should call the callback with the error', function () {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    describe('when not authorized', function () {
+      beforeEach(function () {
+        this.client.disconnect = sinon.stub()
+        this.WebsocketController._assertClientCanApplyUpdate = sinon
+          .stub()
+          .yields((this.error = new Error('not authorized')))
+        return this.WebsocketController.applyOtUpdate(
+          this.client,
+          this.doc_id,
+          this.update,
+          this.callback
+        )
+      })
+
+      // This happens in a setTimeout to allow the client a chance to receive the error first.
+      // I'm not sure how to unit test, but it is acceptance tested.
+      // it "should disconnect the client", ->
+      // 	@client.disconnect.called.should.equal true
+
+      it('should log a warning', function () {
+        return this.logger.warn.called.should.equal(true)
+      })
+
+      return it('should call the callback with the error', function () {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+    })
+
+    return describe('update_too_large', function () {
+      beforeEach(function (done) {
+        this.client.disconnect = sinon.stub()
+        this.client.emit = sinon.stub()
+        this.client.ol_context.user_id = this.user_id
+        this.client.ol_context.project_id = this.project_id
+        const error = new Error('update is too large')
+        error.updateSize = 7372835
+        this.DocumentUpdaterManager.queueChange = sinon
+          .stub()
+          .callsArgWith(3, error)
+        this.WebsocketController.applyOtUpdate(
+          this.client,
+          this.doc_id,
+          this.update,
+          this.callback
+        )
+        return setTimeout(() => done(), 1)
+      })
+
+      it('should call the callback with no error', function () {
+        this.callback.called.should.equal(true)
+        return this.callback.args[0].should.deep.equal([])
+      })
+
+      it('should log a warning with the size and context', function () {
+        this.logger.warn.called.should.equal(true)
+        return this.logger.warn.args[0].should.deep.equal([
+          {
+            user_id: this.user_id,
+            project_id: this.project_id,
+            doc_id: this.doc_id,
+            updateSize: 7372835
+          },
+          'update is too large'
+        ])
+      })
+
+      describe('after 100ms', function () {
+        beforeEach(function (done) {
+          return setTimeout(done, 100)
+        })
+
+        it('should send an otUpdateError the client', function () {
+          return this.client.emit.calledWith('otUpdateError').should.equal(true)
+        })
+
+        return it('should disconnect the client', function () {
+          return this.client.disconnect.called.should.equal(true)
+        })
+      })
+
+      return describe('when the client disconnects during the next 100ms', function () {
+        beforeEach(function (done) {
+          this.client.disconnected = true
+          return setTimeout(done, 100)
+        })
+
+        it('should not send an otUpdateError the client', function () {
+          return this.client.emit
+            .calledWith('otUpdateError')
+            .should.equal(false)
+        })
+
+        it('should not disconnect the client', function () {
+          return this.client.disconnect.called.should.equal(false)
+        })
+
+        return it('should increment the editor.doc-update.disconnected metric with a status', function () {
+          return expect(
+            this.metrics.inc.calledWith('editor.doc-update.disconnected', 1, {
+              status: 'at-otUpdateError'
+            })
+          ).to.equal(true)
+        })
+      })
+    })
+  })
+
+  return describe('_assertClientCanApplyUpdate', function () {
+    beforeEach(function () {
+      this.edit_update = {
+        op: [
+          { i: 'foo', p: 42 },
+          { c: 'bar', p: 132 }
+        ]
+      } // comments may still be in an edit op
+      this.comment_update = { op: [{ c: 'bar', p: 132 }] }
+      this.AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub()
+      return (this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub())
+    })
+
+    describe('with a read-write client', function () {
+      return it('should return successfully', function (done) {
+        this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null)
+        return this.WebsocketController._assertClientCanApplyUpdate(
+          this.client,
+          this.doc_id,
+          this.edit_update,
+          (error) => {
+            expect(error).to.be.null
+            return done()
+          }
+        )
+      })
+    })
+
+    describe('with a read-only client and an edit op', function () {
+      return it('should return an error', function (done) {
+        this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
+          new Error('not authorized')
+        )
+        this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
+        return this.WebsocketController._assertClientCanApplyUpdate(
+          this.client,
+          this.doc_id,
+          this.edit_update,
+          (error) => {
+            expect(error.message).to.equal('not authorized')
+            return done()
+          }
+        )
+      })
+    })
+
+    describe('with a read-only client and a comment op', function () {
+      return it('should return successfully', function (done) {
+        this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
+          new Error('not authorized')
+        )
+        this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
+        return this.WebsocketController._assertClientCanApplyUpdate(
+          this.client,
+          this.doc_id,
+          this.comment_update,
+          (error) => {
+            expect(error).to.be.null
+            return done()
+          }
+        )
+      })
+    })
+
+    return describe('with a totally unauthorized client', function () {
+      return it('should return an error', function (done) {
+        this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
+          new Error('not authorized')
+        )
+        this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
+          new Error('not authorized')
+        )
+        return this.WebsocketController._assertClientCanApplyUpdate(
+          this.client,
+          this.doc_id,
+          this.comment_update,
+          (error) => {
+            expect(error.message).to.equal('not authorized')
+            return done()
+          }
+        )
+      })
+    })
+  })
+})

+ 298 - 198
services/real-time/test/unit/js/WebsocketLoadBalancerTests.js

@@ -9,201 +9,301 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/WebsocketLoadBalancer');
-
-describe("WebsocketLoadBalancer", function() {
-	beforeEach(function() {
-		this.rclient = {};
-		this.RoomEvents = {on: sinon.stub()};
-		this.WebsocketLoadBalancer = SandboxedModule.require(modulePath, { requires: {
-			"./RedisClientManager": {
-				createClientList: () => []
-			},
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			"./SafeJsonParse": (this.SafeJsonParse =
-				{parse: (data, cb) => cb(null, JSON.parse(data))}),
-			"./EventLogger": {checkEventOrder: sinon.stub()},
-			"./HealthCheckManager": {check: sinon.stub()},
-			"./RoomManager" : (this.RoomManager = {eventSource: sinon.stub().returns(this.RoomEvents)}),
-			"./ChannelManager": (this.ChannelManager = {publish: sinon.stub()}),
-			"./ConnectedUsersManager": (this.ConnectedUsersManager = {refreshClient: sinon.stub()})
-		}
-	});
-		this.io = {};
-		this.WebsocketLoadBalancer.rclientPubList = [{publish: sinon.stub()}];
-		this.WebsocketLoadBalancer.rclientSubList = [{
-			subscribe: sinon.stub(),
-			on: sinon.stub()
-		}];
-
-		this.room_id = "room-id";
-		this.message = "otUpdateApplied";
-		return this.payload = ["argument one", 42];});
-
-	describe("emitToRoom", function() {
-		beforeEach(function() {
-			return this.WebsocketLoadBalancer.emitToRoom(this.room_id, this.message, ...Array.from(this.payload));
-		});
-
-		return it("should publish the message to redis", function() {
-			return this.ChannelManager.publish
-				.calledWith(this.WebsocketLoadBalancer.rclientPubList[0], "editor-events", this.room_id, JSON.stringify({
-					room_id: this.room_id,
-					message: this.message,
-					payload: this.payload
-				}))
-				.should.equal(true);
-		});
-	});
-
-	describe("emitToAll", function() {
-		beforeEach(function() {
-			this.WebsocketLoadBalancer.emitToRoom = sinon.stub();
-			return this.WebsocketLoadBalancer.emitToAll(this.message, ...Array.from(this.payload));
-		});
-
-		return it("should emit to the room 'all'", function() {
-			return this.WebsocketLoadBalancer.emitToRoom
-				.calledWith("all", this.message, ...Array.from(this.payload))
-				.should.equal(true);
-		});
-	});
-
-	describe("listenForEditorEvents", function() {
-		beforeEach(function() {
-			this.WebsocketLoadBalancer._processEditorEvent = sinon.stub();
-			return this.WebsocketLoadBalancer.listenForEditorEvents();
-		});
-
-		it("should subscribe to the editor-events channel", function() {
-			return this.WebsocketLoadBalancer.rclientSubList[0].subscribe
-				.calledWith("editor-events")
-				.should.equal(true);
-		});
-
-		return it("should process the events with _processEditorEvent", function() {
-			return this.WebsocketLoadBalancer.rclientSubList[0].on
-				.calledWith("message", sinon.match.func)
-				.should.equal(true);
-		});
-	});
-
-	return describe("_processEditorEvent", function() {
-		describe("with bad JSON", function() {
-			beforeEach(function() {
-				this.isRestrictedUser = false;
-				this.SafeJsonParse.parse = sinon.stub().callsArgWith(1, new Error("oops"));
-				return this.WebsocketLoadBalancer._processEditorEvent(this.io, "editor-events", "blah");
-			});
-
-			return it("should log an error", function() {
-				return this.logger.error.called.should.equal(true);
-			});
-		});
-
-		describe("with a designated room", function() {
-			beforeEach(function() {
-				this.io.sockets = {
-					clients: sinon.stub().returns([
-						{id: 'client-id-1', emit: (this.emit1 = sinon.stub()), ol_context: {}},
-						{id: 'client-id-2', emit: (this.emit2 = sinon.stub()), ol_context: {}},
-						{id: 'client-id-1', emit: (this.emit3 = sinon.stub()), ol_context: {}} // duplicate client
-					])
-				};
-				const data = JSON.stringify({
-					room_id: this.room_id,
-					message: this.message,
-					payload: this.payload
-				});
-				return this.WebsocketLoadBalancer._processEditorEvent(this.io, "editor-events", data);
-			});
-
-			return it("should send the message to all (unique) clients in the room", function() {
-				this.io.sockets.clients
-					.calledWith(this.room_id)
-					.should.equal(true);
-				this.emit1.calledWith(this.message, ...Array.from(this.payload)).should.equal(true);
-				this.emit2.calledWith(this.message, ...Array.from(this.payload)).should.equal(true);
-				return this.emit3.called.should.equal(false);
-			});
-		}); // duplicate client should be ignored
-
-		describe("with a designated room, and restricted clients, not restricted message", function() {
-			beforeEach(function() {
-				this.io.sockets = {
-					clients: sinon.stub().returns([
-						{id: 'client-id-1', emit: (this.emit1 = sinon.stub()), ol_context: {}},
-						{id: 'client-id-2', emit: (this.emit2 = sinon.stub()), ol_context: {}},
-						{id: 'client-id-1', emit: (this.emit3 = sinon.stub()), ol_context: {}}, // duplicate client
-						{id: 'client-id-4', emit: (this.emit4 = sinon.stub()), ol_context: {is_restricted_user: true}}
-					])
-				};
-				const data = JSON.stringify({
-					room_id: this.room_id,
-					message: this.message,
-					payload: this.payload
-				});
-				return this.WebsocketLoadBalancer._processEditorEvent(this.io, "editor-events", data);
-			});
-
-			return it("should send the message to all (unique) clients in the room", function() {
-				this.io.sockets.clients
-					.calledWith(this.room_id)
-					.should.equal(true);
-				this.emit1.calledWith(this.message, ...Array.from(this.payload)).should.equal(true);
-				this.emit2.calledWith(this.message, ...Array.from(this.payload)).should.equal(true);
-				this.emit3.called.should.equal(false); // duplicate client should be ignored
-				return this.emit4.called.should.equal(true);
-			});
-		});  // restricted client, but should be called
-
-		describe("with a designated room, and restricted clients, restricted message", function() {
-			beforeEach(function() {
-				this.io.sockets = {
-					clients: sinon.stub().returns([
-						{id: 'client-id-1', emit: (this.emit1 = sinon.stub()), ol_context: {}},
-						{id: 'client-id-2', emit: (this.emit2 = sinon.stub()), ol_context: {}},
-						{id: 'client-id-1', emit: (this.emit3 = sinon.stub()), ol_context: {}}, // duplicate client
-						{id: 'client-id-4', emit: (this.emit4 = sinon.stub()), ol_context: {is_restricted_user: true}}
-					])
-				};
-				const data = JSON.stringify({
-					room_id: this.room_id,
-					message: (this.restrictedMessage = 'new-comment'),
-					payload: this.payload
-				});
-				return this.WebsocketLoadBalancer._processEditorEvent(this.io, "editor-events", data);
-			});
-
-			return it("should send the message to all (unique) clients in the room, who are not restricted", function() {
-				this.io.sockets.clients
-					.calledWith(this.room_id)
-					.should.equal(true);
-				this.emit1.calledWith(this.restrictedMessage, ...Array.from(this.payload)).should.equal(true);
-				this.emit2.calledWith(this.restrictedMessage, ...Array.from(this.payload)).should.equal(true);
-				this.emit3.called.should.equal(false); // duplicate client should be ignored
-				return this.emit4.called.should.equal(false);
-			});
-		}); // restricted client, should not be called
-
-		return describe("when emitting to all", function() {
-			beforeEach(function() {
-				this.io.sockets =
-					{emit: (this.emit = sinon.stub())};
-				const data = JSON.stringify({
-					room_id: "all",
-					message: this.message,
-					payload: this.payload
-				});
-				return this.WebsocketLoadBalancer._processEditorEvent(this.io, "editor-events", data);
-			});
-
-			return it("should send the message to all clients", function() {
-				return this.emit.calledWith(this.message, ...Array.from(this.payload)).should.equal(true);
-			});
-		});
-	});
-});
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/WebsocketLoadBalancer'
+)
+
+describe('WebsocketLoadBalancer', function () {
+  beforeEach(function () {
+    this.rclient = {}
+    this.RoomEvents = { on: sinon.stub() }
+    this.WebsocketLoadBalancer = SandboxedModule.require(modulePath, {
+      requires: {
+        './RedisClientManager': {
+          createClientList: () => []
+        },
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        './SafeJsonParse': (this.SafeJsonParse = {
+          parse: (data, cb) => cb(null, JSON.parse(data))
+        }),
+        './EventLogger': { checkEventOrder: sinon.stub() },
+        './HealthCheckManager': { check: sinon.stub() },
+        './RoomManager': (this.RoomManager = {
+          eventSource: sinon.stub().returns(this.RoomEvents)
+        }),
+        './ChannelManager': (this.ChannelManager = { publish: sinon.stub() }),
+        './ConnectedUsersManager': (this.ConnectedUsersManager = {
+          refreshClient: sinon.stub()
+        })
+      }
+    })
+    this.io = {}
+    this.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }]
+    this.WebsocketLoadBalancer.rclientSubList = [
+      {
+        subscribe: sinon.stub(),
+        on: sinon.stub()
+      }
+    ]
+
+    this.room_id = 'room-id'
+    this.message = 'otUpdateApplied'
+    return (this.payload = ['argument one', 42])
+  })
+
+  describe('emitToRoom', function () {
+    beforeEach(function () {
+      return this.WebsocketLoadBalancer.emitToRoom(
+        this.room_id,
+        this.message,
+        ...Array.from(this.payload)
+      )
+    })
+
+    return it('should publish the message to redis', function () {
+      return this.ChannelManager.publish
+        .calledWith(
+          this.WebsocketLoadBalancer.rclientPubList[0],
+          'editor-events',
+          this.room_id,
+          JSON.stringify({
+            room_id: this.room_id,
+            message: this.message,
+            payload: this.payload
+          })
+        )
+        .should.equal(true)
+    })
+  })
+
+  describe('emitToAll', function () {
+    beforeEach(function () {
+      this.WebsocketLoadBalancer.emitToRoom = sinon.stub()
+      return this.WebsocketLoadBalancer.emitToAll(
+        this.message,
+        ...Array.from(this.payload)
+      )
+    })
+
+    return it("should emit to the room 'all'", function () {
+      return this.WebsocketLoadBalancer.emitToRoom
+        .calledWith('all', this.message, ...Array.from(this.payload))
+        .should.equal(true)
+    })
+  })
+
+  describe('listenForEditorEvents', function () {
+    beforeEach(function () {
+      this.WebsocketLoadBalancer._processEditorEvent = sinon.stub()
+      return this.WebsocketLoadBalancer.listenForEditorEvents()
+    })
+
+    it('should subscribe to the editor-events channel', function () {
+      return this.WebsocketLoadBalancer.rclientSubList[0].subscribe
+        .calledWith('editor-events')
+        .should.equal(true)
+    })
+
+    return it('should process the events with _processEditorEvent', function () {
+      return this.WebsocketLoadBalancer.rclientSubList[0].on
+        .calledWith('message', sinon.match.func)
+        .should.equal(true)
+    })
+  })
+
+  return describe('_processEditorEvent', function () {
+    describe('with bad JSON', function () {
+      beforeEach(function () {
+        this.isRestrictedUser = false
+        this.SafeJsonParse.parse = sinon
+          .stub()
+          .callsArgWith(1, new Error('oops'))
+        return this.WebsocketLoadBalancer._processEditorEvent(
+          this.io,
+          'editor-events',
+          'blah'
+        )
+      })
+
+      return it('should log an error', function () {
+        return this.logger.error.called.should.equal(true)
+      })
+    })
+
+    describe('with a designated room', function () {
+      beforeEach(function () {
+        this.io.sockets = {
+          clients: sinon.stub().returns([
+            {
+              id: 'client-id-1',
+              emit: (this.emit1 = sinon.stub()),
+              ol_context: {}
+            },
+            {
+              id: 'client-id-2',
+              emit: (this.emit2 = sinon.stub()),
+              ol_context: {}
+            },
+            {
+              id: 'client-id-1',
+              emit: (this.emit3 = sinon.stub()),
+              ol_context: {}
+            } // duplicate client
+          ])
+        }
+        const data = JSON.stringify({
+          room_id: this.room_id,
+          message: this.message,
+          payload: this.payload
+        })
+        return this.WebsocketLoadBalancer._processEditorEvent(
+          this.io,
+          'editor-events',
+          data
+        )
+      })
+
+      return it('should send the message to all (unique) clients in the room', function () {
+        this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
+        this.emit1
+          .calledWith(this.message, ...Array.from(this.payload))
+          .should.equal(true)
+        this.emit2
+          .calledWith(this.message, ...Array.from(this.payload))
+          .should.equal(true)
+        return this.emit3.called.should.equal(false)
+      })
+    }) // duplicate client should be ignored
+
+    describe('with a designated room, and restricted clients, not restricted message', function () {
+      beforeEach(function () {
+        this.io.sockets = {
+          clients: sinon.stub().returns([
+            {
+              id: 'client-id-1',
+              emit: (this.emit1 = sinon.stub()),
+              ol_context: {}
+            },
+            {
+              id: 'client-id-2',
+              emit: (this.emit2 = sinon.stub()),
+              ol_context: {}
+            },
+            {
+              id: 'client-id-1',
+              emit: (this.emit3 = sinon.stub()),
+              ol_context: {}
+            }, // duplicate client
+            {
+              id: 'client-id-4',
+              emit: (this.emit4 = sinon.stub()),
+              ol_context: { is_restricted_user: true }
+            }
+          ])
+        }
+        const data = JSON.stringify({
+          room_id: this.room_id,
+          message: this.message,
+          payload: this.payload
+        })
+        return this.WebsocketLoadBalancer._processEditorEvent(
+          this.io,
+          'editor-events',
+          data
+        )
+      })
+
+      return it('should send the message to all (unique) clients in the room', function () {
+        this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
+        this.emit1
+          .calledWith(this.message, ...Array.from(this.payload))
+          .should.equal(true)
+        this.emit2
+          .calledWith(this.message, ...Array.from(this.payload))
+          .should.equal(true)
+        this.emit3.called.should.equal(false) // duplicate client should be ignored
+        return this.emit4.called.should.equal(true)
+      })
+    }) // restricted client, but should be called
+
+    describe('with a designated room, and restricted clients, restricted message', function () {
+      beforeEach(function () {
+        this.io.sockets = {
+          clients: sinon.stub().returns([
+            {
+              id: 'client-id-1',
+              emit: (this.emit1 = sinon.stub()),
+              ol_context: {}
+            },
+            {
+              id: 'client-id-2',
+              emit: (this.emit2 = sinon.stub()),
+              ol_context: {}
+            },
+            {
+              id: 'client-id-1',
+              emit: (this.emit3 = sinon.stub()),
+              ol_context: {}
+            }, // duplicate client
+            {
+              id: 'client-id-4',
+              emit: (this.emit4 = sinon.stub()),
+              ol_context: { is_restricted_user: true }
+            }
+          ])
+        }
+        const data = JSON.stringify({
+          room_id: this.room_id,
+          message: (this.restrictedMessage = 'new-comment'),
+          payload: this.payload
+        })
+        return this.WebsocketLoadBalancer._processEditorEvent(
+          this.io,
+          'editor-events',
+          data
+        )
+      })
+
+      return it('should send the message to all (unique) clients in the room, who are not restricted', function () {
+        this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
+        this.emit1
+          .calledWith(this.restrictedMessage, ...Array.from(this.payload))
+          .should.equal(true)
+        this.emit2
+          .calledWith(this.restrictedMessage, ...Array.from(this.payload))
+          .should.equal(true)
+        this.emit3.called.should.equal(false) // duplicate client should be ignored
+        return this.emit4.called.should.equal(false)
+      })
+    }) // restricted client, should not be called
+
+    return describe('when emitting to all', function () {
+      beforeEach(function () {
+        this.io.sockets = { emit: (this.emit = sinon.stub()) }
+        const data = JSON.stringify({
+          room_id: 'all',
+          message: this.message,
+          payload: this.payload
+        })
+        return this.WebsocketLoadBalancer._processEditorEvent(
+          this.io,
+          'editor-events',
+          data
+        )
+      })
+
+      return it('should send the message to all clients', function () {
+        return this.emit
+          .calledWith(this.message, ...Array.from(this.payload))
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 14 - 14
services/real-time/test/unit/js/helpers/MockClient.js

@@ -3,20 +3,20 @@
 */
 */
 // TODO: This file was created by bulk-decaffeinate.
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
 // Fix any style issues and re-enable lint.
-let MockClient;
-const sinon = require('sinon');
+let MockClient
+const sinon = require('sinon')
 
 
-let idCounter = 0;
+let idCounter = 0
 
 
-module.exports = (MockClient = class MockClient {
-	constructor() {
-		this.ol_context = {};
-		this.join = sinon.stub();
-		this.emit = sinon.stub();
-		this.disconnect = sinon.stub();
-		this.id = idCounter++;
-		this.publicId = idCounter++;
-	}
+module.exports = MockClient = class MockClient {
+  constructor() {
+    this.ol_context = {}
+    this.join = sinon.stub()
+    this.emit = sinon.stub()
+    this.disconnect = sinon.stub()
+    this.id = idCounter++
+    this.publicId = idCounter++
+  }
 
 
-	disconnect() {}
-});
+  disconnect() {}
+}